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.
## 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
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
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
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.
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.
{
"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.