The model that never rode the queue
When we built the minimal job, we worked out rule #1: the constructor of a job is its payload. For simplicity we put a plain integer in that constructor: dispatch(42)
. In real life you'd pass the whole model, not just an id. Today we put an Eloquent model into a job's constructor and find out what actually rides the queue.
An order worth passing#
We need something worth putting in a constructor: a model with fields that can change. An order with a total will do:
php artisan make:model Order -m
INFO Model [app/Models/Order.php] created successfully. INFO Migration [database/migrations/2026_07_03_190609_create_orders_table.php] created successfully.
Fill in the migration and the model - a customer, a total, a status. #[Fillable]
is a PHP attribute: a label above the class that the framework reads; Laravel 13 leans on them, and we'll meet more today:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('customer');
$table->integer('total');
$table->string('status')->default('new');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('orders');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['customer', 'total', 'status'])]
class Order extends Model
{
protected function casts(): array
{
return ['total' => 'integer'];
}
}
Run the migration:
php artisan migrate
INFO Running migrations. 2026_07_03_190609_create_orders_table .. 1.29ms DONE
Setup complete. Now hand the order to a job.
Put a model in the constructor#
The job for this story: an order has been placed - the warehouse has to ship it. make:job
and the two usual decisions: what it carries, what it does. It carries the order itself, and it logs the total, so the log shows what the job knows at execution time:
php artisan make:job ShipOrder
INFO Job [app/Jobs/ShipOrder.php] created successfully.
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ShipOrder implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(): void
{
Log::info("shipping order #{$this->order->id}, total {$this->order->total}");
}
}
By rule #1, this whole order is about to become the payload. Last time an integer turned into s:7:"orderId";i:42
- so a model turns into… what? All of its columns, serialized one after another? (If your queue still holds the job from The minimal job's final measurement, run php artisan queue:work --once
first; the worker finally picks it up.) Dispatch and read the string - in tinker:
> $o = App\Models\Order::create(['customer' => 'Alice', 'total' => 100, 'status' => 'new']);
= App\Models\Order {#7934
customer: "Alice",
total: 100,
status: "new",
updated_at: "2026-07-04 17:32:30",
created_at: "2026-07-04 17:32:30",
id: 1,
}
> App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "O:18:\"App\\Jobs\\ShipOrder\":1:{s:5:\"order\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":5:{s:5:\"class\";s:16:\"App\\Models\\Order\";s:2:\"id\";i:1;s:9:\"relations\";a:0:{}s:10:\"connection\";s:6:\"sqlite\";s:15:\"collectionClass\";N;}}"
Now read that serialized string character by character: our order is not in it. Where the model should be, there's a different object: ModelIdentifier
, five fields - the class, the id, an empty relations list, the connection, and a collectionClass
slot (null today - it matters only when a whole Eloquent collection rides the queue). The sqlite
is this rig; yours will say mysql
or pgsql
. No customer, no status, no total. Check the string, just to be sure:
> str_contains(json_decode(DB::table('jobs')->value('payload'), true)['data']['command'], '"total"');
= false
> str_contains(json_decode(DB::table('jobs')->value('payload'), true)['data']['command'], 'ModelIdentifier');
= true
The queue took our order and kept a claim tag, like a coat check: “App\Models\Order
, id 1, connection sqlite
”. Alice and her hundred stayed in the orders
table. This is SerializesModels
at work: the trait that rode into our job unannounced, inside Queueable
back in the very first article.
The worker redeems the tag#
But handle()
logs $this->order->total
, and there's no total in the payload. Will this even work? Run the worker:
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
2026-07-04 17:33:31 App\Jobs\ShipOrder .. RUNNING 2026-07-04 17:33:31 App\Jobs\ShipOrder .. 7.25ms DONE [2026-07-04 17:33:31] local.INFO: shipping order #1, total 100
total 100
. The job knew everything, and the payload carried none of it. That can mean exactly one thing: on the way out of the queue, somebody took the claim tag to the orders
table and exchanged it for a fresh copy of the row. One SELECT at wake-up. That little exchange is the subject of this investigation.
Don't take the log's word for it: do the worker's first step by hand. The queue is empty again (a delivered job is deleted), so dispatch another and unserialize
its command right in tinker:
> App\Jobs\ShipOrder::dispatch(App\Models\Order::find(1)); DB::table('jobs')->count();
= 1
> unserialize(json_decode(DB::table('jobs')->value('payload'), true)['data']['command']);
= App\Jobs\ShipOrder {#7460
+order: App\Models\Order {#7456
id: 1,
customer: "Alice",
total: 100,
status: "new",
created_at: "2026-07-04 17:32:30",
updated_at: "2026-07-04 17:32:30",
},
+job: null,
+connection: null,
+queue: null,
+messageGroup: null,
+deduplicator: null,
+debounceOwner: "",
+delay: null,
+afterCommit: null,
+middleware: [],
+chained: [],
+chainConnection: null,
+chainQueue: null,
+chainCatchCallbacks: null,
}
The whole order came back - out of a string that contains none of it. The SELECT fired the moment you called unserialize
, right under your fingers. The same move that brought a job back to life in The minimal job, except this time deserialization goes shopping. (Everything below order
is the familiar panel of dormant switches.) Hand the leftover job to the worker (php artisan queue:work --once
) and let's find out who wrote this trick.
Find the hook#
Hold on, though. unserialize()
is plain PHP: older than Laravel, and it knows nothing about Eloquent. And the string we fed it contains a class name and values, no code. Somewhere between those two facts a SELECT happened. Interrogate the class:
> method_exists(App\Jobs\ShipOrder::class, '__unserialize');
= true
> basename((new ReflectionMethod(App\Jobs\ShipOrder::class, '__unserialize'))->getFileName());
= "SerializesModels.php"
Our job owns a magic method nobody typed into it. And PHP itself names the file it really lives in: the trait's. That's what use
-ing a trait means: a compile-time copy-paste. Queueable
pulled in SerializesModels
back in the first article, and from that moment __serialize()
and __unserialize()
are methods of ShipOrder
itself, as native as handle()
.
And __unserialize()
is not a Laravel invention; it's a hook in PHP's own unserialization protocol. unserialize()
works in three moves. It reads the class name out of the string (the O:18:"App\Jobs\ShipOrder"
from the autopsy) and autoloads that class. It creates the object without calling the constructor: that's why nobody asks where the constructor arguments would come from. And then, if the class defines __unserialize(array)
, PHP hands the parsed values to that method and steps aside; only when there's no such method does it fill the properties itself. So the hook never rides in the string - it arrives with the class the string names. Here's what ours does with the values:
public function __unserialize(array $values)
{
$properties = (new ReflectionClass($this))->getProperties();
foreach ($properties as $property) {
// ... static properties skipped, private/protected names unmangled ...
$property->setValue(
$this, $this->getRestoredPropertyValue($values[$name])
);
}
}
Property by property, every stored value passes through one gate:
protected function getRestoredPropertyValue($value)
{
if (! $value instanceof ModelIdentifier) {
return $value;
}
return is_array($value->id)
? $this->restoreCollection($value)
: $this->restoreModel($value);
}
Anything ordinary walks through unchanged. A ModelIdentifier
- our claim tag - is sent to restoreModel()
, and that is the SELECT that fired under your fingers. The worker plays the same scene on its side of the table: CallQueuedHandler
, the clerk from the first article, calls the same unserialize()
, the same hook fires, the same tag gets redeemed. And the road in is symmetric: serialize()
first asks the class for __serialize()
. The protocol wants that method public, so nothing stops us from calling it ourselves. Watch the exchange happen, no queue in sight:
> $job = new App\Jobs\ShipOrder(App\Models\Order::find(1)); $job->order;
= App\Models\Order {#8163
id: 1,
customer: "Alice",
total: 100,
status: "new",
created_at: "2026-07-04 17:32:30",
updated_at: "2026-07-04 17:32:30",
}
> $job->__serialize();
= [
"order" => Illuminate\Contracts\Database\ModelIdentifier {#7456
+class: "App\\Models\\Order",
+id: 1,
+relations: [],
+connection: "sqlite",
+collectionClass: null,
},
]
A freshly built job still holds the real Alice: the swap happens neither in the constructor nor at dispatch()
; until the moment of packing, the model rides inside the object at full size. One call to the hook, and there's the claim tag as a live object: the exact array PHP is about to flatten into the string. That's where models turn into tags.
Both halves of the trick#
So both halves of the exchange sit one call below the hooks, and they fit on one screen. The trail is one grep of vendor/laravel/framework
for ModelIdentifier
(source). Going in, __serialize()
pushes every property through its own gate - model → tag:
protected function getSerializedPropertyValue($value, $withRelations = true)
{
// ... branch for Eloquent collections omitted ...
if ($value instanceof QueueableEntity) { // an Eloquent model
return new ModelIdentifier( // -> class, id, relations, connection
get_class($value),
$value->getQueueableId(),
$withRelations ? $value->getQueueableRelations() : [],
$value->getQueueableConnection()
);
}
return $value; // everything else - as is
}
Coming out: tag → a fresh database query. Note the firstOrFail()
:
public function restoreModel($value)
{
return $this->getQueryForModelRestoration(
(new ($value->getClass()))->setConnection($value->connection), $value->id
)->useWritePdo()->firstOrFail()->loadMissing($value->relations ?? []);
}
Three deliberate design decisions in ten lines. Compactness: a tag rides the queue, not a megabyte of attributes. Freshness: the worker gets the row as it is now, not a copy from the past. And useWritePdo()
takes it seriously: the restore query goes to the write connection, where a lagging read replica can't serve yesterday's row. And look at the last line of the first half: return $value
- anything that is not an Eloquent model is serialized as is, by value. Hold on to both facts. And notice there is no magic to miss here: the tag is what you would have built by hand (dispatch($o->id)
, a query in handle()
), except the framework does it for every model property, invisibly, with firstOrFail()
attached.
The bill for freshness#
Between dispatch()
and the worker there is a gap in time, and inside that gap the world keeps moving. Run the experiment. From here on, one line per step: tinker runs several statements per line and prints only the last result, so the $_
trick has nothing left to shadow. Dispatch a shipping job and, while it waits, change the order:
> $o = App\Models\Order::create(['customer' => 'Bob', 'total' => 250, 'status' => 'new']); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> $o->update(['total' => 999]);
= true
An order for 250 went into the queue. The database now says 999. Which one ships?
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
2026-07-04 15:50:05 App\Jobs\ShipOrder .. RUNNING 2026-07-04 15:50:05 App\Jobs\ShipOrder .. 7.30ms DONE [2026-07-04 15:50:05] local.INFO: shipping order #2, total 999
We dispatched an order for 250 - and shipped 999. Nothing failed, nobody warned us: the tag was exchanged for the current row, as designed. The uncomfortable part: for ShipOrder
this is correct. The warehouse ships what the order is, not what it was at click time. Freshness is usually what you want. The trap is the word “usually”.
When the moment matters#
Some jobs need the moment, not the row. An accounting record captures what the customer saw at checkout; if the order changes later, that's a correction. We've already seen the recipe in the source: anything that isn't a model rides by value. So instead of the model, pass a snapshot, a plain object with the fields copied in:
php artisan make:class DTO/OrderSnapshot
INFO Class [app/DTO/OrderSnapshot.php] created successfully.
<?php
namespace App\DTO;
use App\Models\Order;
final readonly class OrderSnapshot
{
public function __construct(
public int $id,
public string $customer,
public int $total,
public string $status,
) {}
public static function fromOrder(Order $order): self
{
return new self($order->id, $order->customer, $order->total, $order->status);
}
}
And a job to carry it:
php artisan make:job RecordSale
INFO Job [app/Jobs/RecordSale.php] created successfully.
<?php
namespace App\Jobs;
use App\DTO\OrderSnapshot;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class RecordSale implements ShouldQueue
{
use Queueable;
public function __construct(public OrderSnapshot $snapshot) {}
public function handle(): void
{
Log::info("recording sale of order #{$this->snapshot->id}, total {$this->snapshot->total}");
}
}
The same treachery, one line at a time:
> $o = App\Models\Order::create(['customer' => 'Carol', 'total' => 400, 'status' => 'new']); App\Jobs\RecordSale::dispatch(App\DTO\OrderSnapshot::fromOrder($o)); DB::table('jobs')->count();
= 1
> $o->update(['total' => 999]);
= true
> json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "O:19:\"App\\Jobs\\RecordSale\":1:{s:8:\"snapshot\";O:21:\"App\\DTO\\OrderSnapshot\":4:{s:2:\"id\";i:3;s:8:\"customer\";s:5:\"Carol\";s:5:\"total\";i:400;s:6:\"status\";s:3:\"new\";}}"
> str_contains(json_decode(DB::table('jobs')->value('payload'), true)['data']['command'], 'total";i:400');
= true
And there's the difference, in the open: no ModelIdentifier
this time. The whole snapshot is written into the string by value, and total";i:400
physically sits inside it, fixed at dispatch time while the table already says 999
; the str_contains
only pins it down. No tag - nothing to exchange. The worker confirms:
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
2026-07-04 15:50:43 App\Jobs\RecordSale .. RUNNING 2026-07-04 15:50:43 App\Jobs\RecordSale .. 4.31ms DONE [2026-07-04 15:50:43] local.INFO: recording sale of order #3, total 400
The database says 999
, the record says 400
. That's the whole decision: a model when the job should see the present; a snapshot when it must remember the past. One caveat: copy scalars into the snapshot, not models. The exchange inspects only the job's own properties, so a model hidden inside a DTO rides by value: whole, stale, and without a claim tag.
The order that vanished#
Now the firstOrFail()
from restoreModel()
. The gap lets rows do more than change. They can disappear: the customer cancels, an admin cleans up, a cascade fires. Check what redeeming the tag gets you:
> $o = App\Models\Order::create(['customer' => 'Dave', 'total' => 175, 'status' => 'new']); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> $o->delete();
= true
php artisan queue:work --once
2026-07-04 15:51:22 App\Jobs\ShipOrder .. RUNNING 2026-07-04 15:51:22 App\Jobs\ShipOrder .. 6.91ms FAIL
> DB::table('jobs')->count();
= 0
> DB::table('failed_jobs')->count();
= 1
> strtok(DB::table('failed_jobs')->value('exception'), "\n");
= "Illuminate\\Database\\Eloquent\\ModelNotFoundException: No query results for model [App\\Models\\Order]. in .../Illuminate/Database/Eloquent/Builder.php:786"
FAIL
, and a corpse in a table called failed_jobs
: firstOrFail()
found no row and threw ModelNotFoundException
before handle()
ever ran. How that morgue works - retries, resurrections, last words - is its own article, a little later. Today's question is simpler: is a missing order an error? For ShipOrder
- no. The order is gone, there's nothing to ship, and a human already made that call by deleting it. The job isn't broken; it's obsolete. There's a switch for exactly that verdict. An attribute again, this time from the queue family:
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Support\Facades\Log;
#[DeleteWhenMissingModels]
class ShipOrder implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(): void
{
Log::info("shipping order #{$this->order->id}, total {$this->order->total}");
}
}
Once more, in a fresh tinker (the class just changed) - dispatch, delete, run the worker:
> $o = App\Models\Order::create(['customer' => 'Erin', 'total' => 320, 'status' => 'new']); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> $o->delete();
= true
php artisan queue:work --once
2026-07-04 15:52:11 App\Jobs\ShipOrder .. RUNNING 2026-07-04 15:52:11 App\Jobs\ShipOrder .. 6.69ms DONE
DONE
. But nothing was shipped:
grep -c 'shipping order #5' storage/logs/laravel.log
0
Zero matches: handle()
never ran. That DONE
is the sound of a job being quietly discarded: model gone → job deleted. No failure, no morgue record (failed_jobs
still holds only Dave's job). The #[DeleteWhenMissingModels]
contract: no model - no job. Sign it with your eyes open: if a job must react to deletion, that silence is itself a bug.
Relations ride by name#
One field of the tag is still empty: relations;a:0:{}
. Time to fill it. Give the order some items:
php artisan make:model OrderItem -m
INFO Model [app/Models/OrderItem.php] created successfully. INFO Migration [database/migrations/2026_07_03_203618_create_order_items_table.php] created successfully.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('order_items');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name'])]
class OrderItem extends Model
{
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['customer', 'total', 'status'])]
class Order extends Model
{
protected function casts(): array
{
return ['total' => 'integer'];
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
php artisan migrate
INFO Running migrations. 2026_07_03_203618_create_order_items_table .. 2.83ms DONE
Build an order with two items, load the relation, dispatch - read the tag:
> $o = App\Models\Order::create(['customer' => 'Frank', 'total' => 540, 'status' => 'new']); $o->items()->create(['name' => 'Keyboard']); $o->items()->create(['name' => 'Mouse']); $o->load('items'); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "O:18:\"App\\Jobs\\ShipOrder\":1:{s:5:\"order\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":5:{s:5:\"class\";s:16:\"App\\Models\\Order\";s:2:\"id\";i:6;s:9:\"relations\";a:1:{i:0;s:5:\"items\";}s:10:\"connection\";s:6:\"sqlite\";s:15:\"collectionClass\";N;}}"
relations;a:1:{i:0;s:5:"items";}
. The model carries two full OrderItem
rows; the tag keeps one word: "items"
. Relations ride by name, like the model rides by id. Who pays for the real rows? Catch it red-handed: unserialize
right here, query log on:
> $cmd = json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "O:18:\"App\\Jobs\\ShipOrder\":1:{s:5:\"order\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":5:{s:5:\"class\";s:16:\"App\\Models\\Order\";s:2:\"id\";i:6;s:9:\"relations\";a:1:{i:0;s:5:\"items\";}s:10:\"connection\";s:6:\"sqlite\";s:15:\"collectionClass\";N;}}"
> DB::enableQueryLog();
= null
> $restored = unserialize($cmd);
= App\Jobs\ShipOrder {#7430
+order: App\Models\Order {#8151
id: 6,
customer: "Frank",
total: 540,
status: "new",
created_at: "2026-07-04 15:52:45",
updated_at: "2026-07-04 15:52:45",
items: Illuminate\Database\Eloquent\Collection {#8590
all: [
App\Models\OrderItem {#8587
id: 1,
order_id: 6,
name: "Keyboard",
created_at: "2026-07-04 15:52:45",
updated_at: "2026-07-04 15:52:45",
},
App\Models\OrderItem {#8588
id: 2,
order_id: 6,
name: "Mouse",
created_at: "2026-07-04 15:52:45",
updated_at: "2026-07-04 15:52:45",
},
],
},
},
+job: null,
+connection: null,
+queue: null,
+messageGroup: null,
+deduplicator: null,
+debounceOwner: "",
+delay: null,
+afterCommit: null,
+middleware: [],
+chained: [],
+chainConnection: null,
+chainQueue: null,
+chainCatchCallbacks: null,
}
> collect(DB::getQueryLog())->pluck('query');
= Illuminate\Support\Collection {#7456
all: [
"select * from \"orders\" where \"orders\".\"id\" = ? limit 1",
"select * from \"order_items\" where \"order_items\".\"order_id\" in (6)",
],
}
One unserialize
- two queries: the order by id, the items by name. That's loadMissing()
from restoreModel()
. The worker pays this bill on every attempt, for every name in the tag: load half a relation tree onto the model and the job wakes up to a query storm. Don't need the relations? Don't let them board. The switch sits on the property:
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\WithoutRelations;
use Illuminate\Support\Facades\Log;
#[DeleteWhenMissingModels]
class ShipOrder implements ShouldQueue
{
use Queueable;
public function __construct(
#[WithoutRelations]
public Order $order,
) {}
public function handle(): void
{
Log::info("shipping order #{$this->order->id}, total {$this->order->total}");
}
}
Restart tinker and dispatch the same fully-loaded order:
> App\Jobs\ShipOrder::dispatch(App\Models\Order::with('items')->find(6)); DB::table('jobs')->count();
= 1
> json_decode(DB::table('jobs')->value('payload'), true)['data']['command'];
= "O:18:\"App\\Jobs\\ShipOrder\":1:{s:5:\"order\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":5:{s:5:\"class\";s:16:\"App\\Models\\Order\";s:2:\"id\";i:6;s:9:\"relations\";a:0:{}s:10:\"connection\";s:6:\"sqlite\";s:15:\"collectionClass\";N;}}"
relations;a:0:{}
- the items stayed home, even though they were loaded on the model. One query when the job wakes. If handle()
needs the items, it can ask for them itself.
The order that only pretended to vanish#
One more twist before we close: half the Eloquent models in the wild carry SoftDeletes
. Give the order that trait:
php artisan make:migration add_soft_deletes_to_orders_table
INFO Migration [database/migrations/2026_07_04_080133_add_soft_deletes_to_orders_table.php] created successfully.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable(['customer', 'total', 'status'])]
class Order extends Model
{
use SoftDeletes;
protected function casts(): array
{
return ['total' => 'integer'];
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
php artisan migrate
INFO Running migrations. 2026_07_04_080133_add_soft_deletes_to_orders_table .. 1.09ms DONE
Now the same story as Erin's - fresh tinker again, the model changed - dispatch, delete, run the worker. #[DeleteWhenMissingModels]
is still on the class - it should shrug this off. Right?
> $o = App\Models\Order::create(['customer' => 'Grace', 'total' => 610, 'status' => 'new']); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> $o->delete();
= true
> DB::table('orders')->where('id', 7)->value('deleted_at');
= "2026-07-04 15:55:02"
Note the last line: this time delete()
didn't remove the row; it stamped it. For every query in your app the order is gone. Now the worker:
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
2026-07-04 15:55:07 App\Jobs\ShipOrder .. RUNNING 2026-07-04 15:55:07 App\Jobs\ShipOrder .. 8.31ms DONE [2026-07-04 15:55:07] local.INFO: shipping order #7, total 610
Shipped. A deleted order, shipped. And #[DeleteWhenMissingModels]
never fired, because the model isn't missing. The restore query runs without global scopes (grep the source: newQueryForRestoration()
→ newQueryWithoutScopes()
), so the filter that SoftDeletes
installs for the rest of your app doesn't apply here. firstOrFail()
finds the stamped row and hands it to handle()
like nothing happened. If a job must respect soft deletion, that check is yours to write: $this->order->trashed()
at the top of handle()
. Nobody makes it for you.
The experiment is over. Six lines worth keeping:
- The model doesn't ride the queue. A claim tag does: class, id, relation names, connection.
- The tag is redeemed at wake-up. One SELECT on the worker's side - the job sees the present, not the dispatch moment (the warehouse ships 999).
- The exchange is two PHP hooks.
__serialize()writes tags,__unserialize()goes shopping - the hook arrives with the class, not the string. - The past needs a snapshot. Scalars in a DTO ride by value, fixed at dispatch (the books say 400).
- A missing row is a decision. Default:
ModelNotFoundException;#[DeleteWhenMissingModels]discards the job with no trace. Soft-deleted rows still ship - asktrashed(). - Relations ride as names. They reload on every attempt;
#[WithoutRelations]keeps the tag lean.
No magic - a tag in a table row, read down to the byte. And every payload we opened today carried that panel of dormant switches: delay
, queue
, tries
, backoff
… We flip them in 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