Navigation

Laravel

Laravel Model Observers: Reactive Programming Patterns

Master Laravel model observers for reactive programming. Build sophisticated automation workflows, handle model lifecycle events, and implement reactive patterns with comprehensive examples and testing strategies.

Master Laravel's Observer pattern to implement reactive programming, handle model lifecycle events, and build sophisticated automated workflows that respond to data changes.

Table Of Contents

Understanding Observer Pattern in Laravel

Laravel's Observer pattern provides a clean way to implement reactive programming by listening to Eloquent model events and executing code in response to data changes. This powerful feature enables building sophisticated automation workflows, audit trails, and real-time notifications while maintaining separation of concerns.

Model observers become essential in complex applications where multiple systems need to react to data changes. They provide a centralized location for handling model lifecycle events, making your code more maintainable and testable. This approach works particularly well in event-driven Laravel applications where business logic needs to respond to model changes.

Advanced Observer Implementation

Comprehensive User Observer

Create sophisticated observers that handle multiple aspects of model lifecycle:

<?php

namespace App\Observers;

use App\Models\User;
use App\Events\UserRegistered;
use App\Events\UserProfileUpdated;
use App\Events\UserDeactivated;
use App\Services\AuditService;
use App\Services\CacheService;
use App\Services\NotificationService;
use App\Services\SearchIndexService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;

class UserObserver
{
    protected AuditService $auditService;
    protected CacheService $cacheService;
    protected NotificationService $notificationService;
    protected SearchIndexService $searchService;
    
    public function __construct(
        AuditService $auditService,
        CacheService $cacheService,
        NotificationService $notificationService,
        SearchIndexService $searchService
    ) {
        $this->auditService = $auditService;
        $this->cacheService = $cacheService;
        $this->notificationService = $notificationService;
        $this->searchService = $searchService;
    }
    
    public function creating(User $user): void
    {
        // Set default values
        if (empty($user->uuid)) {
            $user->uuid = \Str::uuid();
        }
        
        // Generate username if not provided
        if (empty($user->username)) {
            $user->username = $this->generateUsername($user->name, $user->email);
        }
        
        // Set default preferences
        $user->preferences = array_merge([
            'theme' => 'light',
            'language' => 'en',
            'notifications' => [
                'email' => true,
                'sms' => false,
                'push' => true,
            ],
            'privacy' => [
                'profile_visible' => true,
                'show_email' => false,
                'allow_messages' => true,
            ],
        ], $user->preferences ?? []);
        
        Log::info('User being created', [
            'email' => $user->email,
            'name' => $user->name,
        ]);
    }
    
    public function created(User $user): void
    {
        // Create audit log
        $this->auditService->log('user_created', $user, [
            'user_id' => $user->id,
            'email' => $user->email,
            'created_at' => $user->created_at,
        ]);
        
        // Send welcome email
        $this->notificationService->sendWelcomeEmail($user);
        
        // Create default user profile
        $user->profile()->create([
            'bio' => '',
            'avatar' => null,
            'website' => null,
            'location' => null,
        ]);
        
        // Add to search index
        $this->searchService->indexUser($user);
        
        // Dispatch domain event
        event(new UserRegistered($user));
        
        // Initialize user analytics
        \App\Jobs\InitializeUserAnalyticsJob::dispatch($user);
        
        Log::info('User created successfully', [
            'user_id' => $user->id,
            'email' => $user->email,
        ]);
    }
    
    public function updating(User $user): void
    {
        // Track what fields are changing
        $changedFields = [];
        foreach ($user->getDirty() as $field => $newValue) {
            $changedFields[$field] = [
                'old' => $user->getOriginal($field),
                'new' => $newValue,
            ];
        }
        
        // Store changed fields for use in updated() method
        $user->setAttribute('_observer_changes', $changedFields);
        
        // Handle sensitive field changes
        if ($user->isDirty('email')) {
            // Mark email as unverified when changed
            $user->email_verified_at = null;
            $user->email_verification_token = \Str::random(60);
        }
        
        if ($user->isDirty('password')) {
            // Update password changed timestamp
            $user->password_changed_at = now();
            
            // Invalidate all existing sessions except current
            $this->invalidateUserSessions($user);
        }
        
        // Update search keywords for better searchability
        $user->search_keywords = $this->generateSearchKeywords($user);
        
        Log::info('User being updated', [
            'user_id' => $user->id,
            'changed_fields' => array_keys($changedFields),
        ]);
    }
    
