Introduction
I remember the first time I encountered a Laravel model with dozens of methods handling everything from validation to email sending. The User model had grown into a 400-line monster that did everything except make coffee. Sound familiar?
Laravel Observers solve this exact problem by giving you a clean way to handle model lifecycle events without turning your models into Swiss Army knives. Instead of cramming event logic directly into your models, observers let you extract that logic into dedicated classes that listen for specific model events.
Think of observers as specialized event handlers that watch your models and react when something interesting happens. When a user registers, gets updated, or deletes their account, your observer can automatically handle the side effects without cluttering your model code.
What Are Laravel Observers?
Laravel Observers are classes that listen to model events and execute specific logic when those events fire. Every Eloquent model automatically dispatches events throughout its lifecycle:
creating
/created
- Before and after a new record is savedupdating
/updated
- Before and after an existing record is modifieddeleting
/deleted
- Before and after a record is removedrestoring
/restored
- Before and after a soft-deleted record is restoredsaving
/saved
- Before and after any save operation (create or update)retrieved
- When a model is retrieved from the database
The key difference between "ing" and "ed" events is timing. The "ing" events fire before the database operation, giving you a chance to modify data or cancel the operation. The "ed" events fire after the operation completes successfully.
// This happens BEFORE the user is saved to the database
public function creating(User $user)
{
$user->uuid = Str::uuid();
// You can still modify $user here
}
// This happens AFTER the user is successfully saved
public function created(User $user)
{
Mail::to($user->email)->send(new WelcomeEmail($user));
// The user definitely exists in the database now
}
💡 Tip: Use "ing" events for data preparation and validation, and "ed" events for side effects like sending emails or logging.
Why Observers Beat Alternative Approaches
The Problem with Fat Models
Before observers, you might handle model events like this:
class User extends Model
{
protected static function boot()
{
parent::boot();
static::creating(function ($user) {
$user->uuid = Str::uuid();
$user->role = 'subscriber';
});
static::created(function ($user) {
Mail::to($user->email)->send(new WelcomeEmail($user));
ActivityLog::create([
'user_id' => $user->id,
'action' => 'user_created'
]);
});
static::deleting(function ($user) {
$user->posts()->delete();
$user->comments()->delete();
});
}
// Your actual model logic gets buried...
}
This approach works, but it has serious downsides:
- Testability: Hard to test event logic in isolation
- Readability: Model becomes cluttered with non-core logic
- Reusability: Can't easily share event logic between models
- Single Responsibility: Model does too many things
The Observer Solution
Observers solve these problems by moving event logic to dedicated classes:
class User extends Model
{
// Clean, focused on data relationships and attributes
}
class UserObserver
{
// Dedicated to handling User events
}
This separation makes your code more maintainable, testable, and follows SOLID principles.
Setting Up Laravel Observers
Step 1: Generate an Observer Class
Laravel's Artisan command makes creating observers straightforward:
php artisan make:observer UserObserver --model=User
This generates a class at app/Observers/UserObserver.php
with method stubs for all model events:
<?php
namespace App\Observers;
use App\Models\User;
class UserObserver
{
public function retrieved(User $user) {}
public function creating(User $user) {}
public function created(User $user) {}
public function updating(User $user) {}
public function updated(User $user) {}
public function saving(User $user) {}
public function saved(User $user) {}
public function deleting(User $user) {}
public function deleted(User $user) {}
public function restoring(User $user) {}
public function restored(User $user) {}
public function forceDeleted(User $user) {}
}
⚠️ Warning: Only implement the methods you actually need. Empty observer methods still get called and can impact performance.
Step 2: Implement Observer Logic
Remove unused methods and implement only what you need:
<?php
namespace App\Observers;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use App\Mail\WelcomeEmail;
use Illuminate\Support\Facades\Mail;
class UserObserver
{
public function creating(User $user)
{
// Set default values before saving
if (empty($user->uuid)) {
$user->uuid = Str::uuid();
}
if (empty($user->role)) {
$user->role = 'subscriber';
}
}
public function created(User $user)
{
// Send welcome email after successful creation
Mail::to($user->email)->send(new WelcomeEmail($user));
// Log the event
Log::info('New user registered', [
'user_id' => $user->id,
'email' => $user->email
]);
}
public function deleting(User $user)
{
// Clean up related data before deletion
$user->posts()->delete();
$user->comments()->delete();
Log::info('User account deleted', [
'user_id' => $user->id,
'email' => $user->email
]);
}
}
Step 3: Register the Observer
You have several options for registering observers:
Option 1: AppServiceProvider (Most Common)
<?php
namespace App\Providers;
use App\Models\User;
use App\Observers\UserObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
User::observe(UserObserver::class);
}
}
Option 2: Custom Service Provider (For Complex Apps)
For larger applications, create a dedicated EventServiceProvider
:
php artisan make:provider EventServiceProvider
<?php
namespace App\Providers;
use App\Models\User;
use App\Models\Post;
use App\Observers\UserObserver;
use App\Observers\PostObserver;
use Illuminate\Support\ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
public function boot()
{
User::observe(UserObserver::class);
Post::observe(PostObserver::class);
// Add more observers here
}
}
Don't forget to register your new provider in config/app.php
.
📌 Note: Observers are registered during the application boot process, so they're available throughout your application's lifecycle.
Real-World Observer Examples
User Management Observer
Here's a comprehensive UserObserver that handles common user lifecycle events:
<?php
namespace App\Observers;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Jobs\SendWelcomeEmail;
use App\Jobs\CleanupUserData;
use Illuminate\Support\Str;
class UserObserver
{
public function creating(User $user)
{
// Generate UUID if not provided
$user->uuid = $user->uuid ?? Str::uuid();
// Set default role
$user->role = $user->role ?? 'user';
// Hash password if it's not already hashed
if ($user->isDirty('password')) {
$user->password = bcrypt($user->password);
}
}
public function created(User $user)
{
// Queue welcome email (don't block the request)
SendWelcomeEmail::dispatch($user);
// Log user registration
ActivityLogger::log('user.created', $user);
// Create default user settings
$user->settings()->create([
'notifications_enabled' => true,
'theme' => 'light'
]);
}
public function updating(User $user)
{
// Track email changes for verification
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
// Hash password only if it's being changed
if ($user->isDirty('password')) {
$user->password = bcrypt($user->password);
}
}
public function updated(User $user)
{
// Send email verification if email changed
if ($user->wasChanged('email')) {
$user->sendEmailVerificationNotification();
}
ActivityLogger::log('user.updated', $user, $user->getChanges());
}
public function deleting(User $user)
{
// Prevent deletion if user has active subscriptions
if ($user->subscriptions()->active()->exists()) {
throw new \Exception('Cannot delete user with active subscriptions');
}
ActivityLogger::log('user.deleting', $user);
}
public function deleted(User $user)
{
// Queue cleanup job for heavy operations
CleanupUserData::dispatch($user->id);
ActivityLogger::log('user.deleted', $user);
}
}
E-commerce Product Observer
<?php
namespace App\Observers;
use App\Models\Product;
use App\Jobs\UpdateSearchIndex;
use App\Services\CacheManager;
class ProductObserver
{
public function created(Product $product)
{
// Update search index
UpdateSearchIndex::dispatch($product);
// Clear related caches
CacheManager::clearProductCaches();
}
public function updated(Product $product)
{
// Only update search if relevant fields changed
if ($product->wasChanged(['name', 'description', 'price'])) {
UpdateSearchIndex::dispatch($product);
}
// Clear specific product cache
CacheManager::forget("product.{$product->id}");
}
public function updating(Product $product)
{
// Auto-generate slug from name
if ($product->isDirty('name')) {
$product->slug = Str::slug($product->name);
}
// Update timestamps for inventory changes
if ($product->isDirty('stock_quantity')) {
$product->stock_updated_at = now();
}
}
}
Advanced Observer Techniques
Conditional Observer Logic
Sometimes you need observers to behave differently based on context:
public function creating(User $user)
{
// Skip UUID generation during testing
if (app()->environment('testing')) {
return;
}
$user->uuid = Str::uuid();
}
public function created(User $user)
{
// Only send emails in production
if (app()->environment('production')) {
Mail::to($user->email)->send(new WelcomeEmail($user));
}
}
Observer Dependencies
Observers can use dependency injection just like controllers:
<?php
namespace App\Observers;
use App\Models\User;
use App\Services\UserService;
use App\Services\NotificationService;
class UserObserver
{
public function __construct(
private UserService $userService,
private NotificationService $notificationService
) {}
public function created(User $user)
{
$this->userService->createDefaultProfile($user);
$this->notificationService->sendWelcomeNotification($user);
}
}
Preventing Infinite Loops
Be careful when your observer logic triggers additional model events:
public function created(User $user)
{
// This could trigger another 'updated' event!
$user->update(['last_login' => now()]);
}
public function updated(User $user)
{
// This observer method would be called again
Log::info('User updated');
}
To prevent this, use direct database queries or check for specific changes:
public function created(User $user)
{
// Direct database update - no model events
$user->updateQuietly(['last_login' => now()]);
}
public function updated(User $user)
{
// Only log if it's not our own update
if (!$user->wasChanged('last_login')) {
Log::info('User updated');
}
}
💡 Tip: Use updateQuietly()
, deleteQuietly()
, or saveQuietly()
to perform model operations without triggering events.
Testing Laravel Observers
Basic Observer Testing
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Mail\WelcomeEmail;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class UserObserverTest extends TestCase
{
use RefreshDatabase;
public function test_creates_uuid_when_user_is_created()
{
$user = User::factory()->create();
$this->assertNotNull($user->uuid);
$this->assertTrue(Str::isUuid($user->uuid));
}
public function test_sends_welcome_email_after_user_creation()
{
Mail::fake();
$user = User::factory()->create();
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
public function test_prevents_deletion_with_active_subscriptions()
{
$user = User::factory()->create();
$user->subscriptions()->create(['status' => 'active']);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Cannot delete user with active subscriptions');
$user->delete();
}
}
Testing Observer Dependencies
public function test_observer_calls_user_service()
{
$this->mock(UserService::class, function ($mock) {
$mock->shouldReceive('createDefaultProfile')
->once()
->with(\Mockery::type(User::class));
});
User::factory()->create();
}
Disabling Observers in Tests
Sometimes you need to test model behavior without observer interference:
public function test_user_creation_without_observers()
{
User::withoutEvents(function () {
$user = User::factory()->create(['uuid' => null]);
$this->assertNull($user->uuid);
});
}
Common Observer Patterns and Best Practices
1. Queueing Heavy Operations
Don't perform heavy operations directly in observers. Queue them instead:
public function created(User $user)
{
// Bad: Blocks the request
$this->generateUserReport($user);
// Good: Queued for background processing
GenerateUserReport::dispatch($user);
}
2. Error Handling in Observers
Observer exceptions can break your application flow. Handle them gracefully:
public function created(User $user)
{
try {
Mail::to($user->email)->send(new WelcomeEmail($user));
} catch (\Exception $e) {
Log::error('Failed to send welcome email', [
'user_id' => $user->id,
'error' => $e->getMessage()
]);
// Don't re-throw unless it's critical
// The user was still created successfully
}
}
3. Conditional Logic Based on Changes
Use Laravel's change detection methods to make observers smarter:
public function updated(User $user)
{
// Only process specific changes
if ($user->wasChanged('email')) {
$user->sendEmailVerificationNotification();
}
if ($user->wasChanged(['first_name', 'last_name'])) {
UpdateUserSearchIndex::dispatch($user);
}
// Check what the old value was
if ($user->wasChanged('role') && $user->getOriginal('role') === 'user') {
Log::info('User role upgraded', ['user_id' => $user->id]);
}
}
4. Observer Organization
For complex applications, organize observers by domain:
app/Observers/
├── User/
│ ├── UserObserver.php
│ ├── UserProfileObserver.php
│ └── UserSubscriptionObserver.php
├── Commerce/
│ ├── ProductObserver.php
│ ├── OrderObserver.php
│ └── PaymentObserver.php
└── Content/
├── PostObserver.php
└── CommentObserver.php
Observer Edge Cases and Gotchas
Mass Assignment Events
Observers don't fire for mass operations like User::where('active', false)->delete()
or User::insert($userData)
. These bypass Eloquent entirely:
// Observers will NOT fire
User::where('role', 'temp')->delete();
User::insert(['name' => 'John', 'email' => 'john@example.com']);
// Observers WILL fire
User::where('role', 'temp')->get()->each->delete();
📌 Note: If you need observer logic for mass operations, consider using database triggers or handle the logic manually.
Transaction Rollbacks
If an observer throws an exception, it can rollback the entire database transaction:
public function created(User $user)
{
// If this fails, the user creation gets rolled back
throw new \Exception('Something went wrong');
}
For non-critical operations, catch exceptions or use queued jobs:
public function created(User $user)
{
try {
// Try to send email
Mail::to($user->email)->send(new WelcomeEmail($user));
} catch (\Exception $e) {
// Log but don't fail the user creation
Log::error('Welcome email failed', ['user_id' => $user->id]);
}
}
Observer Performance
Observers add overhead to every model operation. Keep them fast:
public function created(User $user)
{
// Bad: Synchronous API call
Http::post('https://external-api.com/users', $user->toArray());
// Good: Queue the API call
NotifyExternalService::dispatch($user);
}
Debugging Observer Issues
Observer Not Firing
Common causes and solutions:
- Observer not registered: Check your service provider
- Using mass operations: Switch to Eloquent model operations
- Observer method named wrong: Event methods must match exactly (
created
, notafterCreate
)
Too Many Observer Calls
Use Laravel Telescope or add debug logging:
public function updated(User $user)
{
Log::debug('UserObserver::updated called', [
'user_id' => $user->id,
'changes' => $user->getChanges(),
'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)
]);
}
Observer vs Event Listeners: When to Choose What
Use Observers When | Use Event Listeners When |
---|---|
Logic is tightly coupled to a specific model | Logic applies to multiple models |
Standard model lifecycle events | Custom events or complex event logic |
Simple, straightforward event handling | Need event broadcasting or queuing |
Most of your event logic | Cross-cutting concerns like caching |
// Observer: User-specific logic
class UserObserver
{
public function created(User $user)
{
$user->profile()->create(['bio' => '']);
}
}
// Event Listener: Cross-cutting concern
class ClearCacheListener
{
public function handle($event)
{
Cache::tags(['users', 'posts'])->flush();
}
}
Testing Strategies for Observers
Integration Testing
Test that observers work correctly with your models:
public function test_user_registration_flow()
{
Mail::fake();
$user = User::factory()->create([
'email' => 'test@example.com'
]);
// Observer should have set UUID
$this->assertNotNull($user->uuid);
// Observer should have sent welcome email
Mail::assertSent(WelcomeEmail::class);
// Observer should have created profile
$this->assertDatabaseHas('user_profiles', [
'user_id' => $user->id
]);
}
Unit Testing Observer Methods
Test observer methods in isolation:
public function test_creating_sets_uuid()
{
$observer = new UserObserver();
$user = new User();
$observer->creating($user);
$this->assertNotNull($user->uuid);
$this->assertTrue(Str::isUuid($user->uuid));
}
Performance Considerations
Observer Best Practices
- Keep observers fast: Delegate heavy work to queued jobs
- Avoid N+1 queries: Be careful with relationship loading in observers
- Use database transactions wisely: Know that observer failures can rollback transactions
- Monitor observer performance: Use profiling tools to identify slow observers
Example: Optimized Observer
public function created(User $user)
{
// Fast: Queue heavy operations
ProcessNewUserData::dispatch($user);
// Fast: Bulk operations when possible
if (static::$pendingNotifications === null) {
static::$pendingNotifications = collect();
}
static::$pendingNotifications->push($user);
// Process in batches during application termination
app()->terminating(function () {
if (static::$pendingNotifications && static::$pendingNotifications->isNotEmpty()) {
NotificationService::sendBatch(static::$pendingNotifications);
static::$pendingNotifications = collect();
}
});
}
Conclusion
Laravel Observers transform how you handle model lifecycle events. Instead of cramming logic into your models or scattering event handling throughout your application, observers provide a clean, testable, and maintainable solution.
The key is knowing when and how to use them effectively. Start with simple use cases like setting default values or logging events, then gradually adopt more advanced patterns as your application grows.
Remember: observers should enhance your application's organization, not complicate it. If you find yourself writing complex observers, consider whether the logic belongs in a service class or queued job instead.
Your future self (and your teammates) will thank you for the cleaner, more organized codebase that observers enable.