Distribution Polish for Laravel Packages

9 min read

The first minute after composer require decides whether a package feels trustworthy. A README is not a sales page and it is not a dump of every class in src/. It is an adoption path: install the package, configure the one value that matters, run one command or make one call, and observe a useful result. Everything else—architecture, extension points and release history—can come after that successful first interaction.

Use a concrete package throughout this article: laravel-webhook-tap receives verified webhook deliveries and stores them for inspection. The package is only an example; the release discipline applies equally to mail, billing and admin packages. Distribution polish is engineering work because unclear installation instructions produce broken production configuration and support tickets.

Write the shortest successful path first

Start the README with requirements and the exact installation sequence. Do not make the reader infer whether migrations are automatic, whether package discovery is used, or which config tag to publish.

md
## Requirements

- PHP 8.3 or newer
- Laravel 11, 12 or 13

## Install

```bash
composer require dominik-dev/laravel-webhook-tap
php artisan vendor:publish --tag=webhook-tap-config
php artisan migrate

Set WEBHOOK_TAP_SECRET in .env, then add the route shown below.


The command order is part of the product. Publishing configuration before the
reader sees the route tells them where the secret comes from. A migration command
must appear when the package owns a table; omitting it does not make migration
safe, it merely moves the failure to the first delivery. If the package uses
auto-discovered migrations, say that explicitly and do not ask users to run a
redundant publish command.

Then show one complete route. A reader should be able to paste it into a fresh
application without needing to guess imports, middleware or controller syntax.

```php
<?php

declare(strict_types=1);

use DominikDev\WebhookTap\Http\Controllers\WebhookDeliveryController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/billing', WebhookDeliveryController::class)
    ->middleware('throttle:billing-webhooks')
    ->name('webhooks.billing');

The example deliberately does not claim that CSRF middleware is required or removed: that depends on the application's API/web middleware topology. Explain the required property instead—this endpoint must accept the provider's request and it must be rate-limited at an application-appropriate boundary. Documentation that blindly says “exclude this from CSRF” is a security footgun disguised as a copy-and-paste instruction.

Make configuration discoverable and safe

Document configuration by showing the shipped file, its defaults and the effect of each value. Avoid a table that drifts away from real code. This example has no env() calls outside a config file and no secret hard-coded in a route.

php
<?php

declare(strict_types=1);

return [
    'secret' => env('WEBHOOK_TAP_SECRET'),
    'signature_header' => 'X-Billing-Signature',
    'store_payloads' => true,
    'retention_days' => 30,
];

Explain that the package reads config('webhook-tap.secret'), not env() at runtime, so config:cache remains valid. Explain what happens when a required secret is absent: fail on boot with a useful exception, reject every delivery, or allow a disabled local mode. Each can be reasonable, but the default must be unambiguous. In a security-sensitive integration, silently accepting unsigned deliveries is never a convenient default.

The first public call site should be just as complete. Here a controller gets the recorder from Laravel's container rather than using a facade that hides its dependencies:

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use DominikDev\WebhookTap\WebhookRecorder;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class BillingWebhookController
{
    public function __invoke(Request $request, WebhookRecorder $recorder): Response
    {
        $recorder->record(
            payload: $request->getContent(),
            signature: (string) $request->header('X-Billing-Signature'),
        );

        return response()->noContent();
    }
}

When a package provides a facade, show both the facade and the injectable contract, then recommend the contract for application code that benefits from visible dependencies. Hiding the core service behind a static API can make a quick example pleasant, but it should not obscure how tests replace it.

Prove the documented path in the package suite

The README is a contract. Test its important steps through Orchestra Testbench: provider discovery, configuration merging, migration availability and expected failure. This Pest test verifies behaviour a consumer relies on rather than an implementation detail of the provider.

php
<?php

declare(strict_types=1);

use DominikDev\WebhookTap\WebhookRecorder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('records a verified delivery using the published configuration', function (): void {
    config()->set('webhook-tap.secret', 'top-secret');

    app(WebhookRecorder::class)->record(
        payload: '{"id":"evt_123"}',
        signature: hash_hmac('sha256', '{"id":"evt_123"}', 'top-secret'),
    );

    $this->assertDatabaseHas('webhook_deliveries', [
        'external_id' => 'evt_123',
    ]);
});

it('rejects a delivery with an invalid signature', function (): void {
    config()->set('webhook-tap.secret', 'top-secret');

    app(WebhookRecorder::class)->record('{"id":"evt_123"}', 'invalid');
})->throws(\DomainException::class);

If a README says vendor:publish is optional, test the no-publish case. If it says a command is idempotent, test repeated execution. If it shows a middleware alias, boot an application that resolves the alias. This small discipline stops documentation from becoming a second, untested implementation.