    public function updated(User $user): void
    {
        $changedFields = $user->getAttribute('_observer_changes') ?? [];
        
        if (empty($changedFields)) {
            return;
        }
        
        // Create audit log for changes
        $this->auditService->log('user_updated', $user, [
            'user_id' => $user->id,
            'changes' => $changedFields,
            'updated_at' => $user->updated_at,
        ]);
        
        // Handle specific field changes
        if (isset($changedFields['email'])) {
            $this->notificationService->sendEmailVerification($user);
            
            // Notify about email change
            $this->notificationService->sendEmailChangeNotification(
                $changedFields['email']['old'],
                $user
            );
        }
        
        if (isset($changedFields['password'])) {
            $this->notificationService->sendPasswordChangeNotification($user);
        }
        
        // Update cache
        $this->cacheService->forget("user:{$user->id}");
        $this->cacheService->forget("user:email:{$user->email}");
        
        // Update search index
        $this->searchService->updateUser($user);
        
        // Clear related caches
        $this->clearRelatedCaches($user);
        
        // Dispatch domain event
        event(new UserProfileUpdated($user, $changedFields));
        
        Log::info('User updated successfully', [
            'user_id' => $user->id,
            'changed_fields' => array_keys($changedFields),
        ]);
    }
    
    public function deleting(User $user): void
    {
        // Check if user can be deleted
        if ($user->orders()->exists()) {
            throw new \Exception('Cannot delete user with existing orders');
        }
        
        // Soft delete related records
        $user->posts()->delete();
        $user->comments()->delete();
        $user->favorites()->delete();
        
        Log::warning('User being deleted', [
            'user_id' => $user->id,
            'email' => $user->email,
        ]);
    }
    
    public function deleted(User $user): void
    {
        // Create audit log
        $this->auditService->log('user_deleted', $user, [
            'user_id' => $user->id,
            'email' => $user->email,
            'deleted_at' => now(),
        ]);
        
        // Remove from search index
        $this->searchService->removeUser($user);
        
        // Clear all caches
        $this->cacheService->forget("user:{$user->id}");
        $this->cacheService->forget("user:email:{$user->email}");
        $this->clearRelatedCaches($user);
        
        // Dispatch domain event
        event(new UserDeactivated($user));
        
        // Anonymize user data for analytics
        \App\Jobs\AnonymizeUserDataJob::dispatch($user->id);
        
        Log::info('User deleted successfully', [
            'user_id' => $user->id,
        ]);
    }
    
    public function restoring(User $user): void
    {
        Log::info('User being restored', [
            'user_id' => $user->id,
            'email' => $user->email,
        ]);
    }
    
    public function restored(User $user): void
    {
        // Restore related records
        $user->posts()->restore();
        $user->comments()->restore();
        
        // Re-add to search index
        $this->searchService->indexUser($user);
        
        // Create audit log
        $this->auditService->log('user_restored', $user, [
            'user_id' => $user->id,
            'restored_at' => now(),
        ]);
        
        Log::info('User restored successfully', [
            'user_id' => $user->id,
        ]);
    }
    
    protected function generateUsername(string $name, string $email): string
    {
        $baseUsername = \Str::slug(strtolower($name));
        
        if (empty($baseUsername)) {
            $baseUsername = explode('@', $email)[0];
        }
        
        $username = $baseUsername;
        $counter = 1;
        
        while (User::where('username', $username)->exists()) {
            $username = $baseUsername . $counter;
            $counter++;
        }
        
        return $username;
    }
    
    protected function generateSearchKeywords(User $user): string
    {
        $keywords = [
            $user->name,
            $user->username,
            $user->email,
        ];
        
        if ($user->profile) {
            $keywords[] = $user->profile->bio;
            $keywords[] = $user->profile->location;
        }
        
        return implode(' ', array_filter($keywords));
    }
    
    protected function invalidateUserSessions(User $user): void
    {
        // Implementation depends on your session management strategy
        \DB::table('sessions')
           ->where('user_id', $user->id)
           ->where('id', '!=', session()->getId())
           ->delete();
    }
    
    protected function clearRelatedCaches(User $user): void
    {
        $patterns = [
            "user:{$user->id}:*",
            "posts:user:{$user->id}:*",
            "comments:user:{$user->id}:*",
            "favorites:user:{$user->id}:*",
        ];
        
        foreach ($patterns as $pattern) {
            $this->cacheService->deletePattern($pattern);
        }
    }
}

