---
title: "Handlers and processing — Webhooks for Laravel | Roundly"
description: "Bind handler classes or closures by alias, read the payload, headers and owner, process once per delivery on the queue, and replay stored deliveries."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/handlers
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)

# Handlers and processing

An endpoint stores a handler alias, never a class name. Bind it in config/webhooks.php ('handlers' => \['billing' => BillingWebhookHandler::class\]) or in a service provider’s boot():

```php
// AppServiceProvider::boot() — lasts for the whole process: every request, every job a queue
// worker runs, every Octane request
Webhooks::handlers()->register('billing', BillingWebhookHandler::class);

// a closure works too
Webhooks::handlers()->register('audit', function (InboundWebhook $webhook): void {
    logger()->info('webhook', ['id' => $webhook->deliveryId, 'type' => $webhook->type]);
});
```

An alias matches ^\[a-z0-9\]\[a-z0-9\_.-\]{0,99}$; a handler class resolves through the container. An endpoint without a handler still stores, queues and fires WebhookReceived — listen to the event instead.

## Writing a handler

```php
final class BillingWebhookHandler implements InboundHandler
{
    public function handle(InboundWebhook $webhook): void
    {
        // $webhook->deliveryId  public id — your own idempotency key for side effects that must never repeat
        // $webhook->type        the scheme's event type, else the payload's "type"
        // $webhook->payload     sanitized, untrusted array — validate it
        // $webhook->headers     the stored allowlist (inbound.store_headers)
        // $webhook->endpoint    the InboundEndpoint
        // $webhook->isReplay, ->run, ->attempt, ->receivedAt
        $invoice = $webhook->get('data.invoice.id');   // dot path into the payload, or null
        $agent = $webhook->header('User-Agent');        // a stored header, case-insensitive
        $team = $webhook->owner();                      // the endpoint's owner, or null
    }
}
```

The payload is decoded to arrays only, sanitized and still untrusted — validate it before use, and never use a value as a class name, file path, URL or query fragment without checking it against an allowlist:

```php
$data = Validator::make($webhook->payload, [
    'data.invoice.id' => ['required', 'integer'],
])->validate();
```

## Queued, once per delivery

- ProcessInboundDelivery runs on webhooks.queue.inbound: WebhookReceived fires first, then the handler. Tries inbound.handler.tries (3), backoff \[10, 60, 300\], timeout 60 s.
- Sentinel’s idempotent job middleware makes a double dispatch, a sender retry or a sweep re-queue run the handler once.
- After the last failed try the delivery is failed with last\_error\_code handler\_failed, a safe summary — the exception’s short name and a short code such as QueryException (23000), never its message, which may quote the payload — and WebhookProcessingFailed. The full exception reaches your log through the failed job.
- A delivery whose endpoint was deleted before it ran fails at once (endpoint\_deleted).
- WebhookReceived fires on every attempt of a run: $webhook->attempt and isFirstAttempt() tell them apart.
- Statuses: received → queued → processing → processed | failed. Jobs carry Horizon tags (webhooks, webhooks:inbound, webhooks:delivery:{id}, webhooks:run:{n}).

Exactly once holds for every normal path. A worker killed after the handler returned but before Sentinel recorded completion lets the lease expire and the handler run again — use $webhook->deliveryId as your own idempotency key for side effects that must never repeat.

## Replay

```php
Webhooks::inbound()->replay($delivery);   // runs + 1, isReplay = true, same stored payload
```

A replay opens a new run on the stored payload, flagged isReplay. A pruned payload throws PayloadPrunedException; a delivery being processed throws DeliveryInProgressException — unless its last attempt started longer ago than the handler timeout + 30 s + the longest backoff + 90 s (its worker died), which can then be replayed. From the console: webhooks:replay {public id}.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