Release notes answer a different question

README answers “how do I begin today?” A changelog answers “what changed since the version I run?” Keep them separate. Use a human-readable changelog with Added, Changed, Deprecated, Removed, Fixed and Security sections. Link release headings to tags and link every major release to an upgrade guide.

An upgrade guide must name observable work. “Update configuration” is not enough; show the old and new keys, state whether the migration is additive or destructive, identify deployment order, and give a rollback boundary. For example, if version 3 stores encrypted payloads, an operator needs to know the encryption key must exist before workers consume deliveries. They also need to know whether rolling application code back can read rows written by version 3.

Badges are supporting evidence, not decoration. CI status, latest stable release, license and supported PHP/Laravel range are actionable. Ten badges, download counters and fashionable tooling logos make readers search harder for the install command. A package earns trust with a reproducible first result, not a crowded header.

Give security reports a private route

SECURITY.md should state a monitored private reporting mechanism, expected acknowledgement time, supported version window and coordination expectations. Do not ask a reporter to disclose a signature-bypass issue in a public GitHub issue. Do not promise a response window your project cannot meet. A modest, honest policy is better than copied enterprise language nobody reads.

Security documentation also draws a useful line: package configuration examples must use placeholders; real secrets do not belong in an issue, README, fixture or test snapshot. For webhook packages, document replay and duplicate-delivery behaviour. Signature validity proves origin, not uniqueness, so consumers need to know whether the package stores an external event ID, rejects duplicates, or leaves idempotency to the application.

When polish is not another documentation file

Do not create a documentation site, a separate “core” package, or versioned manuals for a package with one stable major and a three-command setup. That multiplies places where instructions drift. A strong README, changelog, SECURITY.md and one upgrade guide are enough for many maintained packages.

Conversely, do not treat a polished README as a substitute for compatibility testing. A beautiful install section cannot prove the provider boots on every claimed Laravel version. The practical alternative is a lean distribution surface backed by CI and Testbench tests: fewer pages, real commands, explicit failure modes, and release notes that let an operator make an informed change.

Publish a release, not the contents of your laptop

The repository also needs a small release checklist. Before tagging, run the same checks that protect pull requests, inspect the package contents, and test installation into an empty Laravel application. The last step catches a class of mistakes that unit tests do not: a file excluded by .gitattributes, a missing migration, an accidentally committed local path repository, or a README command that only works because the package is checked out beside the example app.

bash
composer validate --strict
composer test
composer archive --format=tar --file=/tmp/laravel-webhook-tap
tar -tf /tmp/laravel-webhook-tap.tar | sort

composer archive is particularly useful for libraries. Composer installs the distributed archive, not your working directory. Keep tests, CI definitions and development notes out of the archive when that reduces download size, but never exclude source files, migration stubs, config files or the licence. Then create a disposable application, require the tagged version rather than dev-main, and follow the README exactly. This is not ceremony for every typo; it is the right release gate whenever a change touches package discovery, publishable assets, Composer metadata or an upgrade path.

The package manifest should make the public promise machine-readable too.

json
{
  "name": "dominik-dev/laravel-webhook-tap",
  "type": "library",
  "require": {
    "php": "^8.3",
    "illuminate/support": "^11.0|^12.0|^13.0"
  },
  "extra": {
    "laravel": {
      "providers": [
        "DominikDev\\WebhookTap\\WebhookTapServiceProvider"
      ]
    }
  }
}

Do not copy a broad constraint just to increase a badge's range. Supporting a Laravel version means the provider, commands, migrations and documented path are tested there. Likewise, package discovery is a convenience, not a reason to omit manual registration instructions when a consumer deliberately disables discovery. Put that escape hatch in a short troubleshooting section instead of forcing every reader through it first.

Treat deprecation as a release feature

Breaking a public class name or configuration key without a migration path turns routine maintenance into emergency work for users. When a change can be introduced gradually, document the old and new APIs together, emit a clear deprecation message where appropriate, and state the first release where the old path will disappear. A practical changelog entry tells readers whether they need a code edit, a config edit, a database migration, or no action at all.

For example, if WEBHOOK_TAP_SECRET becomes WEBHOOK_TAP_SECRETS, version 2 can accept both values while preferring the new one. Version 3 can remove the fallback after the upgrade guide has had one major release to do its job. Do not silently choose between conflicting values: fail fast during boot and name both keys. Silent precedence rules create incidents that cannot be reconstructed from a release note.

The alternative—shipping only major releases and asking every user to read the diff—can work for a private, tightly coordinated package. It is a poor default for an open package with unknown deployment schedules. Clear compatibility boundaries cost a few paragraphs and save every adopter from reverse-engineering your intent.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.