>Dudenkoff_
← Lab / Laravel · Queues

The door before the job

Laravel 13 · PHP 8.5 · database driver · July 9, 2026 · part 7 of 9

Last time every limit lived on the job: a count, a clock, a budget it carried in its own envelope. This one comes from outside. A job that talks to a vendor's API has neighbors, and the neighbors get in each other's way. Two syncs for the same product must not run at once. The API allows sixty calls a minute, no more. When it starts throwing, hammering it is the worst thing you can do. None of that is the job's business, and none of it fits in an attribute. It goes in a wrapper.

Here's the job. It syncs one product's stock, which takes a few seconds against a slow API, and it carries the guard that two of them must never overlap:

app/Jobs/SyncInventory.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;

#[Tries(3)]
class SyncInventory implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $productId) {}

    public function middleware(): array
    {
        return [new WithoutOverlapping($this->productId)];
    }

    public function handle(): void
    {
        Log::info("syncing product #{$this->productId}");
        sleep(5);
    }
}

One method, one line: WithoutOverlapping keyed by the product id. Now dispatch two for one product and hire two workers, one for each. Two jobs, two workers. Two runs.

tinker
> App\Jobs\SyncInventory::dispatch(88); App\Jobs\SyncInventory::dispatch(88); DB::table('jobs')->count();
= 2
terminal
php artisan queue:work --once   # worker A
php artisan queue:work --once   # worker B, a second later
output
worker A   2026-07-09 12:31:53 App\Jobs\SyncInventory .. RUNNING
           2026-07-09 12:31:58 App\Jobs\SyncInventory .. 5s DONE
worker B   2026-07-09 12:31:54 App\Jobs\SyncInventory .. RUNNING
           2026-07-09 12:31:54 App\Jobs\SyncInventory .. 1.45ms DONE

Two greens. Both say DONE . Nothing failed, nothing threw. The obvious reading is that both syncs ran and the queue is empty. It isn't:

tinker
> DB::table('jobs')->count();
= 1
> DB::table('jobs')->value('attempts');
= 1

A row is still there. One of the two jobs reported DONE and then went back into the queue, one attempt spent. Worker A took five seconds; worker B, a millisecond and a half. A sync that reaches an API and moves stock doesn't finish in a millisecond. It never ran. And nothing in our code called release. We wrote one line, WithoutOverlapping , and a job we dispatched turned around at the door and came back. One line should not be able to do that from inside a running worker. So how does it.

The door#

handle() is not called directly. Grep the worker's own code and you find the job goes through a pipeline first, the same Pipeline that carries an HTTP request through its middleware. Stripped to the part that matters (Illuminate/Queue/CallQueuedHandler.php):

vendor · Illuminate/Queue/CallQueuedHandler.php (simplified)
return (new Pipeline($this->container))->send($command)
    ->through($command->middleware())
    ->then(fn ($command) => $this->dispatcher->dispatchNow(
        $command, $this->resolveHandler($job, $command)
    ));

Send the job through its middleware() , and only then run it. Your handle() sits at the end of the line. Every middleware is a link in front of it, and each link decides whether the job walks on to the next one or goes home. Worker B's job reached WithoutOverlapping , found the door shut, and was sent back before handle() ever came up. Everything after this is three doormen with three reasons to say "not now".

A lock you can read#

The door is a lock, and the lock is a row. When worker A took the job it acquired a lock keyed by the product; while it held that lock, worker B couldn't. It's the same cache_locks table that held the unique-job lock earlier in the series. The lock lives only as long as the run, so to catch one standing still we need a run that never ends. Kill a worker in the middle of a sync, the way a deploy or an out-of-memory does, and it never reaches the line that frees the lock:

terminal
php artisan queue:work --once      # picks up the sync, takes the lock, starts the 5s call
# ... killed mid-run (deploy, OOM, kill -9) ...
tinker
> DB::table('cache_locks')->value('key');
= "laravel-cache-laravel-queue-overlap:App\\Jobs\\SyncInventory:88"
> DB::table('cache_locks')->value('expiration') - now()->timestamp;
= 86390

The lock outlived the worker that made it. Its key names the class and the product, 88 , and its expiration is a full day out. That number is the story of the next twenty-four hours, and it comes from the driver, not the middleware. WithoutOverlapping sets no expiry, so on the database driver the lock takes DatabaseLock 's default of a day; on Redis it would never expire at all. The door for product 88 is now shut, and nobody is behind it.

Dispatch the next sync for that product and give the worker its head:

