>Dudenkoff_
← Lab / Laravel · Queues

The model that never rode the queue

Laravel 13 · PHP 8.5 · database driver · July 4, 2026 · part 2 of 9

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:

terminal
php artisan make:model Order -m
output
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:

database/migrations/..._create_orders_table.php
<?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');
    }
};
app/Models/Order.php
<?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:

terminal
php artisan migrate
output
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:

terminal
php artisan make:job ShipOrder
output
INFO  Job [app/Jobs/ShipOrder.php] created successfully.
app/Jobs/ShipOrder.php
<?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:

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:

tinker
> 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:

terminal
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
output
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:

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:

tinker
> 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:

vendor · Illuminate/Queue/SerializesModels.php
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:

vendor · Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php
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:

tinker
> $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:

vendor · Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php
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() :

vendor · same trait
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:

tinker
> $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?

terminal
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
output
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:

terminal
php artisan make:class DTO/OrderSnapshot
output
INFO  Class [app/DTO/OrderSnapshot.php] created successfully.
app/DTO/OrderSnapshot.php
<?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:

terminal
php artisan make:job RecordSale
output
INFO  Job [app/Jobs/RecordSale.php] created successfully.
app/Jobs/RecordSale.php
<?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:

tinker
> $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:

terminal
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
output
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:

tinker
> $o = App\Models\Order::create(['customer' => 'Dave', 'total' => 175, 'status' => 'new']); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> $o->delete();
= true
terminal
php artisan queue:work --once
output
2026-07-04 15:51:22 App\Jobs\ShipOrder .. RUNNING
2026-07-04 15:51:22 App\Jobs\ShipOrder .. 6.91ms FAIL
tinker
> 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:

app/Jobs/ShipOrder.php
<?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:

tinker
> $o = App\Models\Order::create(['customer' => 'Erin', 'total' => 320, 'status' => 'new']); App\Jobs\ShipOrder::dispatch($o); DB::table('jobs')->count();
= 1
> $o->delete();
= true
terminal
php artisan queue:work --once
output
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:

terminal
grep -c 'shipping order #5' storage/logs/laravel.log
output
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:

terminal
php artisan make:model OrderItem -m
output
INFO  Model [app/Models/OrderItem.php] created successfully.

INFO  Migration [database/migrations/2026_07_03_203618_create_order_items_table.php] created successfully.
database/migrations/..._create_order_items_table.php
<?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');
    }
};
app/Models/OrderItem.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['name'])]
class OrderItem extends Model
{
}
app/Models/Order.php
<?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);
    }
}
terminal
php artisan migrate
output
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:

tinker
> $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:

tinker
> $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:

app/Jobs/ShipOrder.php
<?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:

tinker
> 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:

terminal
php artisan make:migration add_soft_deletes_to_orders_table
output
INFO  Migration [database/migrations/2026_07_04_080133_add_soft_deletes_to_orders_table.php] created successfully.
database/migrations/..._add_soft_deletes_to_orders_table.php
<?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();
        });
    }
};
app/Models/Order.php
<?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);
    }
}
terminal
php artisan migrate
output
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?

tinker
> $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:

terminal
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
output
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 - ask trashed() .
  • 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.


Next
Beyond the minimal job
The series
  1. 01 The minimal job
  2. 02 The model that never rode the queue
  3. 03 Beyond the minimal job
  4. 04 Where jobs go when they die
  5. 05 Queue the listener
  6. 06 The limits of patience
  7. 07 The door before the job
  8. 08 What a group costs
  9. 09 Workers in production