Product Observer with Business Logic

Implement complex business logic in observers:

<?php

namespace App\Observers;

use App\Models\Product;
use App\Events\ProductCreated;
use App\Events\ProductPriceChanged;
use App\Events\ProductStockLow;
use App\Services\PriceHistoryService;
use App\Services\RecommendationService;
use App\Services\InventoryService;
use App\Services\SearchIndexService;
use Illuminate\Support\Facades\Cache;

class ProductObserver
{
    protected PriceHistoryService $priceHistoryService;
    protected RecommendationService $recommendationService;
    protected InventoryService $inventoryService;
    protected SearchIndexService $searchService;
    
    public function __construct(
        PriceHistoryService $priceHistoryService,
        RecommendationService $recommendationService,
        InventoryService $inventoryService,
        SearchIndexService $searchService
    ) {
        $this->priceHistoryService = $priceHistoryService;
        $this->recommendationService = $recommendationService;
        $this->inventoryService = $inventoryService;
        $this->searchService = $searchService;
    }
    
    public function creating(Product $product): void
    {
        // Generate slug if not provided
        if (empty($product->slug)) {
            $product->slug = $this->generateUniqueSlug($product->name);
        }
        
        // Set default values
        $product->status = $product->status ?? 'draft';
        $product->sort_order = $product->sort_order ?? 0;
        
        // Generate SEO fields
        if (empty($product->meta_title)) {
            $product->meta_title = \Str::limit($product->name, 60);
        }
        
        if (empty($product->meta_description)) {
            $product->meta_description = \Str::limit($product->description, 160);
        }
        
        // Set search keywords
        $product->search_keywords = $this->generateSearchKeywords($product);
    }
    
    public function created(Product $product): void
    {
        // Create initial price history record
        $this->priceHistoryService->recordPrice($product, $product->price);
        
        // Initialize inventory tracking
        $this->inventoryService->initializeProduct($product);
        
        // Add to search index
        $this->searchService->indexProduct($product);
        
        // Update recommendation engine
        $this->recommendationService->addProduct($product);
        
        // Dispatch domain event
        event(new ProductCreated($product));
        
        // Clear category cache
        Cache::forget("category:{$product->category_id}:products");
        
        \Log::info('Product created', [
            'product_id' => $product->id,
            'name' => $product->name,
            'price' => $product->price,
        ]);
    }
    
    public function updating(Product $product): void
    {
        // Track price changes
        if ($product->isDirty('price')) {
            $oldPrice = $product->getOriginal('price');
            $newPrice = $product->price;
            
            // Store price change info for updated() method
            $product->setAttribute('_price_changed', [
                'old' => $oldPrice,
                'new' => $newPrice,
                'change_percent' => (($newPrice - $oldPrice) / $oldPrice) * 100,
            ]);
        }
        
        // Update slug if name changed
        if ($product->isDirty('name') && empty($product->slug)) {
            $product->slug = $this->generateUniqueSlug($product->name);
        }
        
        // Update search keywords
        if ($product->isDirty(['name', 'description', 'tags'])) {
            $product->search_keywords = $this->generateSearchKeywords($product);
        }
        
        // Handle status changes
        if ($product->isDirty('status')) {
            $oldStatus = $product->getOriginal('status');
            $newStatus = $product->status;
            
            if ($oldStatus !== 'published' && $newStatus === 'published') {
                $product->published_at = now();
            } elseif ($oldStatus === 'published' && $newStatus !== 'published') {
                $product->unpublished_at = now();
            }
        }
        
        // Handle stock level changes
        if ($product->isDirty('stock_quantity')) {
            $newStock = $product->stock_quantity;
            $lowStockThreshold = $product->low_stock_threshold ?? 10;
            
            if ($newStock <= $lowStockThreshold) {
                $product->setAttribute('_stock_low', true);
            }
        }
    }
    
