>Dudenkoff_
← Lab / Series · 8 parts

Open the box

Eloquent from the inside

Relations, the N+1, the query builder, the magic methods. Naive versions built and broken, then matched against the framework source, verb for verb.


01
July 18, 2026

One name, two things

$order->customer and $order->customer() come from one line on the model and behave nothing alike: the method hands back an open query and fires nothing, the property runs it once and caches. Build the naive version where a relation is a plain property and a single find becomes five queries, while the real thing stays one. Laziness is the default, eagerness a lever. Counted straight from the query log.

02
July 19, 2026

The list before the question

with('customer') folds an N+1 loop back to two queries and asks for Alice once. The obvious fix, a shared cache, prints the same numbers on three orders and dies at a hundred: 102 queries, because a cache answers one miss at a time. The real move collects every customer_id before the first question to customers exists, asks once with the list, and stitches the answers back through a dictionary. Built by hand, then found in the vendor, verb for verb.

03
July 20, 2026

Who picks up the phone

Order::where() runs while method_exists says false, and the last episode left the question of who answers. A shared static builder dies of dirty state, a hand-rolled __callStatic almost passes, and then the vendor chain opens: a fresh instance per static call, __call, and a fresh Eloquent Builder from newQuery. The scars: a BadMethodCallException relayed and rewritten until it names the class you actually called, and query(), the honest door beside the hatch.

04
July 21, 2026

The ten that vanished

Two tinker sessions race the same order: the read-modify-write update loses ten dollars on screen, a classic lost update. Why Eloquent's increment survives the race condition: an atomic SQL formula built in incrementEach, and why the model never forwards the call to the builder. The proof is one byte: a formula without a key updates all 103 orders at once.

05
July 22, 2026

The copy you never asked for

Assign 999, ask getDirty, and the model answers without touching the database: every Eloquent model carries a second copy of its row. Dirty tracking from the inside: where $original is born, why save() on a clean model sends zero queries, why a naive touched-flag resave costs 103 pointless UPDATEs, and the strcmp scar that makes "160.0" dirty while "160" is not.

06
July 23, 2026

The date that is not a cast

class_basename says Carbon, the copies from the last episode hold a plain string, and getCasts() does not even list created_at. Laravel's cast and accessor machine from the inside: the three doors of transformModelValue, the getDates gift that comes with timestamps, why every access builds a fresh Carbon (no cache, unlike relations), and the trap where ->addDay() on the way out changes nothing.

07
July 24, 2026

The id you never asked for

new Order, exists false, one save: the INSERT ships with returning "id" and the model learns its own name in the same round trip, no second query. How a row is born: performInsert, both timestamps stamped for free, insertGetId and the Postgres RETURNING clause, Builder::create as new-fill-save in one line, and the mass assignment shield that drops unknown keys in silence.

08
July 25, 2026

The row that would not stay dead

delete() sends one keyed DELETE, flips exists back to false, and leaves a ghost in your hand: the object keeps every attribute after the row is gone, id included. Then the ghost saves itself back, and the INSERT ships the old id as an explicit column, in the exact order the copy holds. Why the model never delegates death, the silent return where increment once hit all 103 rows, and a resurrection that is not a soft delete.