DEV Community

Cover image for What Actually Happens When You Call `->save()` in Laravel Eloquent
Haseeb Mirza
Haseeb Mirza

Posted on

What Actually Happens When You Call `->save()` in Laravel Eloquent

Most Laravel developers call ->save() a hundred times a day.

Almost none of them know what happens after that line executes.

I was one of them — until I inherited a codebase where Eloquent was behaving in ways I could not explain. Models not updating. Events not firing. Timestamps wrong.

I had to open the Laravel source code and actually read it.

What I found surprised me.


The Entry Point — It Is Not Just "Save to Database"

When you call $model->save(), most developers assume Laravel just fires an SQL query.

It does not.

The first thing save() does is make a decision.

It checks one property on your model:

$this->exists
Enter fullscreen mode Exit fullscreen mode

This single boolean decides everything. If $exists is false, Laravel runs an INSERT. If it is true, Laravel runs an UPDATE.

That sounds simple. But it has real consequences that catch developers off guard.


How Laravel Knows INSERT vs UPDATE

The $exists property is set to true the moment a model comes from the database — whether you use find(), first(), get(), or any query that hydrates a model from existing records.

It is false when you create a new model instance manually:

$user = new User(); // $exists = false → will INSERT
$user = User::find(1); // $exists = true → will UPDATE
Enter fullscreen mode Exit fullscreen mode

Where this causes real problems:

When you clone a model.

$original = User::find(1);
$copy = clone $original;
$copy->email = 'new@example.com';
$copy->save(); // Updates the ORIGINAL record — not a new one
Enter fullscreen mode Exit fullscreen mode

The cloned model still has $exists = true and the original primary key. Laravel will update the original row, not create a new one.

The fix — reset it manually:

$copy = clone $original;
$copy->exists = false;
$copy->id = null;
$copy->save(); // Now it INSERTs correctly
Enter fullscreen mode Exit fullscreen mode

Knowing $exists exists saves you from this bug entirely.


Before Anything Touches the Database

Before Laravel runs any SQL, it fires model events.

This is the order:

  1. saving — fires on every save, insert or update
  2. creating — fires only on INSERT
  3. updating — fires only on UPDATE

If any listener on these events returns false, the save is cancelled completely. No query runs.

This is how validation inside Observers works — and also why sometimes your save() silently does nothing. An Observer somewhere returned false and you never knew.

Always check your Observers when a save mysteriously fails.

After events, Laravel does something most developers never think about:

Dirty checking.

Laravel does not update every column. It only updates columns that actually changed since the model was loaded. This is tracked through $dirty — an array of changed attributes.

$user = User::find(1);
$user->name = 'Haseeb'; // marks name as dirty
$user->save(); // only UPDATEs name — not every column
Enter fullscreen mode Exit fullscreen mode

This is a performance feature built into Eloquent. But it also means:

If you set a value to the same thing it already was — nothing updates. No query runs. This is called a no-op save and it is intentional.


The Actual Query

Once events pass and dirty attributes are collected, Laravel builds the SQL.

For an INSERT it uses performInsert(). For an UPDATE it uses performUpdate().

Both go through setKeysForSaveQuery() — a method that determines which column to use as the identifier for the WHERE clause on updates.

By default this is your primary key. But you can override it:

protected function setKeysForSaveQuery($query)
{
    $query->where('custom_column', $this->custom_column);
    return $query;
}
Enter fullscreen mode Exit fullscreen mode

This is useful when you have composite keys or non-standard primary keys — something default Eloquent does not handle well out of the box.

Timestamps are handled here too.

If your model uses timestamps — which is the default — updated_at is set automatically on every UPDATE. created_at and updated_at are both set on INSERT.

You do not touch them. Laravel handles them before the query runs.


After the Save

Once the query executes, Laravel fires the after-events:

  1. saved — fires on every save
  2. created — fires after INSERT
  3. updated — fires after UPDATE

One thing most developers never use — $wasRecentlyCreated:

$user->save();

if ($user->wasRecentlyCreated) {
    // This was a new record — send welcome email
}
Enter fullscreen mode Exit fullscreen mode

This flag is set to true only on INSERT in the current request. It resets on the next load from database.

Useful when you want to do something only on first creation — without adding extra logic to figure out whether you just inserted or updated.


Real World Implications

1. Never call save() inside a loop

// Bad — fires one query per iteration
foreach ($users as $user) {
    $user->status = 'active';
    $user->save();
}

// Good — one query for everything
User::whereIn('id', $userIds)->update(['status' => 'active']);
Enter fullscreen mode Exit fullscreen mode

Every save() inside a loop is a separate database round trip. On 100 records that is 100 queries. Use bulk update() instead.

2. Use save() when you need events. Use update() when you need speed.

save() fires all model events and goes through Eloquent's full lifecycle.

update() called directly on a query builder skips all of that — no events, no observers, no dirty checking.

Both are correct. Choose based on what you actually need.

3. Debugging weird Eloquent behaviour

When save() does nothing — check:

  • Is an Observer returning false?
  • Is the attribute actually dirty or same as before?
  • Is $exists set correctly?

These three questions explain 90% of mysterious Eloquent save issues.


Why This Matters Beyond Trivia

Understanding what happens inside ->save() is not about memorising Laravel internals.

It is about understanding the decisions your framework makes on your behalf — so you can work with them instead of against them.

The best Laravel developers I have worked with have all done this at least once. Not because they had to. Because they wanted to understand what they were actually building on.

You do not need to do this for every method.

But doing it for the ones you call every single day — like save() — changes how you debug, how you architect, and how you write code that does not surprise you later.


What Is Your ->save() Equivalent?

Every framework has methods we call without thinking.

What is one Laravel method you use daily but have never fully looked into?

Drop it in the comments — I will pick one and write about it next.


Haseeb Mirza is a Laravel backend engineer building SaaS platforms, AI-powered applications, and automation systems. Open to senior backend roles and consulting projects.

Connect on LinkedIn or find me on Upwork.

Top comments (0)