    public function updated(Product $product): void
    {
        // Handle price changes
        if ($priceChange = $product->getAttribute('_price_changed')) {
            $this->priceHistoryService->recordPrice($product, $priceChange['new']);
            
            // Notify subscribers about significant price changes
            if (abs($priceChange['change_percent']) >= 10) {
                event(new ProductPriceChanged($product, $priceChange));
            }
            
            // Update recommendation scores
            $this->recommendationService->updateProductScores($product);
        }
        
        // Handle low stock
        if ($product->getAttribute('_stock_low')) {
            event(new ProductStockLow($product));
            
            // Auto-reorder if enabled
            if ($product->auto_reorder_enabled) {
                \App\Jobs\CreatePurchaseOrderJob::dispatch($product);
            }
        }
        
        // Update search index
        $this->searchService->updateProduct($product);
        
        // Clear related caches
        $this->clearProductCaches($product);
        
        // Update category statistics
        if ($product->isDirty('category_id')) {
            $oldCategoryId = $product->getOriginal('category_id');
            
            \App\Jobs\UpdateCategoryStatsJob::dispatch($oldCategoryId);
            \App\Jobs\UpdateCategoryStatsJob::dispatch($product->category_id);
        }
        
        \Log::info('Product updated', [
            'product_id' => $product->id,
            'changed_fields' => array_keys($product->getDirty()),
        ]);
    }
    
    public function deleting(Product $product): void
    {
        // Check if product can be deleted
        if ($product->orderItems()->exists()) {
            throw new \Exception('Cannot delete product with existing orders');
        }
        
        // Soft delete related records
        $product->reviews()->delete();
        $product->favorites()->delete();
        $product->images()->delete();
    }
    
    public function deleted(Product $product): void
    {
        // Remove from search index
        $this->searchService->removeProduct($product);
        
        // Update recommendation engine
        $this->recommendationService->removeProduct($product);
        
        // Clear caches
        $this->clearProductCaches($product);
        
        // Update category statistics
        \App\Jobs\UpdateCategoryStatsJob::dispatch($product->category_id);
        
        \Log::info('Product deleted', [
            'product_id' => $product->id,
            'name' => $product->name,
        ]);
    }
    
    protected function generateUniqueSlug(string $name): string
    {
        $baseSlug = \Str::slug($name);
        $slug = $baseSlug;
        $counter = 1;
        
        while (Product::where('slug', $slug)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
        
        return $slug;
    }
    
    protected function generateSearchKeywords(Product $product): string
    {
        $keywords = [
            $product->name,
            $product->description,
            $product->tags,
            $product->brand,
            $product->model,
        ];
        
        if ($product->category) {
            $keywords[] = $product->category->name;
        }
        
        return implode(' ', array_filter($keywords));
    }
    
    protected function clearProductCaches(Product $product): void
    {
        $cacheKeys = [
            "product:{$product->id}",
            "product:slug:{$product->slug}",
            "category:{$product->category_id}:products",
            "featured:products",
            "popular:products",
        ];
        
        foreach ($cacheKeys as $key) {
            Cache::forget($key);
        }
    }
}

Conditional Observer Logic

Smart Observer with Conditional Execution

Implement observers that execute logic based on specific conditions:

<?php

namespace App\Observers;

use App\Models\Order;
use App\Events\OrderStatusChanged;
use App\Events\OrderCompleted;
use App\Events\OrderCancelled;
use App\Services\CustomerService;
use App\Services\InventoryService;
use App\Services\RewardService;
use App\Services\NotificationService;

class OrderObserver
{
    protected CustomerService $customerService;
    protected InventoryService $inventoryService;
    protected RewardService $rewardService;
    protected NotificationService $notificationService;
    
    public function __construct(
        CustomerService $customerService,
        InventoryService $inventoryService,
        RewardService $rewardService,
        NotificationService $notificationService
    ) {
        $this->customerService = $customerService;
        $this->inventoryService = $inventoryService;
        $this->rewardService = $rewardService;
        $this->notificationService = $notificationService;
    }
    
    public function updating(Order $order): void
    {
        if ($order->isDirty('status')) {
            $oldStatus = $order->getOriginal('status');
            $newStatus = $order->status;
            
            // Store status change info
            $order->setAttribute('_status_changed', [
                'from' => $oldStatus,
                'to' => $newStatus,
                'timestamp' => now(),
            ]);
            
            // Validate status transitions
            $this->validateStatusTransition($oldStatus, $newStatus);
        }
    }
    
