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.
| 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 |
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.
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,savedaround every write, anddeleting/deletedaround 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()anddelete()queries skip them. isDirty(),wasChanged()andgetOriginal()tell a handler what changed.- Soft deletes set
deleted_at; usewithTrashed(),onlyTrashed(),restore()andforceDelete(), and prune old rows on a schedule.