Re-thinking Laravel's Observer Pattern With Column Watchers…
    Neura MarketNeura Market/DeepSeek
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    DeepSeekBlogRe-thinking Laravel's Observer Pattern With Column Watchers
    Back to Blog
    Re-thinking Laravel's Observer Pattern With Column Watchers
    laravel

    Re-thinking Laravel's Observer Pattern With Column Watchers

    Charlie Waddell February 11, 2026
    0 views

    TLDR; View package here Let me tell you about the observer that broke me. It started innocently....

    TLDR; View package here

    Image description

    Let me tell you about the observer that broke me.

    It started innocently. A client needed an email sent when a user's account status changed. Easy - I'll use an observer:

    class UserObserver
    {
        public function updated(User $user)
        {
            if ($user->wasChanged('status')) {
                Mail::to($user)->send(new AccountStatusChanged($user));
            }
        }
    }
    

    Clean. Simple. Laravel best practices.

    Then the requirements kept coming.


    The Observer That Ate My Codebase

    "Can we also sync status changes to Salesforce?"

    public function updated(User $user)
    {
      if ($user->wasChanged('status')) {
          Mail::to($user)->send(new AccountStatusChanged($user));
    
          // New requirement
          app(SalesforceClient::class)->updateContact($user->salesforce_id, [
              'status' => $user->status,
          ]);
      }
    }
    

    "We need to update the user's Stripe subscription when their plan changes."

    public function updated(User $user)
    {
      if ($user->wasChanged('status')) {
          Mail::to($user)->send(new AccountStatusChanged($user));
          app(SalesforceClient::class)->updateContact($user->salesforce_id, [
              'status' => $user->status,
          ]);
      }
    
      // Another requirement
      if ($user->wasChanged('plan')) {
          app(StripeClient::class)->updateSubscription(
              $user->stripe_subscription_id,
              ['price' => $user->plan->stripe_price_id]
          );
      }
    }
    

    "The marketing team needs to know when emails change for their mailing list."

    "Legal wants an audit log of all profile changes."

    "Can we notify the account manager when a high-value customer changes anything?"

    Six months later:

    class UserObserver
    {
        public function __construct(
            private SalesforceClient $salesforce,
            private StripeClient $stripe,
            private MailingListService $mailingList,
            private AuditLogger $auditLogger,
            private SlackNotifier $slack,
            private AnalyticsService $analytics,
        ) {}
        
        public function updated(User $user)
        {
            if ($user->wasChanged('status')) {
                Mail::to($user)->send(new AccountStatusChanged($user));
                $this->salesforce->updateContact($user->salesforce_id, ['status' => $user->status]);
                $this->auditLogger->log('status_change', $user, $user->getOriginal('status'), $user->status);
                
                if ($user->status === 'churned') {
                    $this->slack->notify('#customer-success', "Customer {$user->name} churned");
                    $this->analytics->track('customer_churned', $user);
                }
            
                if ($user->status === 'active' && $user->getOriginal('status') === 'trial') {
                    $this->analytics->track('trial_converted', $user);
                }
            }
            
            if ($user->wasChanged('plan')) {
                $this->stripe->updateSubscription($user->stripe_subscription_id, [
                    'price' => $user->plan->stripe_price_id,
                ]);
                $this->auditLogger->log('plan_change', $user, $user->getOriginal('plan_id'), $user->plan_id);
                $this->salesforce->updateContact($user->salesforce_id, ['plan' => $user->plan->name]);
                
                if ($user->plan->price > $user->getOriginal('plan')->price) {
                    $this->slack->notify('#sales', "Upgrade: {$user->name} moved to {$user->plan->name}");
                }
            }
            
            if ($user->wasChanged('email')) {
                $this->mailingList->updateSubscriber($user->getOriginal('email'), $user->email);
                Mail::to($user->getOriginal('email'))->send(new EmailChangedNotification($user));
                $this->auditLogger->log('email_change', $user, $user->getOriginal('email'), $user->email);
            }
            
            if ($user->wasChanged(['name', 'phone', 'company'])) {
                $this->salesforce->updateContact($user->salesforce_id, $user->only(['name', 'phone', 'company']));
            }
            
            if ($user->isHighValue() && $user->isDirty()) {
                $this->slack->notify('#account-managers', "High-value customer {$user->name} updated their profile");
            }
            
            // ... 150 more lines
        }
    }
    

    This is a real pattern. Maybe you recognise it.


    The Five Problems With Observers

    Problem 1: The God Class

    That observer now has six dependencies, handles five different concerns, and runs every time any user field changes.

    Single Responsibility Principle? We abandoned that somewhere around line 50.

    Want to understand what happens when a user's email changes? Hope you enjoy reading 200 lines of nested conditionals.

    Problem 2: The Testing Nightmare

    How do you test that status changes send an email without also testing Salesforce, Stripe, Slack, and the audit log?

    public function test_status_change_sends_email()
    {
        // Mock EVERYTHING
        Mail::fake();
        $this->mock(SalesforceClient::class);
        $this->mock(StripeClient::class);
        $this->mock(MailingListService::class);
        $this->mock(AuditLogger::class);
        $this->mock(SlackNotifier::class);
        $this->mock(AnalyticsService::class);
        
        $user = User::factory()->create(['status' => 'pending']);
        $user->update(['status' => 'active']);
        
        Mail::assertSent(AccountStatusChanged::class);
    }
    

    You're mocking six services to test one behaviour. And if someone adds a seventh dependency? Every test breaks.

    Alternatively, you disable the observer entirely in tests - which means you're not testing real application behaviour.

    Problem 3: Observers Aren't Queueable

    That Salesforce API call? That Stripe sync? They're running synchronously in your user's request.

    "Just dispatch a job from the observer," you say. Sure:

    if ($user->wasChanged('status')) {
        Mail::to($user)->send(new AccountStatusChanged($user));
        SyncStatusToSalesforce::dispatch($user, $user->getOriginal('status'), $user->status);
        LogStatusChange::dispatch($user, $user->getOriginal('status'), $user->status);
        
        if ($user->status === 'churned') {
            NotifySlackOfChurn::dispatch($user);
            TrackChurnAnalytics::dispatch($user);
        }
    }
    

    Now you have an observer that dispatches jobs, plus separate job classes, plus you're manually passing old/new values everywhere because the job runs later and can't access getOriginal().

    The observer has become a dispatcher for the real logic that lives elsewhere.

    Problem 4: Hidden Side Effects

    Where are observers registered? In AppServiceProvider or EventServiceProvider:

    public function boot()
    {
        User::observe(UserObserver::class);
        Order::observe(OrderObserver::class);
        Payment::observe(PaymentObserver::class);
        // ... 20 more
    }
    

    A new developer opens the User model. They see properties, relationships, scopes. Nothing indicates that saving this model triggers a cascade of external API calls.

    The side effects are invisible at the point where they matter most.

    Problem 5: No Granular Control

    Observers hook into model events: creating, created, updating, updated, saving, saved, deleting, deleted.

    But your logic isn't "when user is updated" - it's "when user's status is updated" or "when user's email is updated."

    So every handler starts with:

    if ($user->wasChanged('status')) {
    

    You're checking for column changes manually, every time, in every method.


    What If There Was A Better Way?

    I wanted something that:

    1. Declared watchers on the model so side effects are visible
    2. Triggered on specific columns, not all updates
    3. Was queueable without manual job dispatching
    4. Could be faked individually in tests
    5. Automatically tracked old and new values

    So I built it.


    Introducing Laravel Column Watcher

    GitHub: https://github.com/CWAscend/laravel-column-watcher

    composer require ascend/laravel-column-watcher
    

    Here's that 200-line observer, rebuilt:

    use Ascend\ColumnWatcher\Attributes\Watch;
    
    #[Watch('status', SendStatusEmail::class)]
    #[Watch('status', SyncStatusToSalesforce::class)]
    #[Watch('status', LogStatusChange::class)]
    #[Watch('status', HandleChurnAnalytics::class)]
    #[Watch('plan', SyncPlanToStripe::class)]
    #[Watch('plan', LogPlanChange::class)]
    #[Watch('plan', SyncPlanToSalesforce::class)]
    #[Watch('email', UpdateMailingList::class)]
    #[Watch('email', SendEmailChangeNotification::class)]
    #[Watch('email', LogEmailChange::class)]
    #[Watch(['name', 'phone', 'company'], SyncContactToSalesforce::class)]
    class User extends Model
    {
      // Your model stays clean
    }
    

    Every watcher is visible. You can see exactly what happens when each column changes. No digging through service providers.


    Writing Handlers

    Each handler is a focused class with one job:

    use Ascend\ColumnWatcher\ColumnWatcher;
    use Ascend\ColumnWatcher\Data\ColumnChange;
    
    class SendStatusEmail extends ColumnWatcher
    {
        protected function execute(ColumnChange $change): void
        {
            Mail::to($change->model)->send(new AccountStatusChanged(
                user: $change->model,
                oldStatus: $change->oldValue,
                newStatus: $change->newValue,
            ));
        }
    }
    

    The ColumnChange object gives you everything:

    • $change->model - The Eloquent model
    • $change->column - Which column triggered this
    • $change->oldValue - The previous value
    • $change->newValue - The current value

    No manual getOriginal() tracking. No wasChanged() checks. It just works.


    Queueable Handlers

    This is where it gets powerful. Want that Salesforce sync to run in the background? Add one interface:

    use Illuminate\Contracts\Queue\ShouldQueue;
    
    class SyncStatusToSalesforce extends ColumnWatcher implements ShouldQueue
    {
        public $queue = 'integrations';
    
        public $tries = 3;
    
        public $backoff = [30, 60, 120];
        
        protected function execute(ColumnChange $change): void
        {
            $user = $change->model;
            
            app(SalesforceClient::class)->updateContact($user->salesforce_id, [
                'status' => $change->newValue,
            ]);
        }
    }
    

    That's it. The handler is dispatched as a queued job automatically.

    No separate job class. No manual dispatching. No passing old/new values through constructor arguments.

    Transaction Safety

    Queued handlers are dispatched after the database transaction commits. If your save fails and rolls back, the job never hits the queue. No orphaned jobs trying to process data that doesn't exist.

    Full Queue Configuration

    All standard Laravel queue features work:

    class SyncToExternalApi extends ColumnWatcher implements ShouldQueue
    {
        public $queue = 'integrations';
    
        public $connection = 'redis';
    
        public $tries = 3;
    
        public $backoff = [30, 60, 120];
    
        public $timeout = 60;
    
        public $maxExceptions = 2;
        
        public function retryUntil(): DateTime
        {
            return now()->addHours(6);
        }
        
        protected function execute(ColumnChange $change): void
        {
            // Your async logic
        }
    }
    

    Testing Made Simple

    Remember mocking six services to test one email? Here's the Column Watcher approach:

    public function test_status_change_sends_email(): void
    {
        SendStatusEmail::fake();
        
        $user = User::factory()->create(['status' => 'pending']);
        $user->update(['status' => 'active']);
        
        SendStatusEmail::assertTriggered();
    }
    

    One line to fake. One line to assert. The other handlers still run (or you can fake them too).

    Rich Assertions

    // Assert it triggered
    SendStatusEmail::assertTriggered();
    
    // Assert it didn't trigger
    SendStatusEmail::assertNotTriggered();
    
    // Assert exact number of triggers
    SendStatusEmail::assertTriggeredTimes(2);
    
    // Assert specific column
    SendStatusEmail::assertTriggeredForColumn('status');
    
    // Assert specific values
    SendStatusEmail::assertTriggeredWithValues(
        oldValue: 'pending',
        newValue: 'active'
    );
    
    // Custom assertion logic
    SendStatusEmail::assertTriggered(function (ColumnChange $change) {
        return $change->model->email === 'test@example.com'
            && $change->newValue === 'active';
    });
    

    Access Recorded Changes

    SendStatusEmail::fake();
    
    $user->update(['status' => 'active']);
    $user->update(['status' => 'suspended']);
    
    $changes = SendStatusEmail::recorded();
    
    $this->assertCount(2, $changes);
    $this->assertEquals('active', $changes[0]->newValue);
    $this->assertEquals('suspended', $changes[1]->newValue);
    

    Test Queued Handlers Identically

    Queueable handlers fake the same way:

    public function test_salesforce_sync_is_queued(): void
    {
        SyncStatusToSalesforce::fake();
    
        $user->update(['status' => 'active']);
    
        SyncStatusToSalesforce::assertTriggered();
    }
    

    No Queue::fake() needed. No job class assertions. Same API whether sync or async.


    Before vs After Save

    Sometimes you need to react before the save - to validate, transform, or even prevent it:

    use Ascend\ColumnWatcher\Enums\Timing;
    
    // Runs BEFORE the database write
    #[Watch('status', ValidateStatusTransition::class, Timing::SAVING)]
    
    // Runs AFTER the database write (default)
    #[Watch('status', SendStatusEmail::class, Timing::SAVED)]
    

    Validation Example

    class ValidateStatusTransition extends ColumnWatcher
    {
        protected function execute(ColumnChange $change): void
        {
            $allowed = [
                'draft' => ['pending'],
                'pending' => ['approved', 'rejected'],
                'approved' => ['completed'],
                'rejected' => ['pending'],
            ];
            
            $valid = $allowed[$change->oldValue] ?? [];
            
            if (! in_array($change->newValue, $valid)) {
                throw new InvalidStatusTransitionException(
                    "Cannot transition from {$change->oldValue} to {$change->newValue}"
                );
            }
        }
    }
    

    Throw an exception in a SAVING handler and the save is aborted.

    Note: SAVING handlers cannot be queued - they run synchronously because they execute before the data is persisted.


    Multi-Column Watching

    Sometimes related columns should trigger the same handler:

    #[Watch(['billing_address', 'shipping_address', 'tax_id'], RecalculateTax::class)]
    class Order extends Model {}
    

    The handler knows which specific column changed:

    class RecalculateTax extends ColumnWatcher
    {
        protected function execute(ColumnChange $change): void
        {
            Log::info("Recalculating tax because {$change->column} changed");
            
            $order = $change->model;
            $order->tax_amount = $this->taxService->calculate($order);
            $order->save();
        }
    }
    

    Global Disable/Enable

    Running migrations? Seeding the database? Disable watchers entirely:

    use Ascend\ColumnWatcher\ColumnWatcher;
    
    ColumnWatcher::disable();
    
    // Seed 50,000 users without triggering any watchers
    User::factory()->count(50000)->create();
    
    // Import legacy data
    DB::table('users')->insert($legacyData);
    
    ColumnWatcher::enable();
    
    Check the current state:
    
    if (ColumnWatcher::isEnabled()) {
        // Watchers are active
    }
    

    Infinite Loop Protection

    What if a handler modifies the model it's watching?

    class UpdateLastStatusChange extends ColumnWatcher
    {
        protected function execute(ColumnChange $change): void
        {
            $change->model->last_status_change_at = now();
            $change->model->save(); // Would this trigger the watcher again?
        }
    }
    

    Column Watcher tracks what's currently processing and prevents re-triggering. No infinite loops, no duplicate executions.


    Artisan Commands

    Generate a new handler:

    php artisan make:watcher SendStatusNotification
    

    See all registered watchers:

    php artisan watcher:list
    

    Output:

      App\Models\Request.status (SAVED) ........................................
      ⇂ App\Watchers\HandleStatusChange
      ⇂ App\Watchers\NotifyAdmins [queued]
    
      App\Models\Request.status (SAVING) .......................................
      ⇂ App\Watchers\ValidateStatusTransition
    
      App\Models\Request.priority (SAVED) ......................................
      ⇂ App\Watchers\HandlePriorityChange
    
      App\Models\User.email (SAVED) ............................................
      ⇂ App\Watchers\SendEmailVerification [queued]
    

    Lifecycle Events

    Need to hook into watcher execution? Events are dispatched automatically:

    use Ascend\ColumnWatcher\Events\WatcherStarted;
    use Ascend\ColumnWatcher\Events\WatcherSucceeded;
    use Ascend\ColumnWatcher\Events\WatcherFailed;
    
    // In a listener
    public function handle(WatcherFailed $event): void
    {
        Log::error('Watcher failed', [
            'handler' => $event->handler,
            'model' => $event->change->model,
            'column' => $event->change->column,
            'exception' => $event->exception->getMessage(),
        ]);
    }
    

    Programmatic Registration

    Prefer not to use attributes? Register watchers manually:

    use Ascend\ColumnWatcher\ColumnWatcher;
    
    // In a service provider
    ColumnWatcher::register(User::class, 'status', SendStatusEmail::class);
    ColumnWatcher::register(User::class, ['name', 'email'], SyncToMailchimp::class);
    

    Configuration

    Publish the config:

    php artisan vendor:publish --tag=column-watcher-config
    
    // config/column-watcher.php
    return [
        // Global enable/disable
        'enabled' => env('COLUMN_WATCHER_ENABLED', true),
        
        // Default namespace for handlers
        'namespace' => 'App\\Watchers',
        
        // Paths to scan for models with Watch attributes
        'model_paths' => ['app/Models'],
    ];
    

    The Comparison

    AspectObserversColumn Watcher
    DeclarationHidden in service providerVisible on model
    ScopeAll model eventsSpecific columns
    Handler sizeGod classSingle responsibility
    Change detectionManual wasChanged()Automatic
    Old value accessManual getOriginal()Automatic
    QueueingDispatch jobs manuallyimplements ShouldQueue
    TestingMock everythingFake individual handlers
    DependenciesAll injected into one classEach handler has its own

    Requirements

    • PHP 8.2+
    • Laravel 11 or 12
    • Octane compatible (scoped state bindings)

    I Want Your Feedback

    I've been using this in production and it's genuinely changed how I think about model side effects. But I built it for my use cases - I want to know about yours.

    Questions for you:

    • Does the attribute-based API feel natural?
    • What features are missing for your projects?
    • Are there scenarios where you'd still reach for observers?
    • How do you currently handle column-specific reactions?

    GitHub: https://github.com/CWAscend/laravel-column-watcher

    Star it, fork it, open issues, or just tell me I'm wrong about observers. I want to hear it all.

    composer require ascend/laravel-column-watcher
    

    Thanks for reading. If this solved a problem you've had, share it with someone who's drowning in observer spaghetti.

    Tags

    laravelphpwebdevdiscuss

    Comments

    More Blog

    View all
    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught megemma

    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught me

    I ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...

    X
    xbill
    Hey DEV, I'm Tobore. Let's actually connect.community

    Hey DEV, I'm Tobore. Let's actually connect.

    Hey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...

    L
    Laurina Ayarah
    I burned through thousands of AI tokens. Then a friend did it for freeai

    I burned through thousands of AI tokens. Then a friend did it for free

    (yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...

    P
    Paulo Henrique
    Claude might be saturating your machineai

    Claude might be saturating your machine

    My laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...

    S
    Sidhant Panda
    Automated GitHub Code Reviews Using Google Geminigithubactions

    Automated GitHub Code Reviews Using Google Gemini

    I Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...

    D
    Darren "Dazbo" Lester
    What is an "agentic harness," actually?ai

    What is an "agentic harness," actually?

    I've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...

    T
    Tilde A. Thurium

    Stay up to date

    Get the latest DeepSeek prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for DeepSeek and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this DeepSeek resource

    • Send push notifications via Google Chrome with the list of all watchers of a specified Bitbucket repositorymake · $2.99 · Related topic
    • Create a REST API for PDF Digital Signatures with Webhooksn8n · $24.99 · Related topic
    • Build AI Agents with Think-Plan-Act Architecture Using Llama-4 Reasoningn8n · $24.99 · Related topic
    • Auto-Publish Content to 9 Social Platforms with Blotato & Airtablen8n · $24.99 · Related topic
    Browse all workflows