The minimal job
Everyone gives you the same advice: push the slow stuff onto a queue. The Laravel docs agree - there's a whole chapter on queues. But the thing you actually write is called a job, and there is no chapter about that. What is this thing, where does it live, and why is a queue pointless without one? Today we build the minimal job and look at every part of it with our own eyes.
Start from the stub#
One command:
php artisan make:job ProcessOrder
INFO Job [app/Jobs/ProcessOrder.php] created successfully.
Here is the file it generated, untouched:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessOrder implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
//
}
}
Before we write a single line, notice: the generator has already made three decisions for us.
implements ShouldQueue- the master switch. With it, a dispatched job goes to the queue; remove it, andhandle()runs immediately, right in your process.use Queueable- all the plumbing in one trait, worth opening in a moment.- Empty
__construct()andhandle()- the parts that are ours. The constructor is what the job carries;handle()is what it does.
"All the plumbing in one trait" isn't a figure of speech. The whole of Queueable
is a single use
line (Illuminate/Foundation/Queue/Queueable.php
):
namespace Illuminate\Foundation\Queue;
use Illuminate\Bus\Queueable as QueueableByBus;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
trait Queueable
{
use Dispatchable, InteractsWithQueue, QueueableByBus, SerializesModels;
}
Four smaller traits with a shared name. Dispatchable
is the dispatch we're about to run. SerializesModels
decides what a model becomes on the way into the queue. InteractsWithQueue
hands the running job its controls. The Bus Queueable
sets where the job goes and when. Each is a chapter, and each gets its moment later.
Replace the placeholders with the smallest thing that can prove the job works - one property and one log line:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ProcessOrder implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function handle(): void
{
Log::info("processing order #{$this->orderId}");
}
}
Meet tinker#
We need a place to run one-off code inside the application. Laravel ships one - tinker, an interactive console: you type a line of PHP, it runs inside your app and prints the result. Everything below happens there:
php artisan tinker
Psy Shell v0.12.24 (PHP 8.5.6 — cli) by Justin Hileman >
Dispatch it#
Dispatch the job. One tinker habit to get used to: it prints the return value of every line you run - the =
lines below are tinker talking:
> App\Jobs\ProcessOrder::dispatch(42);
= Illuminate\Foundation\Bus\PendingDispatch {#7439}
The 42
became $orderId
, and that return value, a PendingDispatch
, is Laravel saying “accepted”. In a fresh app the queue is a database table (jobs
), so the row should be one query away. Let's check:
> DB::table('jobs')->count();
= 0
Zero? We dispatched a job a second ago, and the table is empty. Except it isn't. Run the very same query again:
> DB::table('jobs')->count();
= 1
1
, from a query that read 0
a second earlier. Here's what happened. dispatch()
doesn't insert the job; it hands back that PendingDispatch
, an envelope that inserts the job in its destructor, the moment nothing holds it anymore. Tinker keeps each line's last result in $_
, so the envelope sat parked there, alive, all through the first count; that's why it read an empty table. But running that count replaced $_
with its own result, the envelope was finally unheld and discarded, its destructor ran, and the job landed. The second count saw it.
A couple of ways to skip the double-take. You can drop the reference by hand with $_ = null;
before you count. Simpler, and what we'll use for the rest of the series, is to never let the dispatch be the line's last result: put the count on the same line, App\Jobs\ProcessOrder::dispatch(42); DB::table('jobs')->count();
, and the envelope is discarded at its own semicolon before the count runs. Honest number, first ask.
And since it's just a row in a table, we can look at the whole thing:
> DB::table('jobs')->first();
= {#7450
+"id": 1,
+"queue": "default",
+"payload": "{\"uuid\":\"9920ee22-1a4f-49cf-9ee6-816c94dd59a8\",\"displayName\":\"App\\\\Jobs\\\\ProcessOrder\",\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"maxTries\":null,\"maxExceptions\":null,\"failOnTimeout\":false,\"backoff\":null,\"timeout\":null,\"retryUntil\":null,\"deleteWhenMissingModels\":false,\"data\":{\"commandName\":\"App\\\\Jobs\\\\ProcessOrder\",\"command\":\"O:21:\\\"App\\\\Jobs\\\\ProcessOrder\\\":1:{s:7:\\\"orderId\\\";i:42;}\",\"batchId\":null},\"createdAt\":1783065598,\"delay\":null}",
+"attempts": 0,
+"reserved_at": null,
+"available_at": 1783065598,
+"created_at": 1783065598,
}
id
, queue
, attempts
- readable at a glance. And that monster in +"payload"
is the letter we just mailed, everything our job is, packed into one string. Opening it is the next stop.
Open the letter#
The payload
is a JSON string. Decode it - and the envelope unfolds:
> $data = json_decode(DB::table('jobs')->value('payload'), true);
= [
"uuid" => "9920ee22-1a4f-49cf-9ee6-816c94dd59a8",
"displayName" => "App\\Jobs\\ProcessOrder",
"job" => "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries" => null,
"maxExceptions" => null,
"failOnTimeout" => false,
"backoff" => null,
"timeout" => null,
"retryUntil" => null,
"deleteWhenMissingModels" => false,
"data" => [
"commandName" => "App\\Jobs\\ProcessOrder",
"command" => "O:21:\"App\\Jobs\\ProcessOrder\":1:{s:7:\"orderId\";i:42;}",
"batchId" => null,
],
"createdAt" => 1783065598,
"delay" => null,
]
Read it top to bottom and the letter makes sense:
displayNameanddata.commandName- our class, by name.job-CallQueuedHandler@call: the return address. That's the framework's own clerk who will open this envelope on the other side - remember the name, we'll meet him when we run the worker.- The middle rows -
maxTries,backoff,timeout,retryUntil… - a panel of switches, all in the off position. We haven't touched any of them yet. - And
data.command- the one line that isn't JSON.O:21:"App\Jobs\ProcessOrder":1:{…}is PHP's native serialization format: an Object, class name 21 characters long, with 1 property. It's our job itself, written down as a string.
And if the whole job fits in a string, nothing stops us from bringing it back to life right here:
> unserialize($data['data']['command']);
= App\Jobs\ProcessOrder {#7470
+orderId: 42,
+job: null,
+connection: null,
+queue: null,
+messageGroup: null,
+deduplicator: null,
+debounceOwner: "",
+delay: null,
+afterCommit: null,
+middleware: [],
+chained: [],
+chainConnection: null,
+chainQueue: null,
+chainCatchCallbacks: null,
}
Top line: +orderId: 42
- our constructor argument, alive again. That's a rule worth engraving: the constructor of a job is its payload. To be exact, the job's properties are the payload - whatever ends up in them gets serialized into the letter and rides the queue - and the constructor is where you fill them; a promoted parameter like public int $orderId
does both in one stroke. Everything below orderId
came with the Queueable
trait - the same switches we saw in the JSON, still off, waiting for their moment.
So that's what a dispatched job is: your data plus delivery instructions, packed into one row of a table. It's still lying in the mailbox, though - nobody has read it. Time to hire the mailman.
Hire the mailman#
The mailman is called a worker: a separate process that takes letters out of the box and does what they say. It's not part of your web request, not part of tinker; it's its own program that you start yourself. Open a second terminal. The --once
flag means “take one letter, deliver it, go home” - perfect for watching one delivery at a time (a real deployment runs queue:work
without the flag, around the clock; a story for later):
php artisan queue:work --once
2026-07-03 08:28:22 App\Jobs\ProcessOrder .. RUNNING 2026-07-03 08:28:22 App\Jobs\ProcessOrder .. 4.97ms DONE
RUNNING
, then DONE
in under five milliseconds. But did our code actually run? It had exactly one observable effect, the log line:
tail -n 1 storage/logs/laravel.log
[2026-07-03 08:28:22] local.INFO: processing order #42
There's our handle()
: it ran, it knew its $orderId
, and it did all of that in the worker's process. The letter traveled between two different PHP programs through a database table. And it was the clerk from the return address, CallQueuedHandler
, who unsealed it on arrival and called our handle()
- just as the envelope promised. One more look at the mailbox:
> DB::table('jobs')->count();
= 0
Empty. A delivered letter is destroyed: on success the worker deletes the row. And that's the whole system. Everything the word “queue” means in Laravel is what you just watched: one process writes a letter into a table, another process - maybe a millisecond later, maybe an hour later - takes it out and does the work. A job is the letter. The queue is the mailbox. The worker is the mailman. Everything else builds on these three.
Why mail letters to yourself#
A fair question is overdue. The direct call would have done the same work; instead we got a table, a second terminal and a mailman. Why? Because of what handle()
usually costs. Ours costs nothing, so make it cost something honest: a real email over SMTP takes a couple of seconds. One new line in handle()
:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ProcessOrder implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function handle(): void
{
sleep(2); // pretend we send an email over SMTP
Log::info("processing order #{$this->orderId}");
}
}
One tinker rule before we measure: a running session keeps classes in memory and never sees file edits. After every change to a class, restart it (exit
, then php artisan tinker
).
Now, dispatch()
has a sibling: dispatchSync()
. Same job, but executed right here, right now, no mailbox involved: exactly what your code looks like without queues. That's the old world. Laravel even ships a stopwatch for us, Benchmark
, and tinker knows it without any imports. Time the old world:
> Benchmark::measure(fn () => App\Jobs\ProcessOrder::dispatchSync(42));
= 2009.107442
Two full seconds (measure()
reports milliseconds). If this were a controller, that's a user staring at a spinner while the “email” crawls out. Now the mailbox way - with a peek into the box right after:
> Benchmark::measure(fn () => App\Jobs\ProcessOrder::dispatch(42));
= 5.376898
> DB::table('jobs')->count();
= 1
Five milliseconds: the cost of writing a letter and dropping it into the box, and the count confirms it's in there, waiting. Put the two worlds side by side:
The two seconds didn't disappear; nobody magically made the email faster. They moved: out of the user's face, into the mailman's shift. That is the bargain of a queue - the user pays milliseconds, the worker pays seconds. Everything else about queues is fine print on this bargain, and the fine print is where the next article picks up: what happens when the letter carries not a number but a whole database record. But that's for later. (And the letter from that last measurement is still sitting in the box, by the way. It'll be the first thing your mailman picks up.)
The first visit is over. Six lines worth keeping:
- A job is a constructor and a
handle(). The constructor is what it carries,handle()is what it does - andimplements ShouldQueueis the master switch. - The constructor of a job is its payload. Whatever lands in the properties gets serialized into the letter; a promoted parameter fills them in one stroke.
dispatch()just writes a row. The queue is a table, the letter is your job serialized - open it with aSELECT.- The string is alive.
unserialize()brings the job back - the whole object rides the queue by value. - The worker is a separate PHP program. It reads the box, and the clerk from the envelope -
CallQueuedHandler- unseals the letter and calls yourhandle(). - The bargain: the user pays milliseconds, the worker pays seconds. 2009 ms became 5.4 ms - the two seconds moved, they didn't disappear.
- 01 The minimal job
- 02 The model that never rode the queue
- 03 Beyond the minimal job
- 04 Where jobs go when they die
- 05 Queue the listener
- 06 The limits of patience
- 07 The door before the job
- 08 What a group costs
- 09 Workers in production