>Dudenkoff_
← Lab / Laravel · Eloquent

The id you never asked for

Laravel 13 · PHP 8.5 · Postgres · July 24, 2026 · part 7 of 8

The last episode closed on a model with no row behind it, exists answering false, and a fence I had never crossed. Every write this series has watched touched rows that already existed; the save path in particular never left one condition, if ($this->exists) , its update side. Today I walk to the other side of that fence. Same bench as the last six episodes, the same 101 customers and 103 orders, order 1 at 160 where I left it, and a fresh model reading false:

tinker
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
    101,
    103,
  ]
> App\Models\Order::find(1)->total;
= 160
> (new App\Models\Order)->exists;
= false

I can write the INSERT myself. The part I want is smaller. When a row is brand new, where does it learn its own id? The database owns that number and hands it out by auto-increment; the model cannot guess it in advance. So here is my bet before I touch anything. After the insert lands, I will have to go back and ask the database for the id with a second query. The ids in this world run 1 through 103 with no gap; the copy episode's sweep looped through every one of them. The next value the sequence has waiting is 104.

A row from nothing#

Start a row from nothing. A new Order , still false, then hand it a customer and a total, turn the query log on, and save:

tinker
> $order = new App\Models\Order;
= App\Models\Order {#7430}
> $order->exists;
= false
> $order->customer_id = 1;
= 1
> $order->total = 25;
= 25
> DB::enableQueryLog();
= null
> $order->save();
= true
> array_column(DB::getQueryLog(), 'query');
= [
    "insert into \"orders\" (\"customer_id\", \"total\", \"updated_at\", \"created_at\") values (?, ?, ?, ?) returning \"id\"",
  ]

One query. Not two. I braced to reach back for the id and there is no room for a second round trip, the log holds a single line. The bet is dead. And the tail of that one line is a phrase I never typed, returning "id" . Something appended it to my INSERT. The column list is odd too. I set customer_id and total , yet the row also carries updated_at and created_at , two columns I never named. Read the bindings and both stamps are there:

tinker
> DB::getQueryLog()[0]['bindings'];
= [
    1,
    25,
    "2026-07-24 06:42:34",
    "2026-07-24 06:42:34",
  ]

Four bindings for four columns. My two, 1 and 25 , then two timestamps I never set, and they are the same instant down to the second. Who stamped them, and why both, is a question for the autopsy. So is the order they arrive in, updated_at ahead of created_at , which reads backwards. Now for the id I bet I would have to chase. It is already on the model, sitting at the value the sequence had waiting, and beside it a second flag with a plain name, wasRecentlyCreated , true on a thing that a breath ago had no row at all:

tinker
> $order->id;
= 104
> $order->exists;
= true
> $order->wasRecentlyCreated;
= true

104, with no second query anywhere. exists has flipped to true, the model has a row behind it now, and wasRecentlyCreated marks it as born this session. One save handed back three things I never asked for, both timestamps, the id, and the flip to real. How, is still sealed.

The one line version#

There is a shorter way to do all of that, and it is the shape episode three chased, Order::create , a static that exists as a method nowhere. Confirm the miss, then run it:

tinker
> method_exists(App\Models\Order::class, 'create');
= false
> $second = App\Models\Order::create(['customer_id' => 1, 'total' => 30]);
= App\Models\Order {#7419
    customer_id: 1,
    total: 30,
    updated_at: "2026-07-24 06:42:36",
    created_at: "2026-07-24 06:42:36",
    id: 105,
  }
> $second->wasRecentlyCreated;
= true

false , and the call works anyway. The hatch from episode three catches the static miss and forwards it, and this time the thing it forwards to owns a real create . Here is all of it (Builder.php):

vendor · Illuminate/Database/Eloquent/Builder.php
public function create(array $attributes = [])
{
    return tap($this->newModelInstance($attributes), function ($instance) {
        $instance->save();
    });
}

Three moves in one wrapper. newModelInstance is a new model with my array poured into it by fill (Builder.php, into newInstance), then the same save I ran by hand, then tap hands the model back. New order 105, a second newborn, and wasRecentlyCreated true again. One thing in that dump to hold onto. The id sits last, under the two timestamps, not up top where an id usually rides. It was dropped in after the row was written. Who dropped it there is the autopsy.

Open the box#

Everything above hangs on the else the last two episodes never crossed. Start there.

The other side of the fence#

The save fragment those episodes printed was fenced by if ($this->exists) , the update path. This is the branch on the far side of it (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
else {
    $saved = $this->performInsert($query);

    if (! $this->getConnectionName() &&
        $connection = $query->getConnection()) {
        $this->setConnection($connection->getName());
    }
}

One line does the work, performInsert . The setConnection tail below it is housekeeping for a model with no connection name yet, parked.

What performInsert runs#

This is the center of this episode (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
protected function performInsert(Builder $query)
{
    if ($this->usesUniqueIds()) {
        $this->setUniqueIds();
    }

    if ($this->fireModelEvent('creating') === false) {
        return false;
    }

    // First we'll need to create a fresh query instance and touch the creation and
    // update timestamps on this model, which are maintained by us for developer
    // convenience. After, we will just continue saving these model instances.
    if ($this->usesTimestamps()) {
        $this->updateTimestamps();
    }

    // If the model has an incrementing key, we can use the "insertGetId" method on
    // the query builder, which will give us back the final inserted ID for this
    // table from the database. Not all tables have to be incrementing though.
    $attributes = $this->getAttributesForInsert();

    if ($this->getIncrementing()) {
        $this->insertAndSetId($query, $attributes);
    }

    // If the table isn't incrementing we'll simply insert these attributes as they
    // are. These attribute arrays must contain an "id" column previously placed
    // there by the developer as the manually determined key for these models.
    else {
        if (empty($attributes)) {
            return true;
        }

        $query->insert($attributes);
    }

    // We will go ahead and set the exists property to true, so that it is set when
    // the created event is fired, just in case the developer tries to update it
    // during the event. This will allow them to do so and run an update here.
    $this->exists = true;

    $this->wasRecentlyCreated = true;

    $this->fireModelEvent('created', false);

    return true;
}

Read it top to bottom. usesUniqueIds is for models keyed on a UUID, not ours, parked. Then a creating event that can veto it by returning false, the birth-side echo of the updating veto from the copy episode. Then the timestamps, the answer to my two mystery columns. updateTimestamps in full (HasTimestamps.php):

vendor · Illuminate/Database/Eloquent/Concerns/HasTimestamps.php
public function updateTimestamps()
{
    $time = $this->freshTimestamp();

    $updatedAtColumn = $this->getUpdatedAtColumn();

    if (! is_null($updatedAtColumn) && ! $this->isDirty($updatedAtColumn)) {
        $this->setUpdatedAt($time);
    }

    $createdAtColumn = $this->getCreatedAtColumn();

    if (! $this->exists && ! is_null($createdAtColumn) && ! $this->isDirty($createdAtColumn)) {
        $this->setCreatedAt($time);
    }

    return $this;
}

The copy episode ran this same body, but there exists was true, so the created_at guard, ! $this->exists , was false and that whole branch slept. Only updated_at got a stamp. Today exists is false, both guards open, and both columns get set from one $time , which is why the two stamps in the bindings matched to the second. And setUpdatedAt runs before setCreatedAt , so updated_at reaches the attributes array first. There is the backwards column order from the log, no magic in it, just the order these two lines run.

Back in performInsert , getAttributesForInsert hands over the model's attribute array (HasAttributes.php, one line returning them all), our key increments, so it takes the insertAndSetId road, and the manual-key else for developer-set primary keys stays parked. The tail is the three gifts, spelled out in order, exists = true , wasRecentlyCreated = true , and the created event.

Who writes the id last#

Here is the pair of lines that dropped the id in last (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
protected function insertAndSetId(Builder $query, $attributes)
{
    $id = $query->insertGetId($attributes, $keyName = $this->getKeyName());

    $this->setAttribute($keyName, $id);
}

A bare insert() would have returned true and stopped, no id in hand. Eloquent calls insertGetId instead (the query builder), the variant that carries the id back, and then setAttribute writes it onto the model. That write happens after every other attribute is in place, which is why the id landed last in the create dump. One riddle is left. Who appended returning "id" to my INSERT.

Where returning comes from#

The tail is dialect. Postgres compiles its insert-get-id like this (PostgresGrammar.php):

vendor · Illuminate/Database/Query/Grammars/PostgresGrammar.php
public function compileInsertGetId(Builder $query, $values, $sequence)
{
    return $this->compileInsert($query, $values).' returning '.$this->wrap($sequence ?: 'id');
}

It takes the ordinary insert and glues on returning plus the wrapped key name, falling back to 'id' when no sequence is named. The RETURNING clause is a Postgres thing. The base grammar's compileInsertGetId (Grammar.php) returns the plain insert and appends nothing. From there the Postgres processor reads the id straight out of the row that query returned. Run the same code on MySQL and the shape changes (MySqlProcessor.php), it runs the insert, then asks the driver for lastInsertId in a separate call after the fact. Postgres asks for the answer inside the insert itself, so the id ships home in the same round trip. That is why my bet found no second query to make.

The whole birth is one packet. The model stamps both times, the database mints the name and returns it in the same statement, setAttribute writes the id on last, and exists flips to true. And Order::create is that entire procedure folded behind one line, a new model, fill , and the very same save .

The silent drop#

One thing create does before save that I skipped past, that fill of my array, has a scar in it. Back in episode one the Order listing carried one line above the class, #[Fillable(['customer_id', 'total'])] . Fill a fresh draft with those two keys and a third the model has never heard of:

tinker
> $draft = new App\Models\Order;
= App\Models\Order {#7420}
> $draft->fill(['customer_id' => 1, 'total' => 40, 'flibber' => 9]);
= App\Models\Order {#7420
    customer_id: 1,
    total: 40,
  }
> $draft->getAttributes();
= [
    "customer_id" => 1,
    "total" => 40,
  ]

I handed fill three keys and two came through. flibber is gone, and getAttributes confirms it never even reached the array. fill first runs the whole array through fillableFromArray (GuardsAttributes.php), one array_intersect_key($attributes, array_flip($this->getFillable())) , so a key not on the fillable list falls out before anything else runs. This is the mass assignment shield, and it ties back to the autopsy. Since flibber never landed in attributes, it could never have reached getAttributesForInsert either, the INSERT is built only from the survivors. A shield you get for free, paid for in silence: the stray key vanishes with no error and no warning, exactly the sort of key a form or a JSON body drags in. Laravel makes the drop loud if you ask, through preventSilentlyDiscardingAttributes (Model.php), off by default. I never save this draft, so flibber dies where it fell.

The next episode#

Two rows were born on camera this episode, 104 and 105, and the shop runs to 105 now. The world has actually grown, for the first time since the flagship, after a run of episodes that changed rows but never added one. Birth is done. One last frame before I close, two method checks side by side:

tinker
> [method_exists(App\Models\Order::class, 'create'), method_exists(App\Models\Order::class, 'delete')];
= [
    false,
    true,
  ]

create is false, a fake caught by the hatch and forwarded to the builder, which is where the birth lived. delete is true, a real method sitting on the model itself. Birth got handed off to the builder. Death the model keeps in its own hands. What is it about removing a row that Eloquent will not delegate? The row that would not stay dead is the next episode.


Next
The row that would not stay dead
The series
  1. 01 One name, two things
  2. 02 The list before the question
  3. 03 Who picks up the phone
  4. 04 The ten that vanished
  5. 05 The copy you never asked for
  6. 06 The date that is not a cast
  7. 07 The id you never asked for
  8. 08 The row that would not stay dead