    public function updated(Order $order): void
    {
        if (!$statusChange = $order->getAttribute('_status_changed')) {
            return;
        }
        
        $fromStatus = $statusChange['from'];
        $toStatus = $statusChange['to'];
        
        // Log status change
        $order->statusHistory()->create([
            'from_status' => $fromStatus,
            'to_status' => $toStatus,
            'changed_by' => auth()->id(),
            'notes' => $order->status_notes,
            'changed_at' => $statusChange['timestamp'],
        ]);
        
        // Handle specific status transitions
        $this->handleStatusTransition($order, $fromStatus, $toStatus);
        
        // Dispatch status change event
        event(new OrderStatusChanged($order, $fromStatus, $toStatus));
        
        // Clear related caches
        $this->clearOrderCaches($order);
    }
    
    protected function handleStatusTransition(Order $order, string $fromStatus, string $toStatus): void
    {
        switch ($toStatus) {
            case Order::STATUS_CONFIRMED:
                $this->handleOrderConfirmed($order, $fromStatus);
                break;
                
            case Order::STATUS_PROCESSING:
                $this->handleOrderProcessing($order, $fromStatus);
                break;
                
            case Order::STATUS_SHIPPED:
                $this->handleOrderShipped($order, $fromStatus);
                break;
                
            case Order::STATUS_DELIVERED:
                $this->handleOrderDelivered($order, $fromStatus);
                break;
                
            case Order::STATUS_COMPLETED:
                $this->handleOrderCompleted($order, $fromStatus);
                break;
                
            case Order::STATUS_CANCELLED:
                $this->handleOrderCancelled($order, $fromStatus);
                break;
                
            case Order::STATUS_REFUNDED:
                $this->handleOrderRefunded($order, $fromStatus);
                break;
        }
    }
    
    protected function handleOrderConfirmed(Order $order, string $fromStatus): void
    {
        // Reserve inventory
        foreach ($order->items as $item) {
            $this->inventoryService->reserve($item->product_id, $item->quantity);
        }
        
        // Send confirmation email
        $this->notificationService->sendOrderConfirmation($order);
        
        // Update customer stats
        $this->customerService->incrementOrderCount($order->user_id);
        
        // Create shipping label if auto-ship is enabled
        if ($order->auto_ship_enabled) {
            \App\Jobs\CreateShippingLabelJob::dispatch($order);
        }
        
        \Log::info('Order confirmed', [
            'order_id' => $order->id,
            'from_status' => $fromStatus,
        ]);
    }
    
    protected function handleOrderProcessing(Order $order, string $fromStatus): void
    {
        // Allocate inventory from reserved stock
        foreach ($order->items as $item) {
            $this->inventoryService->allocate($item->product_id, $item->quantity);
        }
        
        // Send processing notification
        $this->notificationService->sendOrderProcessingNotification($order);
        
        // Estimate delivery date
        $order->update([
            'estimated_delivery_date' => now()->addDays(
                $order->shipping_method === 'express' ? 2 : 5
            ),
        ]);
        
        \Log::info('Order processing started', [
            'order_id' => $order->id,
        ]);
    }
    
    protected function handleOrderShipped(Order $order, string $fromStatus): void
    {
        // Send shipping notification with tracking info
        $this->notificationService->sendOrderShippingNotification($order);
        
        // Schedule delivery confirmation job
        \App\Jobs\ConfirmDeliveryJob::dispatch($order)
            ->delay($order->estimated_delivery_date);
        
        // Deduct from physical inventory
        foreach ($order->items as $item) {
            $this->inventoryService->deduct($item->product_id, $item->quantity);
        }
        
        \Log::info('Order shipped', [
            'order_id' => $order->id,
            'tracking_number' => $order->tracking_number,
        ]);
    }
    
    protected function handleOrderDelivered(Order $order, string $fromStatus): void
    {
        // Send delivery confirmation
        $this->notificationService->sendOrderDeliveredNotification($order);
        
        // Schedule automatic completion after review period
        \App\Jobs\CompleteOrderJob::dispatch($order)
            ->delay(now()->addDays(7)); // 7-day review period
        
        // Request review
        \App\Jobs\RequestProductReviewJob::dispatch($order)
            ->delay(now()->addDays(3));
        
        \Log::info('Order delivered', [
            'order_id' => $order->id,
            'delivered_at' => now(),
        ]);
    }
    
    protected function handleOrderCompleted(Order $order, string $fromStatus): void
    {
        // Award loyalty points
        $pointsEarned = $this->rewardService->awardOrderPoints($order);
        
        // Update customer lifetime value
        $this->customerService->updateLifetimeValue($order->user_id, $order->total_amount);
        
        // Send completion notification with points earned
        $this->notificationService->sendOrderCompletedNotification($order, $pointsEarned);
        
        // Trigger recommendation engine update
        \App\Jobs\UpdateCustomerRecommendationsJob::dispatch($order->user_id);
        
        // Dispatch domain event
        event(new OrderCompleted($order));
        
        \Log::info('Order completed', [
            'order_id' => $order->id,
            'points_earned' => $pointsEarned,
        ]);
    }
    
    protected function handleOrderCancelled(Order $order, string $fromStatus): void
    {
        // Release reserved/allocated inventory
        foreach ($order->items as $item) {
            if (in_array($fromStatus, [Order::STATUS_CONFIRMED, Order::STATUS_PROCESSING])) {
                $this->inventoryService->release($item->product_id, $item->quantity);
            }
        }
        
        // Process refund if payment was captured
        if ($order->payment_status === 'captured') {
            \App\Jobs\ProcessRefundJob::dispatch($order);
        }
        
        // Send cancellation notification
        $this->notificationService->sendOrderCancellationNotification($order);
        
        // Update customer stats
        $this->customerService->decrementOrderCount($order->user_id);
        
        // Dispatch domain event
        event(new OrderCancelled($order));
        
        \Log::warning('Order cancelled', [
            'order_id' => $order->id,
            'from_status' => $fromStatus,
            'reason' => $order->cancellation_reason,
        ]);
    }
    
    protected function handleOrderRefunded(Order $order, string $fromStatus): void
    {
        // Deduct loyalty points if they were awarded
        if ($fromStatus === Order::STATUS_COMPLETED) {
            $this->rewardService->deductOrderPoints($order);
        }
        
        // Update customer lifetime value
        $this->customerService->updateLifetimeValue($order->user_id, -$order->total_amount);
        
        // Send refund confirmation
        $this->notificationService->sendOrderRefundNotification($order);
        
        \Log::info('Order refunded', [
            'order_id' => $order->id,
            'refund_amount' => $order->refund_amount,
        ]);
    }
    
    protected function validateStatusTransition(string $fromStatus, string $toStatus): void
    {
        $validTransitions = [
            Order::STATUS_PENDING => [Order::STATUS_CONFIRMED, Order::STATUS_CANCELLED],
            Order::STATUS_CONFIRMED => [Order::STATUS_PROCESSING, Order::STATUS_CANCELLED],
            Order::STATUS_PROCESSING => [Order::STATUS_SHIPPED, Order::STATUS_CANCELLED],
            Order::STATUS_SHIPPED => [Order::STATUS_DELIVERED, Order::STATUS_CANCELLED],
            Order::STATUS_DELIVERED => [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED],
            Order::STATUS_COMPLETED => [Order::STATUS_REFUNDED],
            Order::STATUS_CANCELLED => [], // Terminal state
            Order::STATUS_REFUNDED => [], // Terminal state
        ];
        
        if (!isset($validTransitions[$fromStatus]) || 
            !in_array($toStatus, $validTransitions[$fromStatus])) {
            throw new \InvalidArgumentException(
                "Invalid status transition from {$fromStatus} to {$toStatus}"
            );
        }
    }
    
    protected function clearOrderCaches(Order $order): void
    {
        $cacheKeys = [
            "order:{$order->id}",
            "user:{$order->user_id}:orders",
            "user:{$order->user_id}:order_stats",
        ];
        
        foreach ($cacheKeys as $key) {
            \Cache::forget($key);
        }
    }
}

Testing Observer Logic

Comprehensive Observer Testing

Test observers thoroughly to ensure reliability:

<?php

namespace Tests\Unit\Observers;

use Tests\TestCase;
use App\Models\User;
use App\Models\UserProfile;
use App\Observers\UserObserver;
use App\Events\UserRegistered;
use App\Events\UserProfileUpdated;
use App\Services\AuditService;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;

class UserObserverTest extends TestCase
{
    use RefreshDatabase;
    
    protected function setUp(): void
    {
        parent::setUp();
        
        // Mock services
        $this->app->bind(AuditService::class, function () {
            return \Mockery::mock(AuditService::class);
        });
        
        $this->app->bind(NotificationService::class, function () {
            return \Mockery::mock(NotificationService::class);
        });
    }
    
