A product import touches the same record forty times in a minute, ProductUpdated fires forty times, and the listener that rebuilds the search index runs forty times, each run indexing state the next one overwrites. The queue does exactly what it was told and the work is still 97 percent waste. What you want is for a burst of identical events to collapse into one listener run at the end of the burst, carrying the latest state.
Laravel 13.6 added that collapse for queued jobs, as 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 behavior without restructuring listeners into manually dispatched jobs.
Community packages have filled the gap, Laravel Debounce among them, and the framework's own answer started with jobs.
Debouncing a Listener
Add the attribute to a queued listener and give it 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 for product 42 inside 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 of the listener share one window, which is what you want for listeners like "rebuild the sitemap" that take no per-resource input. The ID can also be a plain $debounceId property when it does not depend on the event.
With debouncing, the last dispatch wins. 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, and when an older queued copy finally executes, it sees it no longer owns the token and discards itself. One caveat follows from the delay-based design: a quiet resource still waits out the full window, so a single event on an idle product runs 30 seconds later, not immediately.
maxWait and the Starvation Problem
Pure debouncing has a failure mode: a stream of events that never pauses for 30 seconds defers the listener forever. That is what maxWait is for. With #[DebounceFor(30, maxWait: 120)], once dispatches have been pushing the window along 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, instead of either forty runs or zero.
Two dials interact with the delay. An explicit delay set on the listener takes precedence over the debounce-derived one, and a listener can override which cache store manages the ownership tokens by defining debounceVia(), useful when your default cache is not shared by every server dispatching events.
The Rules
Three constraints to know before rolling this out:
- No
ShouldBeUnique. The two features hold opposite locks, first-wins versus last-wins, and combining them now throws aLogicExceptionat dispatch rather than picking a winner silently - Scope is the listener, not the event. Other listeners on
ProductUpdatedrun for every event; only the attributed listener collapses. Debouncing is a property of the work, not of the event stream - The handler sees the triggering event, so re-read state. 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 that reason; a debounced listener should treat the event as a pointer to a resource, not as a 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.
Further Reading
- Laravel 13.26 release notes, which also shipped the read-through filesystem driver and
Queue::forward() - Debounceable queued jobs in Laravel 13.6, the job-side original of this API
- Queue::forward() in Laravel 13.26, this release's other queue addition
- Laravel Jobs and Queue 101, for the queue connection and worker fundamentals underneath all of this