terminal
php artisan queue:work
output
2026-07-09 12:32:19 App\Jobs\SyncInventory .. RUNNING
2026-07-09 12:32:19 App\Jobs\SyncInventory .. 3.24ms DONE
2026-07-09 12:32:19 App\Jobs\SyncInventory .. RUNNING
2026-07-09 12:32:19 App\Jobs\SyncInventory .. 0.63ms DONE
2026-07-09 12:32:19 App\Jobs\SyncInventory .. RUNNING
2026-07-09 12:32:19 App\Jobs\SyncInventory .. 0.53ms DONE
2026-07-09 12:32:19 App\Jobs\SyncInventory .. RUNNING
2026-07-09 12:32:19 App\Jobs\SyncInventory .. 0.37ms FAIL

Read the clock: every line is 12:32:19 . The default door sends a job back with no delay, so it comes right around and knocks again, and again, hundreds of times a second, until #[Tries(3)] runs out. Three bounces and a FAIL , all inside one second. This is the shape of the incident, and it isn't a slow leak. It's a flash. Every sync for product 88 that lands dies almost the instant it's dispatched, then the queue for that product goes quiet, and a day later the lock expires and everything heals on its own. The on-call engineer gets paged for a spike that stopped before they opened the laptop, and by morning the evidence has timed out. (Set #[Tries(0)] for unlimited retries and it's worse: no death at all, just a worker pinned at full CPU, spinning on the dead lock all day.)

So the pager fires and someone reads the drawer:

tinker
> DB::table('failed_jobs')->count();
= 1
> Str::before(DB::table('failed_jobs')->value('exception'), ' in ');
= "Illuminate\\Queue\\MaxAttemptsExceededException: App\\Jobs\\SyncInventory has been attempted too many times."

Attempted too many times . That reads like a transient: the API was flaky, the job kept retrying and gave up, so bump the tries, add a backoff, redeploy. It's the obvious move, and it fixes nothing, because this job never made a single call. Here is a job that genuinely failed, one that reached its API and got a real error, for comparison:

tinker
> Str::before(DB::table('failed_jobs')->value('exception'), ' in ');
= "RuntimeException: Payment gateway is down"

That one ran. It threw its own exception, and the exception names the cause. The exception column tells the two deaths apart. A real error means handle() ran and lost. A bare MaxAttemptsExceededException , with no error under it, means the job was turned away every time and never ran. The tries and the backoff you were about to crank belong to jobs that reach their work. This one needs the stuck lock cleared.

Cap the lock, pace the bounce#

Clearing the lock by hand is the fix after the fact. Both numbers the incident turned on are settings on the middleware, one method call each:

app/Jobs/SyncInventory.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;

#[Tries(3)]
class SyncInventory implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $productId) {}

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->productId))
            ->expireAfter(30)
            ->releaseAfter(5)];
    }

    public function handle(): void
    {
        Log::info("syncing product #{$this->productId}");
        sleep(5);
    }
}

expireAfter caps the lock's lifetime. The day-long default we watched count down from 86390 becomes the seconds you hand it, so a worker killed mid-run leaks the door for that long, not for a day. releaseAfter sets the delay on the bounce. The no-wait return that knocked hundreds of times a second becomes a measured pause between tries. dontRelease drops a turned-away job instead of queueing it again at all.

One number has a floor. expireAfter has to clear the job's real runtime, or the lock frees while the sync is still working and the second worker walks straight in, the overlap the door was built to stop. Set it above the slowest run you expect, never below. The defaults assume a lock that always releases; the day a worker can die holding one, these two decide whether a dead sync costs a product's queue seconds or a day.

A budget at the door#

A lock is one reason to hold a job at the door. A rate limit is another. The vendor allows so many calls a minute; past that, the job waits. You define the budget once, by name:

app/Providers/AppServiceProvider.php
RateLimiter::for('webhooks', fn () => Limit::perMinute(1));

And a job reaches for it by that name:

app/Jobs/PushWebhook.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\Log;

#[Tries(3)]
class PushWebhook implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function middleware(): array
    {
        return [new RateLimited('webhooks')];
    }

    public function handle(): void
    {
        Log::info("pushing webhook for order #{$this->orderId}");
    }
}

One a minute, dialed down from the vendor's sixty so the bounce lands on camera. Dispatch two, run the worker twice:

output
run 1   2026-07-09 12:32:41 App\Jobs\PushWebhook .. RUNNING
        2026-07-09 12:32:41 App\Jobs\PushWebhook .. 3.08ms DONE
