Beartropy Logo

Stop Scattering Logic: Master Laravel Model Events & Observers

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 w...

Guides 13 Feb, 2026 Beartropy Team

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 insert
  • updating / updated – Before and after update
  • saving / saved – Before and after insert OR update
  • deleting / deleted – Before and after delete
  • retrieved – When fetched from database
  • replicating – When replicate() is called

You can hook into these directly in your model:

1class User extends Model
2{
3 protected static function booted(): void
4 {
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): void
10 {
11 if ($user->wasChanged('email')) {
12 Mail::to($user)->send(new EmailChangedNotification($user));
13 }
14 }
15 
16 public function deleted(User $user): void
17 {
18 ExternalApi::removeUser($user);
19 Cache::forget("user.{$user->id}");
20 }
21}

Register it in a service provider:

1// AppServiceProvider or EventServiceProvider
2public function boot(): void
3{
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 Model
6{
7 // Clean and declarative
8}

Pro Tips for Production

1. Use Queued Listeners for Heavy Operations

Don't block the request with slow operations:

1public function created(User $user): void
2{
3 // Bad: Blocks the request
4 // ExternalApi::syncUser($user);
5 
6 // Good: Dispatch to queue
7 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): void
2{
3 if ($user->wasChanged('email')) {
4 // Only runs if email actually changed
5 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): void
2{
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 operation
2User::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

0

No comments yet. Be the first to share your thoughts!

Share this post