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:
- Declared watchers on the model so side effects are visible
- Triggered on specific columns, not all updates
- Was queueable without manual job dispatching
- Could be faked individually in tests
- Automatically tracked old and new values
So I built it.
Introducing Laravel Column Watcher
GitHub:
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.
SOCIAL SHARE CARD GENERATOR