laravel-notification-channels/webhook
The Webhook notification channel lets you deliver Laravel notifications as HTTP POST requests to any URL. Define payloads with a simple toWebhook() method and send JSON to external services or custom endpoints using Laravel's standard notification API.
Install and wire it up
composer require laravel-notification-channels/webhook
The package is auto-discovered; there is nothing to publish. As of 2.7.0 it supports Laravel 12.x (2.6.0 covered earlier versions), so check the constraint if you are on an older LTS — Composer will resolve the right tag for you, but pin deliberately (see Gotchas). The whole surface area is two classes — WebhookChannel and WebhookMessage — so the fastest way to get oriented is to open vendor/laravel-notification-channels/webhook/src/ and read both files. They are short, and doing this once will answer most API questions faster than the README.
The first use case: notify an external system that something happened
Add the channel to an existing (or new) notification:
use NotificationChannels\Webhook\WebhookChannel;
use NotificationChannels\Webhook\WebhookMessage;
class InvoicePaid extends Notification implements ShouldQueue
{
public function __construct(public Invoice $invoice) {}
public function via($notifiable): array
{
return [WebhookChannel::class];
}
public function toWebhook($notifiable): WebhookMessage
{
return WebhookMessage::create()
->url($notifiable->webhook_url)
->headers(['X-Signature' => $this->signature()])
->data([
'event' => 'invoice.paid',
'invoice_id' => $this->invoice->id,
'amount' => $this->invoice->amount,
]);
}
}
Then $user->notify(new InvoicePaid($invoice));. That is the entire day-to-day loop: pick an event, build a payload, POST it.
Where to look first when something is off
toWebhook() return a message with a URL — either set via ->url() or resolved from the notifiable?toIso8601String())?Two ways to route, and you should pick one per project
->url(...)) — good for a fixed endpoint: your own internal event bus, a Zapier catch hook, a Discord/Slack webhook.routeNotificationForWebhook() on the model (e.g. return $this->webhook_url;) and let the channel resolve it. This is the pattern for multi-tenant SaaS where each customer registers their own endpoint.Mixing both is where confusion starts. Adopt a convention: message-level URL for internal, notifiable route for customer endpoints.
Model the endpoint, don't scatter URLs
class WebhookEndpoint extends Model
{
protected $casts = ['is_active' => 'boolean', 'secret' => 'encrypted'];
public function tenant() { return $this->belongsTo(Tenant::class); }
}
Store URL, secret, and an is_active flag. Rotating a secret or disabling a broken endpoint becomes an update, not a deploy. When an endpoint is inactive, short-circuit via() to return [].
Always queue, and configure retries explicitly
class InvoicePaid extends Notification implements ShouldQueue
{
public $tries = 5;
public $backoff = [10, 60, 300];
public function failed($e) { /* log, mark endpoint degraded */ }
}
The channel itself does not retry — retries are a property of the queued notification. Without ShouldQueue, a slow receiver blocks your web request and a 500 silently drops the event.
Sign your payloads yourself
The package posts JSON; it does not sign anything. If receivers verify authenticity (Stripe-style), build an HMAC in the notification and send it as a header:
$body = json_encode($this->payload());
$sig = hash_hmac('sha256', $body, $endpoint->secret);
Add an X-Idempotency-Key (event id + timestamp) so receivers can dedupe retries.
Testing — two complementary layers
// 1. Assert the notification carried the right message
Notification::fake();
$user->notify(new InvoicePaid($invoice));
Notification::assertSentTo($user, InvoicePaid::class, function ($n) use ($user, $invoice) {
$msg = $n->toWebhook($user);
return $msg->payload['invoice_id'] === $invoice->id;
});
// 2. Assert the outbound HTTP call (recent versions use Laravel's HTTP client)
Http::fake();
$user->notify(new InvoicePaid($invoice));
Http::assertSent(fn ($req) => $req->url() === $user->webhook_url
&& $req['event'] === 'invoice.paid');
The first layer is fast and stable; the second catches header/URL regressions. Use Http::fake() in feature tests so CI never touches a real endpoint.
Local development
Point webhook_url at a request bin (webhook.site, or ngrok http 8000 to a local route that dumps $request->all()). You will rebuild the payload shape several times before it is right — do not do that against a real consumer.
Silent failures are the default. Laravel's HTTP client does not throw on 4xx/5xx unless ->throw() is called. Unless you have verified the channel throws, treat a 200-less response as an event you lost. Wrap sends in a delivery log table (notification, endpoint, status, response_body, attempt) so you can replay. This is the single most valuable thing you can add on top of the package.
Timeouts. The default HTTP timeout is generous. A hung receiver will occupy a queue worker for a long time. If the message class exposes a timeout, set it (5–10s); if not, set a global Http::globalOptions(['timeout' => 10]) in a service provider.
SSL verification. There is usually a ->verify(false) escape hatch. It exists for local self-signed receivers. Never ship it to production; if you find it in a diff, that is a bug.
Do not send models. ->data($invoice) will attempt to serialize the model and either drop attributes or blow up. Always pass a flat, explicit array. Cast dates with ->toIso8601String() and money as cents/int, not floats.
Payload shape is your contract. Once a customer integrates, the field names and types are frozen. Version your events ("event": "invoice.paid", "v": 1) from day one; it costs nothing now and saves a migration later.
Slack/Discord/Teams need their own payload shapes. This channel is generic JSON — it will not format a Slack attachment for you. You either hand-build {"text": ...} / {"embeds": [...]} in toWebhook(), or use the provider-specific channel (laravel-notification-channels/slack, etc.) for those targets and keep this one for custom endpoints.
Version constraints and maturity. 2.7.0 is the current release and its only change is Laravel 12.x compatibility — a good signal the package is still maintained, but it also tells you the cadence is maintenance-only, not feature-driven. Pin to ^2.7 (not ^1.x, which is now two majors behind and will not resolve on Laravel 12). The API is still thin — two classes, modest community, no dependents to lean on — so budget an afternoon to read the source before making it load-bearing. The upside: it is small enough to fork or replace with a ~40-line custom channel if the package ever stalls again.
Extension point. Because the channel resolves the URL and message from your notification, all your logic belongs in toWebhook() and a small builder if you have many event types:
abstract class BaseWebhookNotification extends Notification implements ShouldQueue
{
abstract protected function eventName(): string;
abstract protected function payload(): array;
public function toWebhook($notifiable): WebhookMessage
{
return WebhookMessage::create()
->url($notifiable->routeNotificationForWebhook())
->headers(['X-Event' => $this->eventName()])
->data(['event' => $this->eventName()] + $this->payload());
}
}
That keeps every concrete notification to two small methods and puts signing, headers, and retry config in one place.
How can I help you explore Laravel packages today?