Beyond the minimal job
This series has been circling the jobs
table for two articles now. The minimal job gave us ProcessOrder
(a constructor, a handle()
, and nothing else), and last time we pinned down exactly what rides in its payload. A minimal job works - and for plenty of jobs that's all the class they'll ever need.
But keep building around it, and sooner or later the real world starts asking questions the minimal job can't answer. The product manager wants the cart reminder to reach the customer tomorrow morning, not now. A flaky bug report lands: once an hour, the worker picks up an order that isn't in the database yet. A user clicks “Confirm” three times - and order #46 gets three invoices. A security review asks who else can read what your payloads carry. And one night the checkout gets stuck behind ten thousand report exports.
Five very different problems, from five directions. And it turns out the job class has an answer to every one of them. So that's the plan: five problems, a job for each, shaped the way it would look in a real codebase. We flip the switch, read what actually changes in the database, and note when you'd want it.
Run it later#
Some jobs shouldn't run right away. “Remind the customer about their cart tomorrow morning.” “Give the payment provider a minute before checking the status.” Meet the job for this story - you know the drill from The minimal job: make:job
, then fill in the constructor and handle()
:
php artisan make:job SendCartReminder
INFO Job [app/Jobs/SendCartReminder.php] created successfully.
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class SendCartReminder implements ShouldQueue
{
use Queueable;
public function __construct(public int $cartId) {}
public function handle(): void
{
Log::info("reminding about cart #{$this->cartId}");
}
}
The switch we're after is called delay
, and it lives right on the dispatch line. In tinker:
> App\Jobs\SendCartReminder::dispatch(42)->delay(60); DB::table('jobs')->count();
= 1
> DB::table('jobs')->value('available_at') - DB::table('jobs')->value('created_at');
= 60
Now the interesting part. The row has two timestamps: created_at
- when the job was dispatched, and available_at
- don't run before. All ->delay(60)
did was set them sixty seconds apart. No timer, no scheduler - a number in a column. What does the worker make of it? Let's check:
php artisan queue:work --once
And… nothing. Not a single line of output. The worker came, found one job stamped “not before”, and exited empty-handed. The job is untouched (count()
says 1
). A minute later we run the exact same command. The only thing that changed is the clock:
php artisan queue:work --once
2026-07-05 03:25:21 App\Jobs\SendCartReminder .. RUNNING 2026-07-05 03:25:21 App\Jobs\SendCartReminder .. 3.71ms DONE
That's all the mechanism there is: on every visit the worker simply skips rows whose available_at
is still in the future. And delay()
accepts more than seconds (->delay(now()->addMinutes(10))
, ->delay(now()->addDay())
). Any date works, because in the end it's all the same thing: one timestamp in one column.
One more form of the same switch. Waiting isn't a whim of this particular dispatch: a cart reminder should never fire immediately, that's its nature. Laravel 13 lets the class say so itself, with a PHP attribute, the same label-above-the-code form the model wore last time (#[Fillable]
), only this one sets a queue default:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Delay;
use Illuminate\Support\Facades\Log;
#[Delay(60)]
class SendCartReminder implements ShouldQueue
{
use Queueable;
public function __construct(public int $cartId) {}
public function handle(): void
{
Log::info("reminding about cart #{$this->cartId}");
}
}
The class changed, so restart tinker: it's still holding the old one in memory. Now an ordinary dispatch waits sixty seconds without being asked:
> App\Jobs\SendCartReminder::dispatch(43); DB::table('jobs')->count();
= 1
> DB::table('jobs')->value('available_at') - DB::table('jobs')->value('created_at');
= 60
And when a particular dispatch knows better, the dispatch line still gets the last word. (Job #43 is still parked with its sixty seconds - from now on, when a scene needs a clean bench, the wipe happens on camera: delete()
returns the number of rows it swept.)
> DB::table('jobs')->delete();
= 1
> App\Jobs\SendCartReminder::dispatch(44)->delay(10); DB::table('jobs')->count();
= 1
> DB::table('jobs')->value('available_at') - DB::table('jobs')->value('created_at');
= 10
The class said “sixty”, the dispatch line said “ten” - the dispatch line won. That's the rule of thumb for every switch we'll meet today: what belongs to the job's nature goes on the class, what belongs to the situation goes on the dispatch line; and the dispatch line always outranks the class.
Dispatch after the commit#
In real code a job is rarely dispatched alone. You save an order into the database and dispatch a job about it - two actions that belong together, so you wrap them in a transaction: all-or-nothing. Our patient here is ProcessOrder
from the first article, back on its minimal shift (if yours still carries the benchmark sleep(2)
, take it out). A fair question nobody asks until production: what happens to a job dispatched from inside a transaction? We don't even need the order - the transaction and the dispatch are enough:
> DB::table('jobs')->delete();
= 1
> DB::beginTransaction(); App\Jobs\ProcessOrder::dispatch(45); DB::table('jobs')->count();
= 1
> DB::rollBack(); DB::table('jobs')->count();
= 0
Read it again: the job was in the queue; after the rollback it's gone. No error, no trace. But why? Because our queue is a table in the same database: the job's INSERT
joined the transaction like any other insert, and the rollback erased it along with everything else the transaction did. On a rollback that's exactly what you want: a job for an order that never existed should never run.
But there are two problems. First, nobody expects dispatch()
to be undoable: your code already logged “job queued” while nothing was queued. And second, the queue is not always your database: in production it often lives in Redis or SQS, which know nothing about your transactions. There the job becomes visible instantly, and a fast worker picks it up before your commit: handle()
goes looking for order #45, and it isn't in the database yet. A classic flaky production bug: never reproduces locally, fires once an hour in prod.
The fix in its situational form lives on the dispatch line, just like ->delay()
did: ->afterCommit()
. Hold this dispatch until the transaction's fate is known. The counts get their own lines again here; the zeros are the point:
> DB::beginTransaction();
= null
> App\Jobs\ProcessOrder::dispatch(45)->afterCommit();
= Illuminate\Foundation\Bus\PendingDispatch {#7419}
> DB::table('jobs')->count();
= 0
> DB::table('jobs')->count();
= 0
> DB::commit();
= null
> DB::table('jobs')->count();
= 1
Two zeros, and this time the second one is not the $_
trick. The job is deliberately held back: COMMIT
- it's queued; ROLLBACK
- it would never have appeared at all. Works the same on every queue backend, Redis included. But think about who owns this decision. ProcessOrder
is always born next to database writes, so it should always wait for the verdict. That's its nature, and by the rule from the last section, nature belongs on the class. This switch is a property the Queueable
trait already owns; flip it in the constructor - not a declared property, and why not is a trap we'll spring at the end of this article:
<?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)
{
$this->afterCommit = true;
}
public function handle(): void
{
Log::info("processing order #{$this->orderId}");
}
}
Fresh tinker, same experiment. Look at the dispatch line: it's the bare dispatch(45)
the rollback ate at the top of this section, no ->afterCommit()
in sight. The difference lives in the class now:
> DB::table('jobs')->delete();
= 1
> DB::beginTransaction();
= null
> App\Jobs\ProcessOrder::dispatch(45);
= Illuminate\Foundation\Bus\PendingDispatch {#7411}
> DB::table('jobs')->count();
= 0
> DB::table('jobs')->count();
= 0
> DB::commit();
= null
> DB::table('jobs')->count();
= 1
Held back again. Now no dispatch line anywhere in the codebase can forget it. (There's also a per-connection form - 'after_commit' => true
on your queue connection in config/queue.php
- when every job on that connection should behave this way.) The takeaway: a job born inside a transaction almost always wants afterCommit
.
Don't queue it twice#
A user clicks “Confirm order”. The button is slow, so they click it again. And once more, to be sure. Three clicks, three dispatches - and if the job in question generates the invoice, order #46 ends up with three invoices. What we want is a rule: if an identical job is already queued, don't accept another one. The rule is a contract, ShouldBeUnique
, and here's a job born with it:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public int $orderId) {}
public function uniqueId(): string
{
return (string) $this->orderId;
}
public function handle(): void
{
Log::info("generating invoice for order #{$this->orderId}");
}
}
implements ShouldBeUnique
is the rule itself; uniqueId()
answers the question the rule immediately raises - what counts as “identical”? Here, the order number: one invoice job per order. (Skip uniqueId()
and the key defaults to the class name - one such job in the queue, period.) Now, the double-click storm:
> DB::table('jobs')->delete();
= 1
> App\Jobs\GenerateInvoice::dispatch(46);
= Illuminate\Foundation\Bus\PendingDispatch {#7442}
> App\Jobs\GenerateInvoice::dispatch(46);
= Illuminate\Foundation\Bus\PendingDispatch {#7408}
> App\Jobs\GenerateInvoice::dispatch(46);
= Illuminate\Foundation\Bus\PendingDispatch {#7438}
> DB::table('jobs')->count();
= 1
> DB::table('cache_locks')->pluck('key');
= Illuminate\Support\Collection {#7431
all: [
"laravel-cache-laravel_unique_job:App\\Jobs\\GenerateInvoice:46",
],
}
Three dispatches, three polite PendingDispatch
replies - Laravel never complained, never threw. But the queue holds one job, and the last line shows the enforcer: a lock in your cache (on the database cache store that's literally the cache_locks
table next door), keyed by class and uniqueId
. While it's held, duplicates are rejected silently: the second and third were dropped at the door. A different order is a different key, so it gets in fine:
> App\Jobs\GenerateInvoice::dispatch(47); DB::table('jobs')->count();
= 2
Run both and check the paper trail:
php artisan queue:work --once
php artisan queue:work --once
tail -n 2 storage/logs/laravel.log
2026-07-05 05:12:59 App\Jobs\GenerateInvoice .. RUNNING 2026-07-05 05:12:59 App\Jobs\GenerateInvoice .. 2.69ms DONE 2026-07-05 05:13:00 App\Jobs\GenerateInvoice .. RUNNING 2026-07-05 05:13:00 App\Jobs\GenerateInvoice .. 4.11ms DONE [2026-07-05 05:12:59] local.INFO: generating invoice for order #46 [2026-07-05 05:13:00] local.INFO: generating invoice for order #47
Order #46 got exactly one invoice - the log is the proof. The lock is held while the job waits in the queue and while it runs; the moment the job completes, it's released:
> DB::table('cache_locks')->count();
= 0
> App\Jobs\GenerateInvoice::dispatch(46); DB::table('jobs')->count();
= 1
Both invoices done - the lock table is empty, and a fresh #46 walks right in. Careful with the conclusion, though: ShouldBeUnique
dedupes overlapping dispatches only. Once the job has run, an identical dispatch is welcome again, so a job that must never repeat its side effect still needs an idempotency check of its own. Two refinements for later: #[UniqueFor(3600)]
puts a safety timer on the lock (a job that dies - or a jobs
row you delete by hand - never releases it), and a sibling contract, ShouldBeUniqueUntilProcessing
, releases the lock as soon as the worker picks the job up rather than when it finishes.
Seal the payload#
Charging a customer's card is queue work by nature: nobody wants the checkout page to hang while a payment provider thinks. So: ChargeCard
, and the job needs the number:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ChargeCard implements ShouldQueue
{
use Queueable;
public function __construct(
public int $orderId,
public string $cardNumber,
) {}
public function handle(): void
{
Log::info("charging order #{$this->orderId}");
}
}
And here rule #1 from The minimal job - the constructor of a job is its payload - comes back to collect. Wipe the bench from the last section, then dispatch and read the row:
> DB::table('jobs')->delete();
= 1
> App\Jobs\ChargeCard::dispatch(48, '4242-4242-4242-4242'); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "O:19:\"App\\Jobs\\ChargeCard\":2:{s:7:\"orderId\";i:48;s:10:\"cardNumber\";s:19:\"4242-4242-4242-4242\";}"
Two properties in the serialized job, and the second one is the problem: the full card number, verbatim, in a database table. We've been reading payloads with a SELECT
all series. So can anyone else with access to your database, its backups, or a dump on a colleague's laptop. And if the job ever fails, its payload gets copied to failed_jobs
and sits there forever. (In real payment code this would be the provider's token: a raw card number shouldn't enter your app at all; treat it as a stand-in for any secret a payload might carry.) The fix is one more contract: ShouldBeEncrypted
- one line in the signature, one import:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ChargeCard implements ShouldQueue, ShouldBeEncrypted
{
use Queueable;
public function __construct(
public int $orderId,
public string $cardNumber,
) {}
public function handle(): void
{
Log::info("charging order #{$this->orderId}");
}
}
Restart tinker. And that plaintext row has no business sitting in the table, so it goes first:
> DB::table('jobs')->delete();
= 1
> App\Jobs\ChargeCard::dispatch(49, '4242-4242-4242-4242'); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "eyJpdiI6IjR4WmlySHlnaXJueTRsOGtHWElXRlE9PSIsInZhbHVlIjoidStCTUhad2JRMGNUSCt3ek9MaklrZ0w3Q0xsR0s3U3FBYTFZelBHMExsK20rNUJ2dHE1VC9wTGdtMGc0QSswdk5nVlh6bFk5R3U3Zlp0TWhBWk1teldqZmc2SG41QkRWN3hQOW1jellQckM5dzduUjN4aU10SnZoL01mVlZvd0J5WDl6NnVkWmpYM3d4Y08yZ3h0Z2N3PT0iLCJtYWMiOiJlYTliZDc5ZTdkZTU2NWIyNGNmZmNkZmE4YjI4ZTlhNGM4MzdlNmNiNzQwNzhhNWU1ZWE4NjI2YWVlMTE1ZWJiIiwidGFnIjoiIn0="
> str_contains(DB::table('jobs')->value('payload'), '4242-4242-4242-4242');
= false
Where the serialized job used to be, there's ciphertext, sealed with your application's APP_KEY
, and the card number is nowhere to be found in the raw payload. Two honest footnotes. Only the command inside is sealed: the envelope - job class name, uuid, attempts - stays readable, so the secret must live in the properties, not the class name. And the seal is the APP_KEY
: rotate it while encrypted jobs sit in jobs
or failed_jobs
, and they become undecryptable - unless the old key stays in APP_PREVIOUS_KEYS
.
The best part is what didn't change: the worker holds the key, so execution works exactly as before, and inside handle()
the property is plain $this->cardNumber
again:
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
2026-07-05 05:13:49 App\Jobs\ChargeCard .. RUNNING 2026-07-05 05:13:49 App\Jobs\ChargeCard .. 2.70ms DONE [2026-07-05 05:13:49] local.INFO: charging order #49
Give urgent jobs a fast lane#
So far every job has been equal: one queue, first come, first served. Production disagrees. A checkout stuck behind ten thousand nightly report exports is a support ticket waiting to happen. The fix is in plain sight. Remember the row from The minimal job? It had +"queue": "default"
in it. Queues have names. Two actors for this scene. The report export is ordinary work, happy in the default lane:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ExportReport implements ShouldQueue
{
use Queueable;
public function __construct(public int $reportId) {}
public function handle(): void
{
Log::info("exporting report #{$this->reportId}");
}
}
The checkout is money, so it always claims the fast lane. That's its nature, which means it goes on the class, with the same attribute form delay
used:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Support\Facades\Log;
#[Queue('high')]
class ProcessCheckout implements ShouldQueue
{
use Queueable;
public function __construct(public int $cartId) {}
public function handle(): void
{
Log::info("processing checkout for cart #{$this->cartId}");
}
}
Two reports land in the queue first, then a checkout:
> App\Jobs\ExportReport::dispatch(7); App\Jobs\ExportReport::dispatch(8); DB::table('jobs')->count();
= 2
> App\Jobs\ProcessCheckout::dispatch(50); DB::table('jobs')->pluck('queue');
= Illuminate\Support\Collection {#7477
all: [
"default",
"default",
"high",
],
}
Three rows, two queue names: the reports sit in default
, the checkout in high
- the attribute did its job. Now run the worker exactly the way we always have:
php artisan queue:work --once
2026-07-05 05:14:18 App\Jobs\ExportReport .. RUNNING 2026-07-05 05:14:18 App\Jobs\ExportReport .. 6.85ms DONE
The worker took report #7, the oldest job in line, and never even glanced at the checkout. A worker only reads the queues it's told about, and by default that list has exactly one entry: default
. A named queue that no worker reads is a black hole: the checkout would sit in high
forever. (A real production classic: someone names a queue, forgets to tell the worker, and wonders where the jobs go.) The list is a flag:
php artisan queue:work --once --queue=high,default
2026-07-05 05:14:19 App\Jobs\ProcessCheckout .. RUNNING 2026-07-05 05:14:19 App\Jobs\ProcessCheckout .. 2.76ms DONE
The checkout jumped the line: the youngest job in the table, served first. There is no priority number, no sorting, no special column: the worker simply drains the queues in list order. Everything from high
first, then default
. Leftmost wins. One more run and the log tells the story in one column:
php artisan queue:work --once --queue=high,default
tail -n 3 storage/logs/laravel.log
2026-07-05 05:14:19 App\Jobs\ExportReport .. RUNNING 2026-07-05 05:14:19 App\Jobs\ExportReport .. 3.84ms DONE [2026-07-05 05:14:18] local.INFO: exporting report #7 [2026-07-05 05:14:19] local.INFO: processing checkout for cart #50 [2026-07-05 05:14:19] local.INFO: exporting report #8
Report #7, then cart #50, then report #8 - the checkout overtook a report that was dispatched before it. The flag itself lives wherever your worker is started; on a real server that's a supervisor config (a story for the article about workers). And a dispatch-line override exists here too, of course: ->onQueue('default')
would send one particular checkout back to the slow lane, because the dispatch line always outranks the class.
public $queue = 'high';
and public $afterCommit = true;
declared as properties in guides all over the internet. In a Laravel 13 job that's a fatal error: the Queueable
trait already owns those properties, and redeclaring a trait property with a different default is a conflict PHP won't tolerate. Assign them in the constructor - or, where an attribute exists (#[Queue]
, #[Delay]
…), use that; afterCommit
has no attribute form.Five problems, five switches, six jobs (the fast lane needed someone to overtake), and not one switch took more than a few lines. Six lines worth keeping:
delayis a timestamp.->delay()and#[Delay]only setavailable_at- no timer, no scheduler; the worker skips rows whose time hasn't come.afterCommitholds the dispatch. A job born inside a transaction waits for the verdict:COMMITqueues it,ROLLBACKmeans it never existed.ShouldBeUniqueis a lock in your cache. Keyed by class +uniqueId(), released on completion - it dedupes overlapping dispatches, not repeats.ShouldBeEncryptedseals the command withAPP_KEY. The envelope - class name, metadata - stays readable.- Queues are names; priority is list order.
--queue=high,defaultdrains left to right, and a queue no worker reads is a black hole. - Nature goes on the class, situation on the dispatch line - and the dispatch line always outranks the class.
Every claim checked with a SELECT
- against jobs
, once against cache_locks
- no magic, just columns. The switch panel is bigger than what we flipped today - #[Tries]
, #[Backoff]
, #[Timeout]
, #[MaxExceptions]
… - but those answer a darker question: what happens when a job fails? That's the next article.
- 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