With Eloquent, in what order do a model's lifecycle events fire on insert, update and delete, and how can a handler cancel the write?
answer
- -ing before the query, -ed after
- saving wraps creating or updating
- clean save skips updating/updated
- SoftDeletes adds trashed
- return false; save() returns false
basics
~20 sInsert fires saving, creating, created, saved; update fires saving, updating, updated, saved; delete fires deleting then deleted. Returning false from a before-event such as saving or deleting cancels the write, and save() or delete() returns false.
solid answer
~40 sOn `save()` of a new model Eloquent fires `saving`, then `creating`, runs the INSERT, then `created` and `saved`. For an existing model it fires `saving`, `updating`, runs the UPDATE, then `updated` and `saved`, but `updating`/`updated` fire only when the model is dirty, while `saving`/`saved` fire even when nothing changed. `delete()` fires `deleting` and `deleted`; with `SoftDeletes` a `trashed` event fires between them, and `restore()` wraps an ordinary save in `restoring`/`restored`. `retrieved` fires for every model hydrated from a query. If a handler for `saving`, `creating`, `updating`, `deleting`, `restoring` or `forceDeleting` returns `false`, no SQL runs and the method returns `false` instead of throwing, so callers that can be vetoed must check the return value.
code
php · 21 lines<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Listing extends Model
{
protected static function booted(): void
{
// Before the INSERT or UPDATE: derive values, or veto.
static::saving(function (Listing $listing) {
if ($listing->asking_price <= 0) {
return false; // no query runs; save() returns false
}
$listing->slug = Str::slug($listing->street_address);
});
}
}go deeper
Recall the rule: -ing events fire before the SQL, -ed events after, and saving/saved wrap both creates and updates.
Explain the dirty check that skips updating and updated, the trashed event under SoftDeletes, and that a false return makes save() return false instead of throwing.
Show where you put derived fields versus side effects, and how you stop a silent veto from passing unnoticed in controllers and jobs that ignore save()'s return.
Weigh the cost of hiding business rules in model hooks against keeping writes explicit in services, especially when several teams touch the same model.
## What an Eloquent model event is An **Eloquent model event** is a named moment in a model instance's life that Laravel announces through the event dispatcher. Code can listen to it with a closure registered in the model's `booted()` method, with an **observer** class, or by mapping it to an event class in `$dispatchesEvents`. The events belong to **model instances**: they fire because a method on a `Model` object, such as `save()`, `delete()` or `restore()`, ran, or because a row was hydrated into a model. The standard list in Laravel 13 is `retrieved`, `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `trashed`, `forceDeleting`, `forceDeleted`, `restoring`, `restored` and `replicating`. The naming rule carries most of the meaning: - an **`-ing`** event fires **before** the SQL statement runs, while the change is still only in memory; - an **`-ed`** event fires **after** the statement has run. ## Insert and update order `save()` decides between an INSERT and an UPDATE by looking at the model's `exists` flag. The sequence for a new model is: 1. `saving` 2. `creating` 3. the INSERT runs and the new key is set on the model 4. `created` 5. `saved` For a model that already exists: 1. `saving` 2. `updating` (only if the model is **dirty**, meaning at least one attribute differs from what was loaded) 3. `updated_at` is refreshed, then the UPDATE runs 4. `updated` 5. `saved` The dirty check is the part people forget. If nothing changed, `save()` skips the UPDATE and the `updating`/`updated` pair entirely, but it **still fires `saving` and `saved`** and returns `true`. A handler on `saved` therefore runs on every save call, including no-op ones; a handler on `updated` runs only when a column actually changed. | Call | Events, in order | |---|---| | `save()` on a new model | `saving`, `creating`, `created`, `saved` | | `save()` on a dirty existing model | `saving`, `updating`, `updated`, `saved` | | `save()` on a clean existing model | `saving`, `saved` | | `$model->increment('views')` | `updating`, `updated` (no `saving`/`saved`) | ## Delete, soft delete and restore - `delete()` fires `deleting`, runs the DELETE, then fires `deleted`. - With the `SoftDeletes` trait, `delete()` runs an UPDATE of `deleted_at` instead, and an extra **`trashed`** event fires between `deleting` and `deleted`. - `forceDelete()` wraps the normal delete: `forceDeleting`, `deleting`, `deleted`, `forceDeleted`. - `restore()` fires `restoring`, clears `deleted_at` through a normal `save()` (so `saving`, `updating`, `updated`, `saved` fire inside it), then fires `restored`. ## retrieved and replicating - **`retrieved`** fires once for each model hydrated from a query result, including every model a `get()` returns. Heavy work in a `retrieved` handler multiplies by the row count. - **`replicating`** fires when `replicate()` builds an unsaved copy, before you save it. ## Cancelling a write from an -ing handler The before-events `saving`, `creating`, `updating`, `deleting`, `restoring` and `forceDeleting` are dispatched in **halting** mode. If a handler returns exactly `false`: - the operation stops before its SQL runs; - the later events of that sequence do not fire; - `save()`, `delete()` or `restore()` returns **`false`**; no exception is thrown. That last point is the trap. Code that calls `$listing->save()` and ignores the return value believes the write happened. `replicating` and the `-ed` events are not dispatched in halting mode, so returning `false` from them cancels nothing. In halting mode any non-null return also stops later handlers for that same event, so handlers that do not mean to cancel should return nothing. ## A worked example: one listing's life Follow a single `Listing` through a week in a property app, with an observer that implements every method: 1. An agent creates the listing: `saving`, `creating`, INSERT, `created`, `saved`. 2. The agent reloads the edit page: `retrieved` fires as `findOrFail()` hydrates it. 3. The agent clicks save without changing anything: `saving`, `saved`, and no SQL at all. 4. The price drops: `saving`, `updating`, UPDATE, `updated`, `saved`. 5. The listing is withdrawn with a soft delete: `deleting`, UPDATE of `deleted_at`, `trashed`, `deleted`. 6. It is restored the next day: `restoring`, then `saving`, `updating`, UPDATE, `updated`, `saved`, then `restored`. Two consequences stand out. An observer that reindexes on `saved` does work at step 3 even though nothing changed; one that reindexes on `updated` does not, but it runs again during the restore at step 6. Which event you hook decides how often the side effect runs, so pick it from this sequence rather than from the event's name alone. ## Practical guidance - Put **derived values** (a slug, a normalised postcode) in `saving` or `creating`, where changing an attribute still reaches the same INSERT or UPDATE. - Put **side effects** (reindexing, audit rows, notifications) in `created`, `updated`, `saved` or `deleted`, after the row exists. - Treat a `false` return as a validation-style veto and check `save()`'s result where a veto is possible; `saveOrFail()` adds a transaction but still reports a veto as `false` rather than throwing.
- Why does a handler on saved run when a form is resubmitted with no changes, while a handler on updated does not?`save()` always fires `saving` and `saved`. It calls the update path, which fires `updating` and `updated`, only when `isDirty()` reports a changed attribute. A resubmitted identical form leaves the model clean, so the UPDATE and its two events are skipped, but `save()` still returns `true` and `saved` still fires. Side effects that should follow real changes belong on `updated`, or should check `wasChanged()`.
- Which events fire when a soft-deleted Eloquent model is restored?`restore()` fires `restoring` first; returning `false` there cancels it. It then sets `deleted_at` to null and calls `save()`, so `saving`, `updating`, `updated` and `saved` fire as for any dirty update. Finally, if the save succeeded, `restored` fires. An observer that reindexes on `updated` will therefore also run on every restore.
saying these in an interview costs you the question
- saved only fires when an attribute actually changed
- A false return from saving throws an exception the caller can catch
- Soft delete fires updating and updated because it runs an UPDATE
- Returning false from created or saved rolls the insert back
- retrieved fires once per query rather than once per model