The row that would not stay dead
The last episode watched two rows get born on this bench and closed on a pair of method checks. create
came back false, a fake the builder catches and runs. delete
came back true, a real method sitting on the model itself. Birth got handed to the builder; death the model keeps in its own hands. So what is it about removing a row that Eloquent will not let go of? Same bench as the last seven episodes, the shop now at 105:
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
101,
105,
]
> App\Models\Order::find(1)->total;
= 160
101 customers and 105 orders, order 1 at 160 where I left it. And the two checks that ended the last episode, printed once more so the fence stays in plain sight:
> [method_exists(App\Models\Order::class, 'create'), method_exists(App\Models\Order::class, 'delete')];
= [
false,
true,
]
Two earlier episodes settled this bet, both times on the key. Whatever runs here turns on it too. A delete with no "whose" behind it would take the table down. Let it run, then open the box.
Death on camera#
The victim is order 105, born on this bench in the last episode, the second newborn of that session. Read it back:
> $victim = App\Models\Order::find(105);
= App\Models\Order {#8545
id: 105,
customer_id: 1,
total: 30,
created_at: "2026-07-25 05:17:40",
updated_at: "2026-07-25 05:17:40",
}
Five fields, the total of 30 it was born with, and the id rides first. Worth one note against the birth episode. There the create dump put the id last, dropped in by setAttribute
after the insert. This copy came off find
, so hydration laid the columns in table order and the id led. Turn the query log on and delete it:
> DB::enableQueryLog();
= null
> $victim->delete();
= true
> array_column(DB::getQueryLog(), 'query');
= [
"delete from \"orders\" where \"id\" = ?",
]
> DB::getQueryLog()[0]['bindings'];
= [
105,
]
One statement. A DELETE with a WHERE on the key. Who wrote that WHERE? Not me. The binding is 105, the victim's own id. So the row is gone. find
confirms it, and the count drops:
> App\Models\Order::find(105);
= null
> App\Models\Order::count();
= 104
The shop is down to 104. The bet holds so far, one keyed delete, nobody's but 105's. The row born on camera in the last episode died on camera in this one.
The ghost in my hand#
I killed the row. But the object in my hand, the $victim
I called delete
on, what state is it in now? The copy episode taught the lesson: the model carries its own copies, and the database never reaches them. So my bet is the attributes are still there, sitting on an object whose row is gone. Ask it three things:
> $victim->exists;
= false
> $victim->total;
= 30
> $victim->id;
= 105
exists
flipped back to false, the mirror of the birth flip to true. The total still reads 30. And the id still reads 105, on an object with no row. Delete erased the row, not the object. In my hand, a full copy of a thing that no longer exists, id and all. Hold that id. Open the box.
Open the box#
Death is a real method on the model, so read it top to bottom (Model.php):
public function delete()
{
$this->mergeAttributesFromCachedCasts();
if (is_null($this->getKeyName())) {
throw new LogicException('No primary key defined on model.');
}
// If the model doesn't exist, there is nothing to delete so we'll just return
// immediately and not do anything else. Otherwise, we will continue with a
// deletion process on the model, firing the proper events, and so forth.
if (! $this->exists) {
return;
}
if ($this->fireModelEvent('deleting') === false) {
return false;
}
// Here, we'll touch the owning models, verifying these timestamps get updated
// for the models. This will allow any caching to get broken on the parents
// by the timestamp. Then we will go ahead and delete the model instance.
$this->touchOwners();
$this->performDeleteOnModel();
// Once the model has been deleted, we will fire off the deleted event so that
// the developers may hook into post-delete operations. We will then return
// a boolean true as the delete is presumably successful on the database.
$this->fireModelEvent('deleted', false);
return true;
}
What delete runs#
Walk it. mergeAttributesFromCachedCasts
folds any cached cast objects back into the array (HasAttributes.php), the cast caches from the date episode; Order
caches nothing there, so it moves nothing. The no-primary-key guard throws a LogicException
, parked, our key is id
. Then the line that will matter later, if (! $this->exists) return;
, a bare return, no exception, nothing deleted. The deleting event can veto it by returning false, the death-side twin of the updating
and creating
vetoes the earlier episodes met. touchOwners
bumps parent timestamps to break parent caches (HasRelationships.php), parked. Then the one line that does the work, performDeleteOnModel
, followed by the deleted
event and return true
.
The two moves that do it#
Here is the work (Model.php):
protected function performDeleteOnModel()
{
$this->setKeysForSaveQuery($this->newModelQuery())->delete();
$this->exists = false;
}
Two moves. setKeysForSaveQuery
is the same key glue from the increment and copy episodes (Model.php), the one that pins a query to where id = 105
. There is the WHERE I never wrote. It hangs off a query from newModelQuery
(Model.php), a slightly different factory than the newQueryWithoutScopes
the increment episode traced, so I will not call it the same move end to end. The glue is what matches, and the glue is the key. The builder runs the DELETE, and the second move flips exists
to false. There is the false I read on the ghost.
So the shape closes. Birth in the last episode was an INSERT plus a flip of exists
to true. Death is a keyed DELETE plus a flip of exists
to false. On both ends the model does the same two things, glue the key and turn the flag, and hands the raw SQL to the builder. The builder can run a delete. Only the model knows whose death it is, and what to fix at home once the row is gone.
Two fates for one flag#
There is that if (! $this->exists) return;
again. Back in the increment episode a fresh, unsaved model ran a formula, and the number still holds:
> (new App\Models\Order)->increment('total', 10);
= 103
103, not one row but every order in the shop, because increment
carrying no key went out as a mass update. The same empty shape now calls delete:
> (new App\Models\Order)->delete();
= null
null. Nothing happened, and nothing could have reached the log: the guard sits lines before any query is built. Same exists
false on both, so why two different fates? increment
carries no key and turns into a table-wide write. delete
hits the explicit guard and returns, and the vendor comment on that line names the reason, there is nothing to delete
. I will not pretend upstream planned the increment blast; a keyless mass update has honest uses. On the delete path the missing key gets caught outright, whereas on the increment path it sails straight through. Death bolts the door increment left open.
A second $victim->delete()
returns null too, caught by the same guard. destroy
is the static form, delete by id; it pulls each model and calls delete
on it (Model.php). forceDelete
with no such trait present is just delete
under another name (Model.php), and deleteQuietly
runs it with the events muted (Model.php).
The row comes back#
The ghost holds every attribute, id included, and exists
reads false. So run the birth trace against it. save
on a model where exists
is false takes the insert branch, performInsert
, and getAttributesForInsert
, the one line I read in the birth episode, returns them all (HasAttributes.php) exactly as they lie. So here is the call before I make it. This is a resurrection. save
will fire an INSERT, it will carry an explicit id because 105 sits right there in the attributes, and the columns will follow the victim dump above, id in front. Flush the log and save:
> DB::flushQueryLog();
= null
> $victim->save();
= true
> array_column(DB::getQueryLog(), 'query');
= [
"insert into \"orders\" (\"id\", \"customer_id\", \"total\", \"created_at\", \"updated_at\") values (?, ?, ?, ?, ?) returning \"id\"",
]
There it is. An INSERT with id
as the first column, then customer_id
, total
, created_at
, updated_at
, the exact order of the dump up top. The id ships as a plain value, never asked of the sequence, because getAttributesForInsert
handed over the array as it lay, 105 in it. Read against the birth episode this is the mirror. There the id landed last, written by setAttribute
after the insert; here it leads, and the INSERT ships what it holds in the order it holds it. The row is back:
> App\Models\Order::find(105)->total;
= 30
> App\Models\Order::count();
= 105
105 again, total 30, the count back up. And it is worth naming what this is not. Not a soft delete restore. Nothing here flips a deleted_at
back to null; the row was truly gone, and a real INSERT put a new one under the old id. On the way through performInsert
, updateTimestamps
ran again, so the stamps that went in are fresh ones, not the ghost's old strings. And an INSERT that carries its own id has nothing to ask of the sequence; 105 came from my hand, not from the counter. A ghost can keep coming back this way for as long as you hold it. This bench decides otherwise:
> $victim->delete();
= true
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
101,
104,
]
Second delete, true this time because exists
is true again, and the world prints [101, 104]. Order 105's life is closed for good.
The next episode#
Count it up. 105 died here for good. But 104, also born in the last episode, is still standing, so across the two the shop is one row up from where it sat for so long. The life cycle is closed now, birth and death both opened. What is left is a debt I have been carrying the whole series:
> collect(glob(app_path('Naive/*.php')))->map(fn ($f) => basename($f, '.php'));
= Illuminate\Support\Collection {#8508
all: [
"EagerOrder",
"MagicOrder",
"MemoOrder",
"StaticOrder",
"TouchedOrder",
],
}
Five naive classes, one per problem the series chewed through, each one reaching past the model straight for DB::table
, some with a hand-rolled find
and its own hydration, some just a raw forward or a shared query stub. Five hand-built stand-ins for one Model
. The next episode lays the five on the table and cuts out the bone they share, a Model
built from nothing, checked rib for rib against the vendor's. You do not have to remember the names.
You've read the series to its edge. New parts land in the lab.
- 01 One name, two things
- 02 The list before the question
- 03 Who picks up the phone
- 04 The ten that vanished
- 05 The copy you never asked for
- 06 The date that is not a cast
- 07 The id you never asked for
- 08 The row that would not stay dead