    public function test_creating_sets_default_values(): void
    {
        $user = new User([
            'name' => 'John Doe',
            'email' => 'john@example.com',
            'password' => 'password',
        ]);
        
        $observer = app(UserObserver::class);
        $observer->creating($user);
        
        $this->assertNotEmpty($user->uuid);
        $this->assertNotEmpty($user->username);
        $this->assertIsArray($user->preferences);
        $this->assertEquals('light', $user->preferences['theme']);
    }
    
    public function test_created_performs_initialization(): void
    {
        Event::fake();
        Queue::fake();
        
        $auditService = \Mockery::mock(AuditService::class);
        $auditService->shouldReceive('log')
                    ->once()
                    ->with('user_created', \Mockery::type(User::class), \Mockery::type('array'));
        
        $notificationService = \Mockery::mock(NotificationService::class);
        $notificationService->shouldReceive('sendWelcomeEmail')
                           ->once()
                           ->with(\Mockery::type(User::class));
        
        $this->app->instance(AuditService::class, $auditService);
        $this->app->instance(NotificationService::class, $notificationService);
        
        $user = User::factory()->create();
        
        // Verify profile was created
        $this->assertInstanceOf(UserProfile::class, $user->fresh()->profile);
        
        // Verify event was dispatched
        Event::assertDispatched(UserRegistered::class, function ($event) use ($user) {
            return $event->user->id === $user->id;
        });
        
        // Verify job was queued
        Queue::assertPushed(\App\Jobs\InitializeUserAnalyticsJob::class);
    }
    
    public function test_updating_handles_email_change(): void
    {
        $user = User::factory()->create([
            'email' => 'old@example.com',
            'email_verified_at' => now(),
        ]);
        
        $user->email = 'new@example.com';
        
        $observer = app(UserObserver::class);
        $observer->updating($user);
        
        $this->assertNull($user->email_verified_at);
        $this->assertNotEmpty($user->email_verification_token);
    }
    
    public function test_updated_dispatches_events_for_changes(): void
    {
        Event::fake();
        
        $user = User::factory()->create(['name' => 'Old Name']);
        
        // Simulate updating process
        $user->name = 'New Name';
        $user->setAttribute('_observer_changes', [
            'name' => ['old' => 'Old Name', 'new' => 'New Name']
        ]);
        
        $observer = app(UserObserver::class);
        $observer->updated($user);
        
        Event::assertDispatched(UserProfileUpdated::class, function ($event) use ($user) {
            return $event->user->id === $user->id && 
                   isset($event->changedFields['name']);
        });
    }
    
    public function test_deleting_prevents_deletion_with_orders(): void
    {
        $user = User::factory()->create();
        $user->orders()->create([
            'total_amount' => 100.00,
            'status' => 'completed',
        ]);
        
        $observer = app(UserObserver::class);
        
        $this->expectException(\Exception::class);
        $this->expectExceptionMessage('Cannot delete user with existing orders');
        
        $observer->deleting($user);
    }
}

// Integration Testing
namespace Tests\Feature\Observers;

use Tests\TestCase;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Events\OrderStatusChanged;
use App\Events\OrderCompleted;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;

class OrderObserverIntegrationTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_order_completion_workflow(): void
    {
        Event::fake();
        
        $order = Order::factory()->create(['status' => Order::STATUS_DELIVERED]);
        $product = Product::factory()->create();
        
        OrderItem::factory()->create([
            'order_id' => $order->id,
            'product_id' => $product->id,
            'quantity' => 2,
            'unit_price' => 50.00,
        ]);
        
        // Simulate status change to completed
        $order->update(['status' => Order::STATUS_COMPLETED]);
        
        // Verify events were dispatched
        Event::assertDispatched(OrderStatusChanged::class);
        Event::assertDispatched(OrderCompleted::class);
        
        // Verify status history was created
        $this->assertDatabaseHas('order_status_history', [
            'order_id' => $order->id,
            'to_status' => Order::STATUS_COMPLETED,
        ]);
    }
    
    public function test_invalid_status_transition_throws_exception(): void
    {
        $order = Order::factory()->create(['status' => Order::STATUS_COMPLETED]);
        
        $this->expectException(\InvalidArgumentException::class);
        
        $order->update(['status' => Order::STATUS_PROCESSING]);
    }
}

Model observers provide a powerful way to implement reactive programming patterns in Laravel applications. When used judiciously, they enable building sophisticated automated workflows while maintaining clean, testable code that responds intelligently to data changes. This approach works exceptionally well in complex Laravel architectures where business logic needs to react to model lifecycle events.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel