
TLDR; View package here Let me tell you about the observer that broke me. It started innocently....
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));
}
}
}
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.
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.
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.
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.
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.
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.
I wanted something that:
So I built it.
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.
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 valueNo manual getOriginal() tracking. No wasChanged() checks. It just works.
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.
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.
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
}
}
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).
// 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';
});
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);
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.
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)]
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.
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();
}
}
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
}
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.
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]
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(),
]);
}
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);
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'],
];
| 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 |
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:
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.
gemmaI ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...
communityHey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...
ai(yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...
aiMy laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...
githubactionsI Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...
aiI've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...
Workflows from the Neura Market marketplace related to this DeepSeek resource