Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Model Events, Observers & Soft Deletes

Hooking into create/update/delete, observers, soft deletes and restoring records.

Every time Eloquent creates, updates or deletes a record, it fires model events you can hook into: to fill in defaults, enforce a rule, clear a cache or send a notification, without repeating that code in every controller. This lesson covers the events themselves, observers that group the handlers in one class, and soft deletes for records you want to be able to bring back.

Try it: watch the events fire

GET /api/demo/model-events creates a throwaway task, updates it and deletes it inside a database transaction that is always rolled back, recording every Eloquent event in the order it fires. Nothing is left in the database. Tick the box to ask for a completed_at three days in the future and watch this app's real observer pull it back to "now".

The events, in order

Each write runs a fixed sequence. The "-ing" events fire before the SQL runs, so a handler can change the model or cancel the write by returning false. The "-ed" events fire after, when the row exists and has an id.

create() update() delete() savingcreatingINSERTcreatedsaved savingupdatingUPDATEupdatedsaved deletingDELETEdeleted before the SQL: can change or cancel after the SQL: row exists
Event Fires Typical use
saving / saved On both create and update Normalise data, clear a cache
creating / created Only on insert Default values, a welcome notification
updating / updated Only on update, and only if something changed Audit trail, react to a status change
deleting / deleted On delete Clean up files or related records
restoring / restored Restoring a soft-deleted row Re-index, notify
Model events only fire when you go through a model instance. Mass queries such as Task::where(...)->update([...]) or ->delete() run one SQL statement and skip them entirely.

Quick handlers with closures

For one small rule, register a closure in the model's booted() method (illustrative — not in this app):

class Project extends Model
{
    protected static function booted(): void
    {
        static::creating(function (Project $project) {
            $project->slug ??= Str::slug($project->name);
        });
    }
}

Once a model has more than one or two of these, move them into an observer.

Observers: one class per model

An observer is a plain class with a method per event. Create one with Artisan:

php artisan make:observer TaskObserver --model=Task

This app has a real one. It enforces a simple rule: a task can't be completed in the future.

app/Observers/TaskObserver.php

class TaskObserver
{
    /**
     * Runs before every insert and update. A task can't be completed in the
     * future, so a completed_at later than now is pulled back to now.
     */
    public function saving(Task $task): void
    {
        if ($task->isDirty('completed_at') && $task->completed_at?->isFuture()) {
            $task->completed_at = now();
        }
    }
}

Attach it with the #[ObservedBy] attribute on the model, so the connection is visible where you'd look for it:

app/Models/Task.php

#[ObservedBy(TaskObserver::class)]
class Task extends Model
{

The alternative is Task::observe(TaskObserver::class) in a service provider's boot() method. Both work; the attribute keeps everything about the model in one file.

isDirty('completed_at') asks "is this attribute being changed in this save?". Its partners are wasChanged() in the "-ed" events and getOriginal('completed_at') for the old value.

Testing an observer

Observers are ordinary code, so test the behaviour, not the wiring. This is the real test:

tests/Feature/Demo/ModelEventsLocalizationDemoTest.php

public function test_observer_pulls_a_future_completed_at_back_to_now(): void
{
    $this->freezeSecond();
    $task = Task::factory()->create();

    $task->update(['completed_at' => now()->addWeek()]);

    $this->assertTrue($task->fresh()->completed_at->equalTo(now()));
}

When a test needs to create data without firing events (to keep a seeder fast, say), wrap it in Task::withoutEvents(fn () => ...) or use saveQuietly().

How the demo records events

The demo controller listens for every Eloquent event on Task with a wildcard, then runs the writes inside a transaction it always rolls back:

app/Http/Controllers/Api/ModelEventsDemoController.php

Event::listen('eloquent.*: '.Task::class, function (string $name, array $payload) use (&$events): void {
    $event = str($name)->after('eloquent.')->before(':')->value();
DB::beginTransaction();

try {
    $task = $project->tasks()->create(['title' => '  Model events demo  ', 'priority' => 'low']);
    // … update(), delete(), recording events after each step
} finally {
    DB::rollBack();
}

The response's title_saved_as is "Model events demo" without the spaces: that's the title mutator from the Eloquent lesson, which runs when the attribute is set, before any event fires.

Soft deletes

A soft delete sets a deleted_at timestamp instead of removing the row. The record disappears from normal queries but can be restored. This app doesn't soft-delete its demo data, so these examples are illustrative.

// Migration
$table->softDeletes();          // nullable deleted_at column

// Model
use Illuminate\Database\Eloquent\SoftDeletes;

class Project extends Model
{
    use SoftDeletes;
}
Code Does
$project->delete() Sets deleted_at; fires deleting / deleted
Project::all() Excludes soft-deleted rows automatically
Project::withTrashed()->get() Includes them
Project::onlyTrashed()->get() Only the deleted ones: a "bin" page
$project->restore() Clears deleted_at; fires restoring / restored
$project->forceDelete() Really deletes the row
$project->trashed() true if soft-deleted

Route model binding ignores trashed records too; add ->withTrashed() to the route when an admin page needs to show them.

Soft deletes don't cascade. If a project is soft-deleted, its tasks are still there and still visible unless they're soft-deleted as well, which is a good job for a deleted handler in the project's observer.

Pruning old records

Soft-deleted rows pile up. The Prunable trait deletes them on a schedule (illustrative):

use Illuminate\Database\Eloquent\Prunable;

class Project extends Model
{
    use Prunable, SoftDeletes;

    public function prunable(): Builder
    {
        return static::onlyTrashed()->where('deleted_at', '<', now()->subDays(30));
    }
}
// routes/console.php
Schedule::command('model:prune')->daily();

MassPrunable does the same with one DELETE query, which is faster but skips model events. The Custom Commands & Scheduling lesson covers the scheduler.

Key takeaways

  • Eloquent fires saving, creating/updating, created/updated, saved around every write, and deleting/deleted around deletes. "-ing" events run before the SQL and can change or cancel it.
  • Put more than a couple of handlers in an observer and attach it with #[ObservedBy] on the model.
  • Model events only fire for model instances: mass update() and delete() queries skip them.
  • isDirty(), wasChanged() and getOriginal() tell a handler what changed.
  • Soft deletes set deleted_at; use withTrashed(), onlyTrashed(), restore() and forceDelete(), and prune old rows on a schedule.