DEV Community

Cover image for Re-thinking Laravel's Observer Pattern With Column Watchers
Charlie Waddell
Charlie Waddell

Posted on

Re-thinking Laravel's Observer Pattern With Column Watchers

TLDR; View package here

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));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
      ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

"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]
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

"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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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')) {
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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';
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode

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}"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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?
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

See all registered watchers:

php artisan watcher:list
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Configuration

Publish the config:

php artisan vendor:publish --tag=column-watcher-config
Enter fullscreen mode Exit fullscreen mode
// 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'],
];
Enter fullscreen mode Exit fullscreen mode

The Comparison

Aspect Observers Column Watcher
Declaration Hidden in service provider Visible on model
Scope All model events Specific columns
Handler size God class Single responsibility
Change detection Manual wasChanged() Automatic
Old value access Manual getOriginal() Automatic
Queueing Dispatch jobs manually implements ShouldQueue
Testing Mock everything Fake individual handlers
Dependencies All injected into one class Each 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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (5)

Collapse
 
xwero profile image
david duymelinck

I never understood why people want to create a tight coupling between the model and non-database tasks.
Let the model do database things, and keep the other tasks in their domain.

In the case of your example I see the domains; log, notify, CRM, payment, analytics and mailinglist.

Collapse
 
charlie_waddell01 profile image
Charlie Waddell

To be honest, I largely agree with you. In practice though, I’ve seen observers heavily overused - especially in projects I’ve inherited. This approach is really about bringing some structure and clarity for teams that already rely on model events, and making that pattern a bit more manageable :)

Collapse
 
xwero profile image
david duymelinck • Edited

I get that you want to create a solution for a problem, and I'm all for it.

The problem I see with your library is that it only focuses the solution to split the code based on columns. Which is a valid way of doing things.
But as I suggested it is also possible to do it on task domains.

I think the problem should be fixed by teaching people about loose coupling and composition rather than steering them in a single direction.
The solution is already in Laravel because the ObservedBy attribute is repeatable.

Thread Thread
 
charlie_waddell01 profile image
Charlie Waddell

Totally hear where you’re coming from 🙂

I think Laravel’s repeatable #[ObservedBy] is a great improvement and definitely help reduce “god classes.” That said, they don’t fully address the areas this package is trying to improve that I have outlined in this blog post:

Single responsibility: Observers can still handle multiple events (create, update, delete, saving, creating etc), so responsibilities can still get mixed.

Async tasks & boilerplate: Async work often turns observers into thin wrappers that just dispatch jobs.

Testability: Mocking or faking observers in isolation is still awkward.

Boilerplate code: You still end up repeatedly checking $model->wasChanged('column'), as an example.

The important bit for me is that this isn’t introducing a new concept or steering developers somewhere unfamiliar. It’s deliberately building on patterns Laravel developers already know - we’re still dealing with observables. The package is really just rethinking what an “observable” can be by giving developers more granular, column-level control, while improving dxp, testability, reducing boilerplate, and providing a clean API to work with.

Laravel has always intentionally mixed concerns in the name of developer experience. Model scopes could live in query builders, accessors/mutators and casts put transformation logic on models, route model binding ties routing directly to Eloquent models, policies and notifications are often model-centric, global scopes live on models, etc. This is really about improving the ergonomics of patterns that already exist in that ecosystem - which is part of why Laravel has become so popular. Its focus on developer experience over strict architectural purity makes it easier to learn, easier to pick up, and very practical in real-world apps.

So this isn’t about steering developers in a new direction - it’s about making a familiar path easier to work with!

Thread Thread
 
xwero profile image
david duymelinck • Edited

I do get your library has features that are not available in Laravel and requires less bootstrap code. I think it is a good option when people want to use it.

I think I wasn't clear enough about the steering. It is not about a new direction. It is about presenting a single way of doing things. Of course that is the idea behind most packages.
With the comments I want to create a more open way of thinking about the problem.

I think it should be up to the developers if they want their observers to handle a single task or multiple tasks. Your library is not going to stop developers from having a single observer per column.

About being able to target an Eloquent event in the Laravel observers. I think that is a good thing. That is the feature that your library seems to miss? I only found the saving and saved events.

About the tests, I think it fine to just test the observer method. There is no need to trigger a model event, that is already covered by the tests that come with Laravel.

About asynchronous tasks, I don't think they belong in observers.

Don't get me started on mixing concerns. The short version is, great for rapid development bad for long term projects.
I understand the developer experience perspective, but over-engineering and performance degradation are lurking around the corner. I think the framework makes developers walk a tight rope, and the ones with less knowledge will be prone to fall off.