Skip to content
Backend Infrastructure8 min

The webhook that fired the truth from ten minutes ago

A queued listener receives an event carrying fully-loaded Eloquent models. By the time the worker runs, the models are a snapshot of the past — and that snapshot is what your integration partners receive as current state.

Failure modeEvents dispatched to the queue carry hydrated Eloquent models instead of identifiers. The worker deserialises a stale snapshot and acts on it, so downstream systems receive data that was already superseded before the job ran.

Commercial risk

A webhook is a contract: it tells another company's system what is true right now. Fire it from a stale model and you are asserting, in writing, a state that is no longer real — and their system will act on your assertion, not on your reality.

The damage lands in someone else's database, which makes it the most expensive kind to fix: a document reported as “uploaded” that was since replaced, a status pushed as “active” that was cancelled, reconciled by two teams across two companies days later, by hand.

Queues exist to move slow work off the request path, and the price of that asynchrony is time: the job runs later — sometimes seconds later, sometimes, under backlog, minutes or hours. Any data the job carries is a snapshot taken at dispatch, not at execution. When that data is a fully-loaded Eloquent model, the snapshot includes every attribute and every eager-loaded relation, frozen at the instant the event fired.

The failure mode is treating that snapshot as live. It is the async cousin of a stale cache, and it is especially dangerous in webhook dispatch, because the whole point of a webhook is to communicate current truth to a system you don't control.

1. The model that serialises its whole world

When an event implementing `ShouldQueue` carries a model, the framework serialises it to put it on the queue. Laravel's `SerializesModels` trait is specifically designed to avoid this trap for the top-level model — it stores the primary key and re-fetches on unserialize. But that protection does not extend to models you pass inside a payload object, or to relations you loaded and the listener reads without re-querying. Those travel as frozen attribute bags.

So a listener that reads `$event->transfer->status` may be reading the status as it was at dispatch, and a transformer that walks `$event->transfer->documents` is walking the document set from ten minutes ago. Nothing errors. The job completes successfully. It just built its payload from a version of reality that has since moved on.

// Risky: the event hands the listener a hydrated model + relations.
class TransferUpdated
{
    public function __construct(public Transfer $transfer) {}
    // $transfer->documents was eager-loaded at dispatch and is now frozen.
}

class DispatchWebhook implements ShouldQueue
{
    public function handle(TransferUpdated $event): void
    {
        // Ten minutes later under backlog, this is a stale snapshot.
        $payload = $this->transform($event->transfer);
        $this->send($payload); // asserts old state as current truth
    }
}

2. Pass identifiers, re-hydrate on execution

The correct contract for a queued job is: carry the minimum needed to find the data, and load the data at the moment of execution. Pass the primary key, not the model. In `handle()`, re-fetch from the database, so the job acts on the state that is true when it runs, not when it was queued.

This also forces you to handle a case the model-carrying version hid: the record may have been deleted between dispatch and execution. A stale model would happily emit a webhook for a transfer that no longer exists; a re-fetch returns null and lets you decide — skip, emit a deletion event, or fail for retry. That decision is a feature of correctness, not an inconvenience.

// Robust: carry the ID, load fresh, handle the vanished record.
class TransferUpdated
{
    public function __construct(public int $transferId) {}
}

class DispatchWebhook implements ShouldQueue
{
    public function handle(TransferUpdated $event): void
    {
        $transfer = Transfer::with('documents')->find($event->transferId);
        if ($transfer === null) {
            // Superseded by a delete — emit a deletion event, don't lie.
            return;
        }
        $this->send($this->transform($transfer)); // current truth
    }
}

3. Retries, backoff, and idempotency downstream

The same class of bug has a delivery-side twin. A webhook job set to retry immediately on failure, with no backoff, will hammer a temporarily-unavailable partner and can amplify an outage into a cascade. Retries need exponential backoff, so a transient failure is absorbed rather than compounded.

And because retries mean a webhook can be delivered more than once, the payload must be idempotent for the receiver: include a stable event ID and a timestamp so the partner can discard duplicates and reject out-of-order deliveries. A re-fetch guarantees the payload is current; an event ID guarantees the receiver can tell your third delivery of the same event from a genuinely new one.

Async work runs in the future, so any data it carries is from the past. A queued job that carries a hydrated model is carrying a snapshot and presenting it as live — harmless for an internal side effect, actively misleading when the job's purpose is to tell another system what is true.

Pass identifiers, re-hydrate at execution, handle the record that vanished in between, and make deliveries retry with backoff and carry an idempotency key. The rule is the same one that governs caches: never trust a value you captured earlier to still be true when you finally use it.

Need this level of structural integrity engineered for your system?

We audit and re-architect systems where a single one of these failure modes costs real money. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint