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.
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: 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
| 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
Thanks for reading. If this solved a problem you've had, share it with someone who's drowning in observer spaghetti.

Top comments (5)
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.
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 :)
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
ObservedByattribute is repeatable.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!
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.