run 2   2026-07-09 12:32:41 App\Jobs\PushWebhook .. RUNNING
        2026-07-09 12:32:41 App\Jobs\PushWebhook .. 3.18ms DONE

Two DONE again, and again one job is lying. The second was over budget and bounced. Where it lands is the difference from the lock:

tinker
> DB::table('jobs')->value('attempts');
= 1
> DB::table('jobs')->value('available_at') - DB::table('jobs')->value('created_at');
= 63

Back in the queue, one attempt gone, and its next try set sixty-three seconds out: a minute for the window, three seconds of limiter buffer. Over budget, a job waits for the window instead of failing - but keep the budget full and it spends its tries one window at a time, then dies attempted too many times , never having run. The lock spent a job's tries in a second. The limiter takes minutes.

The circuit#

The last doorman watches the failures. When the vendor starts throwing, retrying every job just piles load on a service that's already down. ThrottlesExceptions trips a breaker: let a few exceptions through, then open the circuit and send every job home without running it until the service has had time to recover. A job that calls a vendor that's returning 500s:

app/Jobs/CallVendor.php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Support\Facades\Log;

#[Tries(10)]
class CallVendor implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function middleware(): array
    {
        return [(new ThrottlesExceptions(2, 60))->backoff(0)];
    }

    public function handle(): void
    {
        Log::info("calling vendor for order #{$this->orderId}");
        throw new \RuntimeException('vendor 500');
    }
}

Two exceptions, then a sixty-second cooldown. Run the daemon and watch three attempts:

output
2026-07-09 12:32:50 App\Jobs\CallVendor .. RUNNING
2026-07-09 12:32:50 App\Jobs\CallVendor .. 2.75ms DONE
2026-07-09 12:32:50 App\Jobs\CallVendor .. RUNNING
2026-07-09 12:32:50 App\Jobs\CallVendor .. 0.55ms DONE
2026-07-09 12:32:50 App\Jobs\CallVendor .. RUNNING
2026-07-09 12:32:50 App\Jobs\CallVendor .. 0.36ms DONE

Three runs on the worker's face. But handle() logs a line every time it's reached, so count them:

terminal
grep -c 'calling vendor' storage/logs/laravel.log
output
2

Three runs, two calls. The first two reached the vendor and threw; that second throw opened the circuit. The third run never made the call. The breaker caught the job at the door and sent it back to wait out the cooldown, and the worker still printed DONE . That's the deal, and it cuts both ways. A job that would have succeeded, because the vendor came back one second into the minute, still waits the full cooldown. In exchange, you stop hammering a service that's already down.

Three doormen, one move. A lock, a budget, a breaker, and each one sends the job back into the queue to come around again. Under all three, a job can die without a single line of handle() ever running, and the death certificate reads the same for each. It never says the job was turned away at the door. That's the failure mode the exception column exists to catch.


Where a job's attributes are its own patience, kept in its own envelope, middleware is patience imposed from outside and shared between jobs. A lock coordinates jobs by key. A limiter and a breaker are one budget the whole fleet draws on. That's the thing an attribute can't reach: how jobs behave toward each other. Six lines worth keeping:

  • Middleware wraps handle() in a pipeline. The job runs at the end of the line; any link can send it home first, and the worker still prints DONE .
  • A turned-away job comes back, one attempt spent. It's the release from last time, called for the job instead of by it.
  • WithoutOverlapping is a lock in cache_locks . One job per key at a time; a job killed mid-run leaks the lock for a day unless expireAfter caps it.
  • RateLimited bounces to the window. Over budget, the job waits for the limit to reset instead of failing; spend its tries and it dies without running.
  • ThrottlesExceptions opens on failures. Once the circuit is open, handle() is skipped for the cooldown, good jobs and bad alike.
  • A bare MaxAttemptsExceededException means it never ran. A real exception means it did. That column is how you tell a stuck door from a broken job.

One job wrapped by these is patience for itself. The next visit is what happens when jobs stop being solitary: dozens dispatched as one unit, ordered in a chain or gathered in a batch, and the bookkeeping that tracks them. That's chains and batches.


Next
What a group costs
The series
  1. 01 The minimal job
  2. 02 The model that never rode the queue
  3. 03 Beyond the minimal job
  4. 04 Where jobs go when they die
  5. 05 Queue the listener
  6. 06 The limits of patience
  7. 07 The door before the job
  8. 08 What a group costs
  9. 09 Workers in production