The Problem: Redundant Queue Work
A product import touches the same record forty times in a minute. ProductUpdated fires forty times. The listener that rebuilds the search index runs forty times, each run indexing state the next one immediately overwrites. The queue does exactly what it was told, but 97 percent of the work is waste.
What you actually want is for a burst of identical events to collapse into one listener execution at the end of the burst, carrying the latest state.
Laravel 13.6 introduced debounceable queued jobs. Laravel 13.26 extends the same #[DebounceFor] attribute to queued event listeners, contributed by @stevebauman in #61169, so event-driven code gets the same behavior without restructuring listeners into manually dispatched jobs.
Debouncing a Queued Listener
Add the #[DebounceFor] attribute to any listener that implements ShouldQueue and specify a debounce window in seconds:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30, maxWait: 120)]
class UpdateProductSearchIndex implements ShouldQueue
{
public function debounceId(ProductUpdated $event): string
{
return (string) $event->product->getKey();
}
public function handle(ProductUpdated $event): void
{
ProductIndexer::index($event->product->fresh());
}
}
Every ProductUpdated event for product 42 within a 30-second window now results in one handle() call, made for the last event of the burst. Events for product 43 debounce independently because debounceId() keys the window per product.
Without a debounceId, all dispatches share one window — ideal for global listeners like "rebuild the sitemap". The ID can also be a plain $debounceId property when it does not depend on the event payload.
How the Debounce Mechanism Works
Each dispatch queues the listener with a delay equal to the debounce window and records an owner token in the cache, keyed by listener class and debounce ID. A newer dispatch overwrites the token. When an older queued copy finally executes, it checks whether it still owns the token — if not, it discards itself silently.
One important caveat: a single event on an otherwise idle resource still waits out the full debounce window before executing. There is no "fire immediately on first event" shortcut.
Preventing Starvation with maxWait
Pure debouncing has a failure mode: a continuous stream of events that never pauses long enough for the window to expire defers the listener indefinitely.
maxWait solves this. With #[DebounceFor(30, maxWait: 120)], once dispatches have been pushing the window for 120 seconds, the next dispatch executes without delay instead of extending the deferral again. A busy import still gets its writes collapsed — roughly one index run per two minutes — rather than either forty runs or zero.
Rules and Constraints
Three things to know before rolling this out:
- No
ShouldBeUniquetogether. Combining the two attributes throws aLogicExceptionat dispatch time. They hold opposite semantics — first-wins vs. last-wins — and the framework refuses to pick silently. - Debouncing is scoped to the listener, not the event. Other listeners on
ProductUpdatedstill run for every event. Only the attributed listener collapses. - Re-read state in the handler. The event object that survives the debounce is the last one dispatched, but by execution time even it can be stale. The example above calls
$event->product->fresh()for exactly this reason. Treat the event as a pointer to a resource, not as a complete payload.
That last habit is what makes debouncing safe: if the listener re-derives its output from the database, collapsing forty runs into one changes the cost, not the result.
Key Takeaways
#[DebounceFor(seconds, maxWait: seconds)]on aShouldQueuelistener collapses event bursts into one execution.debounceId()scopes the debounce window per resource; omit it for global listeners.maxWaitprevents indefinite deferral under sustained event streams.- Cannot be combined with
ShouldBeUnique. - Always call
->fresh()or re-query state inside the handler; the surviving event object may be stale.
Source: Debounced Queued Event Listeners in Laravel — Laravel News