liberusoftware/webhooks
A Laravel package for handling webhooks, offering tools to receive, verify, and dispatch webhook requests between your application and external services with a simple, framework-friendly API.
This is a pre-1.0-community package with no dependents — treat the README as the contract you verify against the code before relying on it. Budget an hour to read src/, config/, and the migrations before writing a single line of application code.
Install and publish:
composer require liberusoftware/webhooks
php artisan vendor:publish --provider="Liberu\Webhooks\WebhooksServiceProvider"
php artisan migrate
Expect the publish step to drop a config file (config/webhooks.php) and a set of migrations covering, at minimum: registered endpoints, per-endpoint subscriptions/filters, delivery attempts (the "evidence" store), and logs. Check the migration filenames first — they tell you the real domain model, which is usually more accurate than the docs.
Where to look first, in order:
config/webhooks.php — signing algorithm, secret length, retry/backoff schedule, timeout, queue connection, retention.First use case: register an endpoint for an internal event, dispatch it, and read the delivery evidence row.
$endpoint = WebhookEndpoint::create([
'url' => 'https://consumer.test/hooks/orders',
'secret' => config('webhooks.secret') ?? Str::random(48),
'events' => ['order.created', 'order.refunded'],
'active' => true,
]);
// then, wherever the domain event fires
Webhook::dispatch('order.created', $order->toWebhookPayload(), $endpoint);
The payoff of this package over a hand-rolled Http::post is rows in the delivery table: status code, response body, attempt number, next retry time, and signature used. Get one of those rows visible (via Tinker or a quick Blade page) before going further. Everything else — replay, rotation, backoff — is an operation on that row.
Dispatch from listeners, not models. Keep webhook dispatch out of booted() model hooks and out of controllers. A queued listener gives you retries, ordering, and a place to shape the payload independently of your schema:
class SendOrderCreatedWebhook implements ShouldQueue
{
public $afterCommit = true;
public function handle(OrderCreated $event): void
{
Webhook::event('order.created')
->payload(OrderPayload::from($event->order)->toArray())
->toSubscribers(); // fan-out to every active endpoint filtering on this event
}
}
afterCommit matters: dispatch before the transaction commits and a rolled-back order still produces a webhook.
Fan-out vs. targeted sends. Build two call sites — ->toSubscribers('order.created') for broadcast semantics, and ->to($endpoint) for one-off/manual sends (support tooling, backfills). The second is what you'll use constantly during integration with a customer.
Filtering belongs at registration, not at dispatch. Register narrow event subscriptions per endpoint rather than sending everything and letting the consumer ignore it. If the package supports payload-level filters (field matches), push them into the endpoint record — it keeps the delivery log meaningful and makes "why didn't I get this?" answerable by looking at one row.
Signing and verification. The receiver-side check is the part you actually have to get right:
$raw = $request->getContent();
$expected = hash_hmac('sha256', $request->header('X-Webhook-Timestamp').'.'.$raw, $secret);
abort_unless(hash_equals($expected, $request->header('X-Webhook-Signature')), 401);
abort_if(abs(time() - (int) $request->header('X-Webhook-Timestamp')) > 300, 401);
Always sign and verify the raw body (getContent()), never $request->all() re-encoded — JSON key ordering and float formatting will differ and every signature will fail. Verify against the timestamp too, or a captured request is replayable forever.
Consume the evidence store as an operational queue. The delivery table is the thing you'll lean on daily:
next_attempt_at in the past → the retry worker isn't running.If the package doesn't ship a pruning command, write one; delivery logs grow at the rate of events × endpoints and are the single biggest source of table bloat in webhook systems.
Replay deliberately. Replay from the evidence row (by delivery UUID), not by re-firing the domain event — re-firing re-runs your payload shaping and can produce a different body than the customer originally saw. Two distinct tools:
Webhook::replay($delivery->id); // resend exact stored payload
Webhook::replayRange($endpoint, $from, $to); // backfill after an outage
Rate-limit bulk replays. A thousand-row replay against a struggling consumer is indistinguishable from an attack.
Rotation with an overlap window. Rotate by writing the new secret, keeping the old one valid for verification/accepted-signature purposes for a bounded window (24–72h), notifying the consumer, then dropping the old. If the package models secrets as a list per endpoint, this is free — check for that. Otherwise, add a previous_secret and rotated_at column yourself rather than doing a hard swap.
Backoff should be tuned per endpoint class, not globally. The default schedule (something like 1m, 5m, 30m, 2h, 6h, 24h over ~48h) is a reasonable baseline for external customers. Internal services consuming your webhooks should be on a much tighter, shorter schedule. If backoff lives only in config, that's a reason to run more than one queue/connection rather than fighting the config.
Testing. Http::fake() covers the sending side, but the interesting assertions are on persisted state:
Http::fake(['consumer.test/*' => Http::response('ok', 200)]);
Webhook::dispatch('order.created', $payload, $endpoint);
$this->assertDatabaseHas('webhook_deliveries', [
'endpoint_id' => $endpoint->id,
'status' => 'delivered',
]);
Also add a signature-verification contract test against a fixed payload + secret, so a package upgrade that changes the signing string fails loudly in CI rather than silently in production.
"liberusoftware/webhooks": "0.x.y"), commit the lockfile, and read src/ for anything you depend on. If the migrations get rewritten between patch releases, you'll find out the hard way.--timeout, or slow consumers will get the worker killed mid-delivery and produce duplicate sends. Same for retry_after — it must exceed the HTTP timeout.order.updated that retries can land after order.deleted. Include a sequence number or timestamp in the payload and let consumers reconcile.$model->toArray() leaks internal columns (and sometimes relations) to third parties. Always pass through an explicit payload class or resource. Watch Carbon serialization format changes across Laravel versions if you sign the body — the signed bytes must match what's sent.localhost, and cloud metadata IPs (169.254.169.254) at registration and at send time, and disable redirects.php artisan config:cache in production means anything you mutate at runtime (per-tenant signing config, for example) won't take effect. Keep such settings in the database, resolved per call.How can I help you explore Laravel packages today?