Here is a small mystery. You dispatch a job, then do something else in the same method, and the ordering is not what you wrote:

NotifySubscribers::dispatch($post);

Log::info('dispatched the notification');

The log line comes out before the job is on the queue. Not by much, but it can matter, and once you know why it explains a few stranger bugs too.

dispatch() returns an object, it does not queue

Job::dispatch() does not push anything. It builds and returns a PendingDispatch, and the actual push happens in that object's destructor.

That design is what makes the fluent API possible:

NotifySubscribers::dispatch($post)
    ->onQueue('notifications')
    ->delay(now()->addMinutes(5));

Each of those returns the same PendingDispatch so you can keep configuring it. Laravel cannot queue on the first call, because it does not yet know the queue or the delay. It waits until nobody is holding the object any more, then dispatches with whatever you configured.

In normal use the object has no other reference, so PHP destroys it at the end of the statement and the job is queued essentially immediately. You will never notice.

Where you do notice: assigning it

Give it a variable and the object stays alive until the variable goes out of scope:

public function publish(Post $post): void
{
    $pending = NotifySubscribers::dispatch($post);

    $this->doSomethingSlow();     // job is STILL not queued

    // queued here, when $pending is destroyed at the end of the method
}

If doSomethingSlow() takes a while, the job sits in memory that whole time rather than being available to workers. Assigning the result is rarely useful, and this is why.

Where it really bites: tests

This is the version people hit:

Queue::fake();

$pending = NotifySubscribers::dispatch($post);

Queue::assertPushed(NotifySubscribers::class);   // fails

The assertion runs while $pending is still alive, so nothing has been dispatched yet. Drop the assignment and it passes. If you genuinely need the reference, unset it first:

unset($pending);

Queue::assertPushed(NotifySubscribers::class);

The transaction connection

The destructor is also why dispatching inside a database transaction is risky. The object is destroyed at the end of the statement, well before your transaction commits, so the job can be on the queue and picked up by a worker while the rows it needs are still uncommitted and invisible.

That is one of the two causes of ModelNotFoundException in a queued job, and the fix is afterCommit, covered in database transactions in Laravel.

Ways to dispatch that skip all this

When you want the push to happen exactly where you wrote it, use the dispatcher directly:

use Illuminate\Support\Facades\Bus;

Bus::dispatch(new NotifySubscribers($post));

Bus::dispatch() queues immediately and returns no pending object. Related options:

Call What happens
Job::dispatch() queued when the returned object is destroyed
Bus::dispatch(new Job) queued right there
Job::dispatchSync() runs now, in this process
Job::dispatchAfterResponse() runs after the response is sent, no queue
dispatch(new Job) same as the helper, queued via the container

An exception does not save you

One more consequence worth knowing: destructors run while the stack unwinds. If something throws after you dispatched, the pending job is still destroyed and still queued. dispatch() is not undone by a later failure, which is another reason the work should be safe to run twice and, where it matters, held until the transaction commits.

Wrapping up

Job::dispatch() hands you a PendingDispatch and queues the job when that object is destroyed, which is what lets you chain onQueue() and delay() after it. In everyday code it is invisible. It shows up when you assign the result and wonder why nothing is queued yet, when a Queue::fake() assertion fails for no obvious reason, and when a transaction has not committed yet. Reach for Bus::dispatch() when you want the push to happen on that exact line.

Related