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
- Advanced Observer Implementation
- Conditional Observer Logic
- Testing Observer Logic
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.
Add Comment
No comments yet. Be the first to comment!