The ten that vanished
The last episode closed on one name. The faked statics all answered method_exists
with false
; increment
answered true
. It is real, and __call
catches its whole family on the first line, before the builder it forwards everything else to. This one call the model keeps. What is it about adding one to a column that it will not pass along? Before I open the body, my bet: I do not need the method. I can already add. Ten, this time, to keep the arithmetic visible.
Same bench as the last two episodes, the same 101 customers and 103 orders, order 1 still Alice's and still at 100:
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
101,
103,
]
And the fact that opened the question, the same output that closed the last episode:
> method_exists(App\Models\Order::class, 'increment');
= true
Read, add, save#
Turn the query log on once for the whole session. Read Alice's order, add ten in PHP, save it back. The read and the property sit on one line, so the session echoes only the last value, the 100
(the multi-statement trick from the last episode swallows the model dump):
> DB::enableQueryLog();
= null
> $order = App\Models\Order::find(1); $order->total;
= 100
> $order->total = $order->total + 10;
= 110
> $order->save();
= true
> App\Models\Order::find(1)->total;
= 110
It stuck: the final find
reads 110
back. Two lines, no special method. Now the shape of what went to the database:
> array_column(DB::getQueryLog(), 'query');
= [
"select * from \"orders\" where \"orders\".\"id\" = ? limit 1",
"update \"orders\" set \"total\" = ?, \"updated_at\" = ? where \"id\" = ?",
"select * from \"orders\" where \"orders\".\"id\" = ? limit 1",
]
> DB::getQueryLog()[1]['bindings'];
= [
110,
"2026-07-21 09:42:12",
1,
]
A select, an update, a select. The update carries a value, 110
, computed in PHP and sitting in the bindings beside the timestamp and the id. The database got a finished number. One thing to notice: the SET clause names only total
and updated_at
. Hold that; it returns at the very end.
The real increment#
Now the method I said I did not need. Flush the log, call it, look:
> DB::flushQueryLog();
= null
> $order->increment('total', 10);
= 1
> array_column(DB::getQueryLog(), 'query');
= [
"update \"orders\" set \"total\" = \"total\" + 10, \"updated_at\" = ? where \"id\" = ?",
]
> App\Models\Order::find(1)->total;
= 120
Same number, 120
. Different shape. The = 1
is the count of rows the call touched. No select this time, and the SET clause carries no value. It carries a formula, "total" = "total" + 10
: add ten to whatever is there. The ten is written into the text of the query as a literal, not a placeholder, while its neighbor updated_at
stays a placeholder. Two roads, one number. Why would Laravel reach for a formula when plain read, add, save already did the job with no special method?
A second session#
The same number, as long as I am the only one holding the order. Production is not one person. Two HTTP requests land at once, two processes, each reading the order and writing it back. I open a second tinker session for the second request; at home, a second tab. The two terminals are A
(the one from the top) and B
. Both read the order:
> $a = App\Models\Order::find(1); $a->total;
= 120
> $b = App\Models\Order::find(1); $b->total;
= 120
Both hold 120. Now each adds ten and saves:
> $a->total = $a->total + 10; $a->save();
= true
> $b->total = $b->total + 10; $b->save();
= true
A computed 130 and saved. B computed from its own copy, 120 plus ten, and saved too. Two writes, each adding ten to 120. I expect 140:
> App\Models\Order::find(1)->total;
= 130
The lost update#
130. Two added ten, and only one addition survived. B read 120 before A's write existed, added ten to that stale copy, and its save()
carried the absolute number 130 out to the row, painting over A's write. No error, no warning. The tally is just short by ten. This is a race condition, and the specific shape is a lost update: two readers of one value, one write wins, the other vanishes without a mark.
The same race, real increment#
The formula does not carry a value it read, so it should survive the same test. Same choreography, increment
in place of read-add-save. Both read the current 130:
> $a2 = App\Models\Order::find(1); $a2->total;
= 130
> $b2 = App\Models\Order::find(1); $b2->total;
= 130
Both call the method:
> $a2->increment('total', 10);
= 1
> $b2->increment('total', 10);
= 1
This time I expect 150:
> App\Models\Order::find(1)->total;
= 150
150. Both tens landed. Each UPDATE says add ten to whatever is there now, and the add happens in the database, where the copy is single. The two UPDATEs run in turn, so nothing gets painted over. That is an atomic increment: the read and the add are one statement the database owns, not two steps split across a process. Now the method has a reason to exist. Two questions are left: how it is built, and why it lives on the model and not the builder.
Open the box#
The hatch from the last episode was __call
, and its first line tests the method name against increment
and its family, then returns $this->increment(...)
, sending the call home to the model (Model.php, the body quoted last time). Why a whole branch for it? Because the model's increment
is protected. From outside PHP will not let you reach it, so the miss lands in __call
, which invokes it as an instance method, allowed from inside:
> (new ReflectionMethod(App\Models\Order::class, 'increment'))->isProtected();
= true
increment
itself is a one-line delegate into incrementOrDecrement
(Model.php). That method is the center (Model.php):
protected function incrementOrDecrement($column, $amount, $extra, $method)
{
if (! $this->exists) {
return $this->newQueryWithoutRelationships()->{$method}($column, $amount, $extra);
}
$this->{$column} = $this->isClassDeviable($column)
? $this->deviateClassCastableAttribute($method, $column, $amount)
: $this->{$column} + ($method === 'increment' ? $amount : $amount * -1);
$this->forceFill($extra);
if ($this->fireModelEvent('updating') === false) {
return false;
}
if ($this->isClassDeviable($column)) {
$amount = (clone $this)->setAttribute($column, $amount)->getAttributeFromArray($column);
}
return tap($this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), function () use ($column) {
$this->syncChanges();
$this->fireModelEvent('updated', false);
$this->syncOriginalAttribute($column);
});
}
Trace our values through it, with $column
as 'total'
, $amount
as 10
, and $extra
an empty []
. The first branch, if (! $this->exists)
, is for a model with no row in the database; park it, we return to it in the scars. Our order exists, so the assignment runs. The class-cast half of the ternary is a detour for columns wrapped in cast objects (twice, here and lower down); our plain integer total
takes the else
, $this->total + 10
, adding to its own in-memory copy, and the same body runs decrement
with the sign flipped. forceFill($extra)
fills nothing. The updating
event can return false
and cancel the operation before it starts. Then the working line, wrapped in tap
: setKeysForSaveQuery
glues on the where "id" = ?
from the query, newQueryWithoutScopes
is the fresh-query kin from the last episode, and ->{$method}(...)
calls increment('total', 10, [])
on an Eloquent Builder
. The closure tidies up after: sync changes, fire updated
, sync the original attribute. That last line is a loaded gun for one of the scars.
One level down, on the Eloquent builder (Builder.php):
public function increment($column, $amount = 1, array $extra = [])
{
return $this->toBase()->increment(
$column, $amount, $this->addUpdatedAtColumn($extra)
);
}
Two moves in one line. addUpdatedAtColumn
slips updated_at
into $extra
, which is why "updated_at" = ?
showed up as a placeholder while the add did not, and toBase()
drops from the model's builder to the plain query builder (Builder.php):
public function increment($column, $amount = 1, array $extra = [])
{
if (! is_numeric($amount)) {
throw new InvalidArgumentException('Non-numeric value passed to increment method.');
}
return $this->incrementEach([$column => $amount], $extra);
}
A guard, is_numeric($amount)
, then a hand-off to incrementEach(['total' => 10])
. Keep the guard in view. The kitchen is one more step down (Builder.php):
public function incrementEach(array $columns, array $extra = [])
{
foreach ($columns as $column => $amount) {
if (! is_numeric($amount)) {
throw new InvalidArgumentException("Non-numeric value passed as increment amount for column: '$column'.");
} elseif (! is_string($column)) {
throw new InvalidArgumentException('Non-associative array passed to incrementEach method.');
}
$columns[$column] = $this->raw("{$this->grammar->wrap($column)} + $amount");
}
return $this->update(array_merge($columns, $extra));
}
Per column, the two guards run again, then $this->raw("{$this->grammar->wrap($column)} + $amount")
builds the string "total" + 10
and stitches it into the UPDATE as a raw expression. There is the placeholder puzzle answered: the ten is a literal because it is a piece of SQL text, and only a value that already passed is_numeric
is allowed to become SQL text, the same reflex as the raw integer list two episodes back. The formula is real. But the plain query builder's increment
is public; the builder can do all of this alone. So why does the model take the call back? The answer is in the branch I parked first.
A formula with no key#
Unpark if (! $this->exists)
. A model with no row in the database sends the formula through newQueryWithoutRelationships
, with no setKeysForSaveQuery
, which means no WHERE. Watch a fresh, unsaved order call increment
:
> (new App\Models\Order)->increment('total', 10);
= 103
> App\Models\Order::find(1)->total;
= 160
103. That is not one row. That is every order in the shop, the 103 from the world at the top. The formula, carrying no key, added ten to all of them, and order 1 rode from 150 to 160 as collateral. The same body in reverse puts it back:
> (new App\Models\Order)->decrement('total', 10);
= 103
> App\Models\Order::find(1)->total;
= 150
And there is the answer to the episode. If __call
had forwarded increment
the way it forwards where
, into a fresh newQuery
, the builder would have sent the formula happily. But a fresh query holds no id, and a formula with no key is exactly that last frame, ten to the whole shop. That is what is special about adding ten: the formula does not know whose it is. The model keeps the call so it can glue on its own key with setKeysForSaveQuery
before the formula reaches the builder, then fire its events and mend its copy. The key is the reason; the rest is a bonus.
The copy in memory#
The bonus limps. That assignment line added ten to $this->total
in memory rather than re-reading the row. After the survival scene, B's copy $b2
was never re-read:
> $b2->total;
= 140
> App\Models\Order::find(1)->total;
= 150
> $b2->isDirty();
= false
140 in the copy, 150 in the row. B added ten to its own stale 130 and never saw A's ten. The scar is honest: increment
wins the fight for the database, but the in-memory copy stays a local guess (a fresh instance would not lag, and the method never promised a two-way sync). isDirty()
answers false
because the parked last line of the tap
closure, syncOriginalAttribute
, already told the model it was clean. The quiet and each-column variants ride in through the same __call
branch.
The next episode#
One thing from the first scene stayed open. The naive save()
shipped set "total" = ?, "updated_at" = ?
and nothing else. Nobody told it which columns I had touched. Read the order fresh, change one field, and ask what is dirty (no save
this time, so the world stays at 150):
> $order = App\Models\Order::find(1); $order->total = $order->total + 10; $order->getDirty();
= [
"total" => 160,
]
The model knows total
changed, and only total
, though it never re-read the columns to compare. How does it know? The copy you never asked for is the next episode.
- 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