mpbarlow/laravel-queue-debouncer
Debounce Laravel queued jobs so rapid repeated dispatches collapse into a single run. This package helps prevent queue flooding and duplicate work by ensuring only the latest job in a debounce window is processed, with simple integration into existing Laravel queues.
mpbarlow/laravel-queue-debouncer collapses a burst of dispatched jobs into a single execution — the last one wins. It's the queue equivalent of a debounce on an input event, and it's the right tool when a user action fires faster than the work should run (typing in a search box, dragging a slider, saving a model in a tight loop, a webhook provider retrying five times in two seconds).
Minimal setup:
composer require mpbarlow/laravel-queue-debouncer — version 3.2.0 adds Laravel 13 support; the 3.x line otherwise targets Laravel 11/12 with no API changes.php artisan vendor:publish --tag=queue-debouncer-config. The default window is short; most apps end up overriding it.redis, memcached, database, dynamodb). This is the single most common install failure — see Gotchas.First use case to try: a RefreshSearchIndex-style job dispatched from a model observer or form request. Dispatch it 20 times in a loop in php artisan tinker (or a test) and confirm your queue only processes one execution, carrying the last payload.
Per-entity debounce keys. The default behaviour debounces all instances of a job class together. For anything model-scoped you want an independent window per record, so make the key stable and specific — typically a method or property on the job that returns something like "refresh-index:{$this->post->id}". Get this wrong and a debounced job for post 1 will silently cancel a pending job for post 2.
Bursty UI → queued side effects. Debounce at the dispatch boundary, not inside the job. Dispatching is cheap; the point is to never enqueue work you'll throw away. This keeps queue depth flat during traffic spikes and is where most of the day-to-day value is.
Webhook and integration fan-in. Providers that retry (Stripe, GitHub, couriers) will happily send you the same event three times in 800ms. Debouncing the downstream sync job gives you idempotency-ish behaviour without building a dedupe table — but only if the payloads are equivalent; see the "last payload wins" gotcha.
Chaining with Bus::batch and other middleware. The debounce middleware composes with WithoutOverlapping and rate limiters, but think about ordering: a debounce that runs before WithoutOverlapping throws away work cheaply, which is what you want. If you're debouncing a job that's part of a batch, note the batch loses any cancelled jobs' callbacks — keep debounced jobs out of batches whose completion logic matters.
Tuning the window per job, not globally. The config default is a floor, not a policy. A 5-second window is right for a search index; it's wrong for a "send the welcome email" job where you want 30–60 seconds to absorb double-submits. Override on the job itself where the version supports it, and leave the global config as a conservative default.
Testing. Test the debounce semantics in a feature test with a real cache driver (array in tests is fine if it supports locks in your Laravel version — verify) and Carbon::setTestNow(). Assert on Queue::size() / dispatched counts rather than job execution. Keep a separate thin test that just asserts the job is dispatchable, because the debounce layer will mask payload bugs if it's the only thing you test.
Observability. Debounced jobs disappear without a trace — that's the feature and the debugging hazard. Log at dispatch time (before the debounce decision) with the debounce key, so when someone asks "why didn't my job run?" you have a trail showing a later dispatch superseded it.
array store does not share state across processes and older file store implementations didn't lock; if you see the debounce silently not working in production but working in tests, this is almost always why. Set the store explicitly in the package config rather than relying on your app default, which someone will change.Queue::fake() bypasses middleware. Faked queues never run the debounce logic, so a test suite full of Queue::fake() assertions will pass while production debounces everything into one job. Keep at least one integration test that uses a real (sync/array-with-locks) driver.SyncOrder(1), SyncOrder(2), SyncOrder(3) under one key, the job runs once with 3 and orders 1 and 2 are never touched. For accumulating work, debounce a "flush" job that reads the full set from the database or cache instead of carrying the payload.spl_object_id or a timestamp means you'll never actually debounce.Carbon::setTestNow() works, but travel() mid-test can leave a lock held. Prefer asserting "one job ran" over asserting exact timing.sync_status column) rather than relying on queue retries.config:cache after config changes. Obvious, universally forgotten, and produces a confusing "my window change did nothing" bug report.Overall: 3.2.0 is a compatibility release (Laravel 13 support, first contribution from @scybulski) with no breaking changes or new features. If you're upgrading to Laravel 13, bump the constraint and re-run your one real-driver debounce test — the risk is entirely in cache-lock behaviour, not in the package API.
How can I help you explore Laravel packages today?