Your User model is 500 lines long. Half of it is business logic that triggers when something gets created, updated, or deleted. Sound familiar?
Laravel's Model Events and Observers give you a clean way to handle side effects—sending emails, logging changes, syncing data—without cluttering your models or controllers. Here's how to use them properly.
The Problem: Logic Sprawl
Most Laravel apps start clean. Then requirements pile up:
- Send a welcome email when a user registers
- Log every time a post is published
- Clear cache when settings change
- Sync data to an external API on updates
Suddenly your controller looks like this:
1public function store(Request $request) 2{ 3 $user = User::create($request->validated()); 4 5 Mail::to($user)->send(new WelcomeEmail($user)); 6 Log::info('User created', ['user_id' => $user->id]); 7 Cache::forget('user-count'); 8 ExternalApi::syncUser($user); 9 10 return redirect()->route('users.index');11}
This is fine for one controller. But what about UserSeeder? The RegisterController? API endpoints? Now you're duplicating logic everywhere.
Model Events: The Basics
Laravel fires events at key moments in a model's lifecycle:
creating/created– Before and after insertupdating/updated– Before and after updatesaving/saved– Before and after insert OR updatedeleting/deleted– Before and after deleteretrieved– When fetched from databasereplicating– Whenreplicate()is called
You can hook into these directly in your model:
1class User extends Model2{3 protected static function booted(): void4 {5 static::created(function (User $user) {6 Mail::to($user)->send(new WelcomeEmail($user));7 });8 }9}
Now the welcome email fires regardless of where the user is created—controllers, seeders, Tinker, queues. One place, always works.
When to Use Observers
Model event closures work great for one or two hooks. But when you have five or more, your model gets bloated again. Enter Observers.
Observers are dedicated classes that handle all events for a model:
1php artisan make:observer UserObserver --model=User
This generates:
1class UserObserver 2{ 3 public function created(User $user): void 4 { 5 Mail::to($user)->send(new WelcomeEmail($user)); 6 Log::info('User created', ['user_id' => $user->id]); 7 } 8 9 public function updated(User $user): void10 {11 if ($user->wasChanged('email')) {12 Mail::to($user)->send(new EmailChangedNotification($user));13 }14 }15 16 public function deleted(User $user): void17 {18 ExternalApi::removeUser($user);19 Cache::forget("user.{$user->id}");20 }21}
Register it in a service provider:
1// AppServiceProvider or EventServiceProvider2public function boot(): void3{4 User::observe(UserObserver::class);5}
Or use the #[ObservedBy] attribute in Laravel 10+:
1use App\Observers\UserObserver;2use Illuminate\Database\Eloquent\Attributes\ObservedBy;3 4#[ObservedBy(UserObserver::class)]5class User extends Model6{7 // Clean and declarative8}
Pro Tips for Production
1. Use Queued Listeners for Heavy Operations
Don't block the request with slow operations:
1public function created(User $user): void2{3 // Bad: Blocks the request4 // ExternalApi::syncUser($user);5 6 // Good: Dispatch to queue7 SyncUserToExternalApi::dispatch($user);8}
2. Check What Actually Changed
The updated event fires even if no columns changed (when save() is called). Use wasChanged() to avoid unnecessary work:
1public function updated(User $user): void2{3 if ($user->wasChanged('email')) {4 // Only runs if email actually changed5 VerifyNewEmail::dispatch($user);6 }7}
3. Prevent Infinite Loops
Be careful with saving or updating events that modify the model:
1public function saving(Post $post): void2{3 // This triggers another 'saving' event... infinite loop!4 // $post->save();5 6 // Instead, just modify the model (it will be saved automatically)7 $post->slug = Str::slug($post->title);8}
4. Use withoutEvents() When Needed
Sometimes you need to bypass observers (bulk imports, data migrations):
1// Skip all events for this operation2User::withoutEvents(function () {3 User::where('inactive', true)->delete();4});
5. Order Matters in Saving vs Creating
The saving event fires before creating or updating. Use it for validation or data transformation that applies to both scenarios:
1public function saving(User $user): void 2{ 3 $user->name = Str::title($user->name); // Normalize name 4} 5 6public function created(User $user): void 7{ 8 // Runs after saving, only for new records 9 SendWelcomeNotification::dispatch($user);10}
When NOT to Use Observers
Observers aren't always the right choice:
- Complex business logic → Use Action classes or Services
- Conditional behavior → If logic depends on who or why, keep it in controllers
- Performance-critical bulk operations → Use
withoutEvents()or raw queries - Cross-model transactions → Use database transactions with explicit error handling
The rule of thumb: Observers handle universal side effects. If it should happen every single time the model changes, use an observer. If it depends on context, keep it explicit.
The Payoff
With observers in place, your controllers become thin again:
1public function store(Request $request)2{3 $user = User::create($request->validated());4 5 return redirect()->route('users.index');6}
All the side effects—emails, logs, cache clearing, API syncs—happen automatically. Seeders work. API endpoints work. Tinker works. No duplication.
Quick Reference
| Event | When It Fires | Common Uses |
|---|---|---|
creating |
Before insert | Set defaults, generate UUIDs |
created |
After insert | Send notifications, log activity |
updating |
Before update | Validate changes, update timestamps |
updated |
After update | Sync to external APIs, clear cache |
saving |
Before insert/update | Normalize data, generate slugs |
saved |
After insert/update | Update related models |
deleting |
Before delete | Check dependencies, soft-delete related |
deleted |
After delete | Clean up files, remove from external services |
A practical guide from the Beartropy team. Build cleaner Laravel apps with less code duplication.
Comments
Leave a comment
No comments yet. Be the first to share your thoughts!