Nothing happens when you dispatch a Laravel job
You are reviewing a pull request:
Queue::push(new WakeUpOnCallEngineerJob($incident));
WakeUpOnCallEngineerJob::dispatch($incident);
The author writes "same thing, cleaner syntax." Approve?
Do not approve. Not yet. The two lines look interchangeable, and teams that treat them that way collect a specific set of production bugs: jobs on the wrong queue, duplicate "unique" jobs, work running inside the web request that everyone believed was async.
I traced all three dispatch forms through the Laravel 13.24 source, with the 10.x, 11.x and 12.x branches open next to it for the places where behavior changed. The short version, so you can decide whether to keep reading: Queue::push is a raw write to the queue backend that skips every dispatch-time feature the framework has added since, and keeps adding. The other two forms are one mechanism with two spellings. The practical conclusion is a team convention near the end, with a PHPStan rule to enforce it.
What each call actually does #
Queue::push(new WakeUpOnCallEngineerJob($incident));
WakeUpOnCallEngineerJob::dispatch($incident);
dispatch(new WakeUpOnCallEngineerJob($incident));
Queue::push, the low-level door #
The Queue facade resolves QueueManager, which forwards push() to the default connection's driver. The payload is serialized and written to Redis, the database, or SQS before the line finishes. There is no command bus involved, no wrapper object, no deferral. What you pass is what gets stored, on the default connection, on that connection's default queue, with no delay.
Job::dispatch, the command bus door #
The static dispatch() comes from the Dispatchable trait. It constructs your job and wraps it (Dispatchable.php#L17):
public static function dispatch(...$arguments)
{
return static::newPendingDispatch(new static(...$arguments));
}
Nothing is enqueued at this point. The returned PendingDispatch sits in memory until PHP destroys it, and the destructor does the real work (PendingDispatch.php#L283):
public function __destruct()
{
if (! $this->shouldDispatch()) {
return;
}
$this->addUniqueJobInformationToContext($this->job);
$this->acquireDebounceLock();
if ($this->afterResponse) {
app(Dispatcher::class)->dispatchAfterResponse($this->job);
} else {
app(Dispatcher::class)->dispatch($this->job);
}
$this->removeUniqueJobInformationFromContext($this->job);
}
For a bare statement the object loses its last reference at the end of that statement, so dispatch feels immediate. It is not, and the whole fluent API depends on that. The deferral exists so you can finish configuring before anything ships:
WakeUpOnCallEngineerJob::dispatch($incident)
->onQueue('alarms')
->delay(60)
->afterCommit();
The bus (Illuminate\Bus\Dispatcher) then checks ShouldQueue, resolves the connection from the job, and pushes with the job's queue and delay applied.
dispatch(), the same door with a helper sign #
The global helper wraps whatever object you hand it in the same PendingDispatch. Timing and behavior are identical to the static form. Two real differences: you construct the job yourself, and the helper accepts closures, which it wraps in CallQueuedClosure and returns as PendingClosureDispatch so you can attach ->catch().
So the honest mental model is two forms, not three. There is the queue layer (Queue::push) and the command bus (::dispatch() and dispatch()). Everything interesting lives in the gap between them.
One more door hides in plain sight: Bus::dispatch($job). It enters the same Illuminate\Bus\Dispatcher, but directly, with no PendingDispatch around it. Routing works and Bus::fake() sees it, while uniqueness, debouncing and the PreparesForDispatch veto silently do not run. Treat it as Queue::push with better camouflage and keep it out of application code too.
flowchart TD
H["dispatch(new Job)"] --> P
S["Job::dispatch()"] --> P
P["PendingDispatch::__destruct()<br>fires at end of statement"] --> G
G["ShouldBeUnique lock<br>#[DebounceFor] token<br>PreparesForDispatch veto"] --> D
B["Bus::dispatch($job)"] -.-> D
D["Bus\Dispatcher<br>routing: $queue · #[Queue] · Queue::route()"] --> E
Q["Queue::push($job)"] -.-> E
E["Queue::enqueueUsing()<br>after_commit gate"] --> W
W["driver write<br>Redis / database / SQS"]
class G diagram-gates
class B,Q diagram-side
Four entry points, one driver write. The dotted arrows are the side doors: they join the column below the gates, and everything above their entry point never runs.
The gap, in one table #
Legend: ✅ honored or enforced, ❌ ignored, bypassed or unavailable.
| Behavior | Queue::push |
Bus::dispatch |
::dispatch() / dispatch() |
|---|---|---|---|
Enqueue moment (after_commit off) |
during the call | during the call | on PendingDispatch destruction |
Job's $connection, $queue, $delay |
❌ | ✅ | ✅ |
#[Queue], #[Connection], #[Delay] attributes (13) |
❌ | ✅ | ✅ |
Queue::route() central routing (13) |
❌ | ✅ | ✅ |
ShouldQueue missing |
queues anyway | runs synchronously | runs synchronously |
ShouldBeUnique lock |
❌ | ❌ | ✅ |
#[DebounceFor] debouncing (13) |
❌ | ❌ | ✅ |
PreparesForDispatch cancel hook (13) |
❌ | ❌ | ✅ |
Fluent ->chain() |
❌ | ❌ | ✅ |
->catch() |
❌ | ❌ | ✅ queued closures only |
Visible to Bus::fake() |
❌ | ✅ | ✅ |
| Return value | driver job id | driver job id | PendingDispatch |
Read the middle column top to bottom and the camouflage is visible: Bus::dispatch agrees with the bus forms on everything a test would catch, and with Queue::push on everything a test would not.
Note what Queue::push does not skip. Payload-level settings (tries, backoff, timeout, maxExceptions, failOnTimeout, encryption, batch id, an already configured chain) are applied by createObjectPayload() on both paths. The push form skips routing and dispatch time decisions, which is exactly what makes its failures quiet.
Pitfall 1: the job lands on the default queue #
Someone configures a job properly:
class WakeUpOnCallEngineerJob implements ShouldQueue
{
use Queueable;
public function __construct(public Incident $incident)
{
$this->onQueue('alarms');
$this->onConnection('redis-alarms');
}
}
Then another file does Queue::push(new WakeUpOnCallEngineerJob($incident)). The job runs, so nobody notices for months. But it runs on the default connection's default queue, because push() takes its queue from an argument, not from the job. Your carefully provisioned alarms worker pool sits idle while the default queue absorbs the load. A delay() set the same way silently becomes zero.
The bus path reads all three from the job (or, on Laravel 13, from #[Queue], #[Connection] and #[Delay] attributes). The push path needs them repeated as arguments, and nothing warns you when they disagree.
Pitfall 2: the dispatch that moved #
Because the bus forms dispatch in a destructor, holding the return value moves the dispatch:
$pending = WakeUpOnCallEngineerJob::dispatch($incident);
// nothing has been enqueued yet
logger('dispatched'); // log line appears before the job exists
unset($pending); // enqueued here
Assign the result to a variable for logging, collect pendings in an array, or return one from a helper method, and the enqueue slides to wherever that reference dies. That can be the end of the request. It can also cross a transaction boundary in either direction, which changes whether your after_commit settings even see an open transaction. Inside a long-running worker or Octane, a reference that survives in a captured closure can delay a dispatch indefinitely.
The fix is a habit: treat Job::dispatch(...) as a statement, never as a value. If you need the job instance, build it first and hand it to dispatch().
Pitfall 3: the queued job that ran inline #
The bus decides queue versus inline with one check (Dispatcher.php#L84):
return $this->queueResolver && $this->commandShouldBeQueued($command)
? $this->dispatchToQueue($command)
: $this->dispatchNow($command);
commandShouldBeQueued() is just $command instanceof ShouldQueue. Forget the interface and dispatch() runs your job synchronously, inside the web request, with no retries, no backoff, no timeout supervision. The code works in every environment, which is the problem. It works slowly, in the wrong process, and nobody sees it until a slow external call inside the job turns into a slow endpoint.
Queue::push has the opposite failure: it queues anything you hand it, interface or not. So the same class behaves async in one call site and inline in another, depending on which door it went through.
Make ShouldQueue a review checklist item for every new job. When you genuinely want inline execution, say it with dispatchSync(), which routes through the sync connection and keeps the intent visible.
Pitfall 4: unique jobs that are not unique #
For a job you dispatch yourself, ShouldBeUnique is enforced in exactly one place, PendingDispatch::shouldDispatch(), which acquires a cache lock before the bus is invoked. (Queued listeners, scheduled jobs and broadcasts acquire the same lock through their own dispatch paths.) Queue::push never constructs a PendingDispatch, so it never takes the lock. Every direct push of a "unique" job is a duplicate waiting to run.
Two sharp edges even on the correct path:
First, the default lock lifetime is forever. UniqueLock::acquire() falls back to 0 seconds when you define no uniqueFor (UniqueLock.php#L37), and a Laravel cache lock with zero seconds never expires on its own. If the lock is acquired and the job never runs to completion, nothing else with that key dispatches again until someone clears cache.
Second, transactions. The lock is taken at destruct time, before any after_commit deferral. On Laravel 10 and 11, if the transaction rolls back, the job is discarded but the lock stays behind, held until its uniqueFor expiry, or forever per the previous paragraph. Laravel 12 added a rollback callback that releases the lock, and 13 keeps it. If you run unique jobs with after_commit on 10 or 11, define uniqueFor or accept that a rollback can wedge that job class.
One more constraint the docs bury: ShouldBeUnique does not imply ShouldQueue. A unique job without ShouldQueue acquires the lock and then runs inline, skipping the worker path that releases locks.
Pitfall 5: the tests that pass anyway #
Bus::fake() and Queue::fake() intercept different layers, and each is blind to the other's.
Bus::fake() records every job dispatched through the bus forms, including jobs without ShouldQueue, because BusFake::dispatch() records before the routing decision. It sees nothing from Queue::push, which never touches the bus. Queue::fake() sees Queue::push directly, and sees the bus forms only when the job implements ShouldQueue and actually reaches the queue layer. Install both and Bus::fake() wins for bus dispatches, so Queue::assertPushed finds nothing.
Consequences worth memorizing:
- Under
Queue::fake(),assertPushedstays green whether the call site saysQueue::push($job)orWakeUpOnCallEngineerJob::dispatch(...). Migrate between the two forms and the test suite cannot tell, while uniqueness, routing and after commit semantics all change underneath it. QueueFake::later()records the job immediately and does not simulate the delay.QueueFakealso bypasses the transaction logic inenqueueUsing()entirely. Fakes prove intent, never timing.- The destructor applies in tests too.
$pending = Job::dispatch(); Bus::assertDispatched(...)fails because the destructor has not run. The assertion starts passing the moment you drop the assignment. - A unique job whose lock is already held never reaches the fake, so
Bus::fake()records nothing, andBusFakenever releases locks.QueueFakeis better behaved: it tracks the unique jobs it records,Queue::fake()releases their locks when the queue gets faked again, andQueue::releaseUniqueJobLocks()does it on demand. A suite that dispatches unique jobs underBus::fake()without clearing cache between tests can still poison itself.
Rule of thumb: assert intent (dispatched, chained, batched) with Bus::fake(), assert routing (queue name, connection, payload) with Queue::fake(), and never treat either as evidence about transactions or delays.
Pitfall 6: dispatched inside a transaction #
The classic bug: dispatch inside DB::transaction(), the worker picks the job up before the commit lands, the job reads the database and finds nothing. Both dispatch doors are equally exposed, because the fix lives in the queue layer, in enqueueUsing(), which both paths eventually call.
The decision, as implemented in 13 (Queue.php#L396):
protected function shouldDispatchAfterCommit($job)
{
if ($job instanceof ShouldQueueAfterCommit) {
return ! (isset($job->afterCommit) && $job->afterCommit === false);
}
if (! $job instanceof Closure && is_object($job) && isset($job->afterCommit)) {
return $job->afterCommit;
}
return $this->dispatchAfterCommit ?? false;
}
So the precedence is: the ShouldQueueAfterCommit interface, then the job's own afterCommit() or beforeCommit() call, then the connection's after_commit config. On 10 and 11 the interface was absolute and beforeCommit() could not override it. Since 12 it can.
Set 'after_commit' => true on the connection and the whole class of bugs disappears, at the cost of one contract change: every push through that connection now returns null instead of a job id, transaction or no transaction, because enqueueUsing() hands the work to the transaction manager and nothing propagates the driver's return value back. Timing without a transaction stays immediate. The return value does not, so any code that inspects the result of Queue::push for an id breaks the day you turn the config on.
Pitfall 7: closures without a safety net #
dispatch(fn () => ...) wraps the closure in CallQueuedClosure and hands back PendingClosureDispatch, which is the only place ->catch() exists:
dispatch(function () use ($podcast) {
$podcast->publish();
})->catch(function (Throwable $e) {
// runs when the queued closure exhausts its attempts
});
Queue::push(fn () => ...) also works, which surprises people. createPayload() performs the same CallQueuedClosure wrapping. But push returns a driver id, not a pending object, so there is nowhere to hang ->catch(), and a failed closure job fails silently into failed_jobs. If a closure job matters enough to need failure handling, it has probably outgrown being a closure. Make it a class.
Which traits your job actually needs #
Run php artisan make:job on Laravel 13 and the stub is two lines of declaration:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class WakeUpOnCallEngineerJob implements ShouldQueue
{
use Queueable;
}
That single Queueable is a composite. Laravel uses traits inside traits here, and the composite expands to the four classic ones (Queueable.php#L10):
namespace Illuminate\Foundation\Queue;
trait Queueable
{
use Dispatchable, InteractsWithQueue, QueueableByBus, SerializesModels;
}
Know what each part earns:
Dispatchable provides the static API: dispatch(), dispatchIf(), dispatchUnless(), dispatchSync(), dispatchAfterResponse(), withChain(). Drop it and Job::dispatch() is a fatal error, though dispatch(new Job) still works. One detail worth knowing: dispatchIf(false, ...) with a boolean skips constructing the job entirely and returns a Fluent stub, while passing a closure as the condition constructs the job first so the closure can inspect it.
Illuminate\Bus\Queueable (aliased as QueueableByBus inside the composite) supplies the routing state: $connection, $queue, $delay, $afterCommit, chain storage, and every fluent method from Pitfalls 1 and 6 (onQueue, onConnection, delay, afterCommit, beforeCommit, chain). Without it the bus still dispatches your job, but there is nothing to configure routing with.
InteractsWithQueue is for the job's runtime, not its dispatch. It gives handle() access to $this->attempts(), $this->release($delay), $this->delete(), $this->fail($exception). Since Laravel 11 it also carries test helpers: $job->withFakeQueueInteractions()->handle(); $job->assertReleased(30); lets you unit test retry decisions without a real queue.
SerializesModels changes what goes into the payload. With it, an Eloquent model in your constructor is stored as a ModelIdentifier (class, key, connection name and loaded relation names) and refetched fresh when the worker runs. Without it, the whole model serializes: stale attributes at execution time, bloated payloads, and crashes when a model carries an unserializable connection or closure. It only does anything when models (or collections of them) appear in your properties, and it pairs with deleteWhenMissingModels for the case where the row is gone by execution time.
The rules I would put in a handbook:
- In application code, keep the stub's composite
Queueableon every job, even when a given job uses only part of it. An unused trait costs nothing at runtime, and uniformity is worth more than a minimal import list. Stacking the composite together with the individual traits compiles fine (PHP deduplicates members that originate from the same trait), but it buys you nothing except a confusing use block. The real collision risk is a homegrown trait declaring its owndispatch(). - Do not add anything beyond the stub "just in case."
Batchablebelongs only on jobs actually dispatched throughBus::batch().ShouldBeUnique,ShouldBeEncryptedandShouldQueueAfterCommitare contracts with behavior, not decoration. - In package code, spell out the four traits explicitly instead of the composite, since
Illuminate\Foundation\Queue\Queueableonly exists on Laravel 11 and later and your package may support 10. - Remember the split: interfaces decide (queue or not, unique or not, encrypt or not), traits equip (static API, routing state, queue control, model handling).
ShouldQueuewithoutDispatchablefails loudly at the call site.DispatchablewithoutShouldQueuefails quietly at runtime, which is Pitfall 3.
On Laravel 13 you can also move static configuration out of the constructor into attributes:
#[Queue('alarms')]
#[Connection('redis-alarms')]
#[Tries(5)]
class WakeUpOnCallEngineerJob implements ShouldQueue
{
use Queueable;
}
The bus and the payload builder read these through the same resolution chain as the properties. Attributes fit values that never change per dispatch. Keep the fluent calls for decisions made at the call site.
Laravel 13 keeps widening the gap #
Every feature below ships in the dispatch path. None of them exist for Queue::push.
Central routing. Queue::route(WakeUpOnCallEngineerJob::class, queue: 'alarms', connection: 'redis-alarms') in a service provider replaces scattered onQueue() calls. The resolution order is attribute or property first, then the route table, then defaults. Mail, notifications, broadcasts and queued event listeners consult the same table, so one registry finally describes your whole queue topology. Raw Queue::push never looks at it.
Debouncing. #[DebounceFor(30)] delays each dispatch by the debounce window (an explicit ->delay() on the job wins over it) and stamps it with an owner token from a cache lock acquired in PendingDispatch. Every dispatch in a burst still enqueues, but at execution time CallQueuedHandler drops any job whose token was superseded by a newer dispatch (CallQueuedHandler.php#L256), so only the latest one runs. A second maxWait argument forces a run even while events keep arriving. The framework refuses to combine it with ShouldBeUnique, and when the dispatch is deferred with after_commit, a rollback releases the debounce lock.
A dispatch veto. Implement PreparesForDispatch and prepareForDispatch() runs inside shouldDispatch(); return false and the dispatch is cancelled before the bus hears about it.
FIFO support. ->onGroup() and ->withDeduplicator() on PendingDispatch target SQS FIFO message groups and deduplication.
Each release makes the two doors less interchangeable, always in the same direction. The bus accumulates behavior, the queue layer stays raw. A codebase that still mixes them in 13 is not choosing between equivalent styles. It is randomly opting some dispatches out of uniqueness, debouncing, routing and cancellation.
The convention #
My default recommendation starts from a boring observation: the official documentation teaches the two bus spellings, so that is what every new hire, every Stack Overflow answer and every AI agent already knows.
Between the two, I pick the helper, and this part is personal. SomeJob::dispatch($incident) forwards its arguments to the constructor through new static(...$arguments), a variadic hop that static analyzers cannot see through without plugin help. Wrong argument count, wrong type, renamed constructor parameter: none of it gets flagged at the call site. dispatch(new WakeUpOnCallEngineerJob($incident)) is a real constructor call, checked by PHPStan and your IDE for free. One more piece of Laravel magic (the same family I measured before) that you can opt out of at zero cost.
Paste this into your docs and AI agent instructions and adjust names:
## Queue dispatch rules
1. Dispatch jobs with `dispatch(new SomeJob(...))`. The static
`SomeJob::dispatch(...)` form is fine if your team standardizes on
it instead; pick exactly one. Queued closures use the same helper.
2. `Queue::push`, `Queue::pushOn`, `Queue::later`, `Queue::laterOn`,
`Queue::bulk` and `Bus::dispatch` are banned in application code.
Delays are expressed with `->delay()`, routing per rules 4 and 5.
3. Every job class implements `ShouldQueue` and uses the
`Illuminate\Foundation\Queue\Queueable` composite trait. Inline
execution is spelled `SomeJob::dispatchSync(...)`.
4. Static per-class routing lives in `#[Queue]` / `#[Connection]`
attributes or the `Queue::route()` registry, in one service provider.
Call site `onQueue()` / `onConnection()` is reserved for genuinely
dynamic routing and needs a comment saying why.
5. Never assign the result of `dispatch()` or `SomeJob::dispatch()` to
a variable. Configure fluently in the same statement.
6. Every queue connection sets `'after_commit' => true`. Jobs that must
escape it call `->beforeCommit()` explicitly. That override works on
every covered version; only overriding the ShouldQueueAfterCommit
interface itself needs Laravel 12 or newer.
7. Unique jobs define `uniqueFor`. No unlimited locks.
8. Tests assert intent with `Bus::fake()` and routing with
`Queue::fake()`, never both faked in one test without a reason.
Rules 1 and 2 are mechanically enforceable with spaze/phpstan-disallowed-calls:
parameters:
disallowedStaticCalls:
-
method: 'Illuminate\Support\Facades\Queue::push()'
message: 'dispatch jobs with SomeJob::dispatch(), see queue rules'
allowIn:
- tests/*
-
method: 'Illuminate\Support\Facades\Queue::later()'
message: 'use SomeJob::dispatch(...)->delay(...)'
allowIn:
- tests/*
Extend the list with pushOn, laterOn, bulk and Bus::dispatch(). Two honest caveats. These static rules catch the facades only, so an injected Illuminate\Contracts\Queue\Queue instance or Queue::connection('x')->push(...) needs matching disallowedMethodCalls entries on the contract to close the side doors. And the allowIn exception matters, because tests legitimately use the facade to arrange scenarios.
The rules above are a default, not a dogma. The actual rule is one form, chosen with open eyes. A long-lived codebase with a senior team and enforcement tooling can standardize on any of the three, Queue::push included. The low-level form is not bad. It is explicit, it is predictable, and a team that passes connection and queue arguments deliberately and handles uniqueness where it matters loses little. What ruins projects is mixing, where each call site opts out of a different subset of guarantees and nobody can say which without reading the framework. If you pick Queue::push, pick it on purpose, document what it skips, and enforce it with the same PHPStan rule pointed the other way.
When Queue::push is the right call #
The framework itself uses the low level API where the bus's assumptions do not hold. BroadcastManager pushes broadcast events with pushOn(), and Mailable queues itself the same way, because neither can assume the object carries Dispatchable or wants bus semantics. If you write framework extensions or packages that queue arbitrary user objects, you are in the same position, and Queue::push with explicit connection and queue arguments is the honest tool. Inside an application, that situation is rare enough that a code comment should mark every occurrence.
Summary #
Queue::push is a raw write to the queue backend. SomeJob::dispatch() and dispatch() are the command bus, wrapped in a PendingDispatch that dispatches from its destructor and carries uniqueness, debouncing, routing, transaction awareness and cancellation. The three lines from the code review at the top produce the same row in Redis only for the simplest possible job, and the set of jobs that qualify shrinks with every release.
Default to the bus, in whichever spelling your team picks. Mine is dispatch(new SomeJob(...)), the one static analyzers can check. If your team standardizes on something else, even Queue::push, make it a written decision and let static analysis hold the line. The failure mode is not the wrong form. It is three forms at once.
Verified against the Laravel 13.24 vendor source and the 10.x, 11.x and 12.x branch tips on 2026-08-11. Class and method names cited: Illuminate\Foundation\Bus\PendingDispatch, Illuminate\Foundation\Bus\Dispatchable, Illuminate\Bus\Dispatcher, Illuminate\Queue\Queue, Illuminate\Bus\UniqueLock, Illuminate\Queue\CallQueuedHandler, Illuminate\Support\Testing\Fakes\BusFake, Illuminate\Support\Testing\Fakes\QueueFake.