ascend / laravel-column-watcher
Watch and react to Eloquent model column changes with PHP 8 attributes. Trigger handlers when specific columns are modified, with support for queued handlers, before/after save timing, and built-in infinite loop protection. Think observers, but column specific!
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0
README
A Laravel package that provides attribute-based column watching for Eloquent models. React to specific column changes without the boilerplate of full model observers.
Table of Contents
- The Problem
- The Solution
- Installation
- Usage
- The ColumnChange Object
- Real-World Examples
- Comparison with Observers
- Disabling Watchers
- Events
- Laravel Octane Compatibility
- Configuration
- Artisan Commands
- Testing
- Edge Cases & Limitations
- Requirements
The Problem
Laravel's observer pattern is powerful but fundamentally flawed for column-level reactions. What starts as a simple "notify when status changes" requirement quickly becomes a maintenance burden.
The "God Observer" Anti-Pattern
Observers tend to grow into monolithic classes that handle everything:
class RequestObserver { public function saved(Request $request): void { if ($request->wasChanged('status')) { HandleStatusChange::dispatch($request); NotifyAdmins::dispatch($request); SyncToExternalApi::dispatch($request); } if ($request->wasChanged('priority')) { HandlePriorityChange::dispatch($request); } if ($request->wasChanged('assigned_to')) { NotifyAssignee::dispatch($request); AuditAssignmentChange::dispatch($request); } if ($request->wasChanged(['name', 'description'])) { IndexForSearch::dispatch($request); } // ... and it keeps growing } }
This violates the Single Responsibility Principle. Your observer becomes a dumping ground for unrelated concerns, making it difficult to understand, maintain, and test.
Observers Can't Be Mocked
Testing observer behaviour is notoriously difficult:
// You can't do this: RequestObserver::fake(); // You're forced to either: // 1. Test side-effects directly (fragile, slow) // 2. Disable observers entirely (loses coverage) // 3. Create elaborate test doubles (complex, brittle)
There's no clean way to verify that your observer logic was triggered without testing the downstream effects. This leads to either undertested code or slow, integration-heavy test suites.
Observers Can't Be Queued
When your observer needs to call a slow external API, you're stuck dispatching a job from within the observer:
class RequestObserver { public function saved(Request $request): void { if ($request->wasChanged('status')) { // You can't queue the observer itself // You must create and dispatch a separate job SyncStatusToExternalApi::dispatch($request); } } }
This means creating two classes for every async operation: the observer to detect the change, and the job to handle it. The observer becomes nothing more than a dispatcher.
The Hidden Coupling Problem
Observers create invisible dependencies:
// Looking at this model, you have no idea what happens when you save it class Request extends Model { protected $fillable = ['status', 'priority', 'name']; } // The observer is registered in a service provider far, far away // Surprise side-effects await anyone who modifies this model
New developers (and future you) must hunt through service providers and observer classes to understand what happens when a model changes.
Summary of Observer Pain Points
- Untestable: No mocking, no faking, no assertions
- Not queueable: Must dispatch separate jobs for async work
- God class tendency: All column logic piles into one file
- Hidden behaviour: Registration buried in service providers
- Boilerplate heavy: Separate class + registration for simple reactions
- All-or-nothing: Fires on every save, requires manual change detection
The Solution
Column Watcher represents a paradigm shift in how you react to model changes. Instead of centralised observers, you get:
- Fakeble handlers with built-in test assertions
- Queueable handlers that run in the background
- Single-purpose handlers that do one thing well
- Visible declarations right on your model
use Ascend\LaravelColumnWatcher\Attributes\Watch; #[Watch('status', HandleStatusChange::class)] #[Watch('status', SyncToExternalApi::class)] #[Watch('priority', HandlePriorityChange::class)] class Request extends Model { // Anyone reading this model immediately knows what happens on change // Separate concern, separate handler }
Each handler is a focused, testable, optionally-queueable class.
Fully mockable and fakeable:
HandleStatusChange::fake(); $request->update(['status' => 'approved']); HandleStatusChange::assertTriggered();
Queueable with a single interface:
class SyncToExternalApi extends ColumnWatcher implements ShouldQueue { protected function execute(ColumnChange $change): void { // Runs in the background automatically } }
No observer class. No service provider registration. No manual change detection. The handler only runs when status actually changes.
Installation
composer require ascend/laravel-column-watcher
The package auto-discovers its service provider. No manual registration needed.
Usage
1. Create a Handler
Generate a handler using the artisan command:
php artisan make:watcher HandleStatusChange
# For queueable handlers (runs in background):
php artisan make:watcher SyncToExternalService --queued
This creates app/Watchers/HandleStatusChange.php:
<?php namespace App\Watchers; use Ascend\LaravelColumnWatcher\ColumnWatcher; use Ascend\LaravelColumnWatcher\Data\ColumnChange; class HandleStatusChange extends ColumnWatcher { protected function execute(ColumnChange $change): void { // $change->model - The model instance // $change->column - The column that changed ('status') // $change->oldValue - The previous value // $change->newValue - The new value } }
All handlers extend ColumnWatcher, which includes built-in testing utilities like HandleStatusChange::fake() and HandleStatusChange::assertTriggered(). See Testing for details.
2. Register the Watcher
Option A: Using Attributes (Recommended)
Add the #[Watch] attribute to your model class:
<?php namespace App\Models; use Ascend\LaravelColumnWatcher\Attributes\Watch; use App\Watchers\HandleStatusChange; use Illuminate\Database\Eloquent\Model; #[Watch('status', HandleStatusChange::class)] class Request extends Model { // ... }
Option B: Programmatic Registration
Register watchers in a service provider:
use Ascend\LaravelColumnWatcher\ColumnWatcher; use App\Models\Request; use App\Watchers\HandleStatusChange; class AppServiceProvider extends ServiceProvider { public function boot(): void { ColumnWatcher::register(Request::class, 'status', HandleStatusChange::class); } }
3. Watch Multiple Columns
A single handler can watch multiple columns:
#[Watch(['name', 'email', 'phone'], HandleContactInfoChange::class)] class User extends Model {}
When any of these columns change, the handler receives a ColumnChange object that tells you which specific column triggered it:
class HandleContactInfoChange extends ColumnWatcher { protected function execute(ColumnChange $change): void { Log::info("User {$change->column} changed", [ 'from' => $change->oldValue, 'to' => $change->newValue, ]); } }
4. Multiple Watchers
You can attach multiple watchers to the same model:
#[Watch('status', HandleStatusChange::class)] #[Watch('status', NotifyAdmins::class)] #[Watch('priority', HandlePriorityChange::class)] class Request extends Model {}
5. Timing: Before vs After Save
By default, handlers run after the model is saved (Timing::SAVED). You can run them before save using the timing parameter:
use Ascend\LaravelColumnWatcher\Enums\Timing; // Runs AFTER save (default) - use for notifications, logging, side effects #[Watch('status', SendNotification::class)] // Runs BEFORE save - use for validation, transformation, blocking saves #[Watch('status', ValidateStatusTransition::class, Timing::SAVING)]
When to use each:
| Timing | Event | Use Case |
|---|---|---|
Timing::SAVED (default) |
After database write | Notifications, logging, external API calls |
Timing::SAVING |
Before database write | Validation, data transformation, blocking invalid changes |
6. Queueable Handlers
For handlers that perform slow operations (sending emails, calling APIs), add implements ShouldQueue to run them in the background:
php artisan make:watcher SyncToExternalService --queued
use Ascend\LaravelColumnWatcher\ColumnWatcher; use Ascend\LaravelColumnWatcher\Data\ColumnChange; use Illuminate\Contracts\Queue\ShouldQueue; class SyncToExternalService extends ColumnWatcher implements ShouldQueue { protected function execute(ColumnChange $change): void { // This runs in the background via queue worker ExternalApi::sync($change->model); } }
You can optionally configure queue behaviour using standard Laravel properties:
class SyncToExternalService extends ColumnWatcher implements ShouldQueue { public $connection = 'redis'; public $queue = 'external-sync'; public $tries = 3; public $timeout = 30; protected function execute(ColumnChange $change): void { // ... } }
Note: Queueable handlers only work with timing: Timing::SAVED (the default). Using timing: Timing::SAVING with a queueable handler would defeat the purpose since the save hasn't happened yet.
The ColumnChange Object
Handlers receive a ColumnChange object with these properties and methods:
class HandleStatusChange extends ColumnWatcher { protected function execute(ColumnChange $change): void { // Properties $change->model; // The Eloquent model instance $change->column; // The column name that changed (string) $change->oldValue; // The previous value (mixed) $change->newValue; // The new value (mixed) // Helper methods $change->hasChanged(); // true if oldValue !== newValue $change->wasNull(); // true if oldValue was null $change->isNull(); // true if newValue is null $change->wasEmpty(); // true if oldValue was empty $change->isEmpty(); // true if newValue is empty } }
Real-World Examples
Send Notification on Status Change
#[Watch('status', HandleRequestStatusChange::class)] class Request extends Model {}
class HandleRequestStatusChange extends ColumnWatcher { protected function execute(ColumnChange $change): void { $request = $change->model; match ($change->newValue) { 'approved' => $request->submitter->notify(new RequestApproved($request)), 'rejected' => $request->submitter->notify(new RequestRejected($request)), default => null, }; } }
Audit Trail for Sensitive Fields
#[Watch(['email', 'password', 'role'], AuditSensitiveChange::class)] class User extends Model {}
class AuditSensitiveChange extends ColumnWatcher { protected function execute(ColumnChange $change): void { AuditLog::create([ 'user_id' => $change->model->id, 'field' => $change->column, 'old_value' => $change->column === 'password' ? '[redacted]' : $change->oldValue, 'new_value' => $change->column === 'password' ? '[redacted]' : $change->newValue, 'changed_by' => auth()->id(), ]); } }
Validate State Transitions
#[Watch('status', ValidateStatusTransition::class, Timing::SAVING)] class Order extends Model {}
class ValidateStatusTransition extends ColumnWatcher { private array $allowedTransitions = [ 'pending' => ['processing', 'cancelled'], 'processing' => ['shipped', 'cancelled'], 'shipped' => ['delivered'], ]; protected function execute(ColumnChange $change): void { $allowed = $this->allowedTransitions[$change->oldValue] ?? []; if (!in_array($change->newValue, $allowed)) { throw new InvalidStatusTransition( "Cannot transition from {$change->oldValue} to {$change->newValue}" ); } } }
Sync to External Service (Queued)
#[Watch(['name', 'email', 'plan'], SyncToStripe::class)] class Customer extends Model {}
class SyncToStripe extends ColumnWatcher implements ShouldQueue { public $queue = 'integrations'; protected function execute(ColumnChange $change): void { $customer = $change->model; // Resolve dependencies in execute(), not the constructor // Constructor injection doesn't work with queued jobs $stripe = app(StripeClient::class); $stripe->customers->update($customer->stripe_id, [ 'name' => $customer->name, 'email' => $customer->email, 'metadata' => ['plan' => $customer->plan], ]); } }
Important: Don't use constructor dependency injection in queueable handlers. Laravel serializes the job for the queue and cannot restore injected dependencies. Use app() or resolve() inside the execute() method instead.
Comparison with Observers
| Feature | Observers | Column Watcher |
|---|---|---|
| Testing | Cannot be mocked or faked | Built-in fake(), assertTriggered() |
| Queueing | Must dispatch separate jobs | implements ShouldQueue on handler |
| Code organisation | Tends toward "God observer" | One handler per concern |
| Declaration | Separate class + service provider | Attribute on model |
| Visibility | Hidden in observer class | Visible on model |
| Granularity | All model events | Specific columns only |
| Change detection | Manual (wasChanged()) |
Automatic |
| Multiple handlers | One observer per model | Multiple #[Watch] attributes |
| Timing control | Different methods | Single timing parameter |
Before: Observer Approach
// app/Observers/RequestObserver.php class RequestObserver { public function saved(Request $request): void { if ($request->wasChanged('status')) { HandleStatusChange::dispatch($request); } if ($request->wasChanged('priority')) { HandlePriorityChange::dispatch($request); } if ($request->wasChanged(['name', 'description'])) { HandleMetadataChange::dispatch($request); } } } // app/Providers/AppServiceProvider.php public function boot(): void { Request::observe(RequestObserver::class); }
After: Column Watcher Approach
// app/Models/Request.php #[Watch('status', HandleStatusChange::class)] #[Watch('priority', HandlePriorityChange::class)] #[Watch(['name', 'description'], HandleMetadataChange::class)] class Request extends Model {}
Disabling Watchers
You can temporarily disable watchers globally (useful for migrations, seeding, or testing):
use Ascend\LaravelColumnWatcher\ColumnWatcher; // Disable all watchers ColumnWatcher::disable(); // Run operations without triggering watchers $request->update(['status' => 'archived']); // Re-enable watchers ColumnWatcher::enable(); // Check if enabled if (ColumnWatcher::isEnabled()) { // ... }
Or use the config:
// config/column-watcher.php return [ 'enabled' => env('COLUMN_WATCHER_ENABLED', true), ];
Events
The package dispatches events during watcher execution, allowing you to hook into the lifecycle for logging, metrics, or error tracking.
Available Events
| Event | Dispatched When |
|---|---|
WatcherStarted |
Before the watcher's execute() method runs |
WatcherSucceeded |
After execute() completes successfully |
WatcherFailed |
When execute() throws an exception |
All events contain a reference to the watcher instance, giving you access to the model, column, and values:
use Ascend\LaravelColumnWatcher\Events\WatcherStarted; use Ascend\LaravelColumnWatcher\Events\WatcherSucceeded; use Ascend\LaravelColumnWatcher\Events\WatcherFailed; // In a service provider or listener Event::listen(WatcherStarted::class, function (WatcherStarted $event) { Log::debug('Watcher starting', [ 'watcher' => get_class($event->watcher), 'model' => get_class($event->watcher->model), 'model_id' => $event->watcher->model->getKey(), 'column' => $event->watcher->column, 'old_value' => $event->watcher->oldValue, 'new_value' => $event->watcher->newValue, ]); }); Event::listen(WatcherSucceeded::class, function (WatcherSucceeded $event) { Metrics::increment('watcher.success', [ 'watcher' => get_class($event->watcher), ]); }); Event::listen(WatcherFailed::class, function (WatcherFailed $event) { Log::error('Watcher failed', [ 'watcher' => get_class($event->watcher), 'exception' => $event->exception->getMessage(), ]); // Report to error tracking service report($event->exception); });
Events Fire for Both Sync and Queued Handlers
Events are dispatched inside the watcher's handle() method, which means they fire:
- Synchronous handlers: Events fire immediately during the model save
- Queued handlers: Events fire when the queue worker processes the job
This gives you consistent behaviour regardless of how the watcher is executed.
Laravel Octane Compatibility
This package is fully compatible with Laravel Octane. Internal state is stored using Laravel's scoped container bindings, which are automatically reset between requests.
You don't need to do anything special - it just works. The package avoids static properties that could cause state to bleed between requests in Octane's long-running worker processes.
Configuration
Publish the config file:
php artisan vendor:publish --tag=column-watcher-config
// config/column-watcher.php return [ // Globally enable/disable column watching 'enabled' => env('COLUMN_WATCHER_ENABLED', true), // Default namespace for generated watchers 'namespace' => 'App\\Watchers', // Directories to scan for models (used by watcher:list) 'model_paths' => ['app/Models'], ];
Artisan Commands
watcher:list
List all registered column watchers in your application:
php artisan watcher:list
Output (styled similar to Laravel's event:list):
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]
The command scans model directories configured in model_paths and includes programmatically registered watchers. Queueable handlers are marked with [queued].
make:watcher
Generate a new watcher handler class:
# Create a basic watcher php artisan make:watcher HandleStatusChange # Create a queueable watcher (runs in background) php artisan make:watcher SyncToExternalService --queued
This creates a file in app/Watchers/ (configurable via namespace in config):
<?php namespace App\Watchers; use Ascend\LaravelColumnWatcher\ColumnWatcher; use Ascend\LaravelColumnWatcher\Data\ColumnChange; class HandleStatusChange extends ColumnWatcher { protected function execute(ColumnChange $change): void { // } }
With --queued, the class also implements ShouldQueue:
use Illuminate\Contracts\Queue\ShouldQueue; class SyncToExternalService extends ColumnWatcher implements ShouldQueue { protected function execute(ColumnChange $change): void { // } }
Testing
Faking Handlers
All handlers that extend ColumnWatcher can be faked for testing, similar to Laravel's notification and event faking:
use App\Watchers\HandleStatusChange; public function test_status_change_triggers_handler(): void { HandleStatusChange::fake(); $request = Request::factory()->create(['status' => 'draft']); $request->update(['status' => 'submitted']); // Assert the handler was triggered HandleStatusChange::assertTriggered(); // Assert with specific conditions HandleStatusChange::assertTriggered( fn ($change) => $change->newValue === 'submitted' ); // Assert specific values HandleStatusChange::assertTriggeredWithValues('draft', 'submitted'); // Assert for a specific column HandleStatusChange::assertTriggeredForColumn('status'); // Assert triggered exactly N times HandleStatusChange::assertTriggeredTimes(1); } public function test_handler_not_triggered_when_column_unchanged(): void { HandleStatusChange::fake(); $request = Request::factory()->create(['status' => 'draft']); $request->update(['name' => 'New Name']); // status not changed HandleStatusChange::assertNotTriggered(); }
Available Assertions
| Method | Description |
|---|---|
Handler::fake() |
Start faking the handler |
Handler::stopFaking() |
Stop faking and clear recorded changes |
Handler::assertTriggered() |
Assert handler was triggered at least once |
Handler::assertTriggered($callback) |
Assert with custom condition |
Handler::assertNotTriggered() |
Assert handler was never triggered |
Handler::assertTriggeredTimes($n) |
Assert handler was triggered exactly N times |
Handler::assertTriggeredForColumn($col) |
Assert handler was triggered for specific column |
Handler::assertTriggeredWithValues($old, $new) |
Assert handler was triggered with specific values |
Handler::recorded() |
Get all recorded ColumnChange objects |
Accessing Recorded Changes
When faking, you can access all recorded changes:
HandleStatusChange::fake(); $request->update(['status' => 'submitted']); $request->update(['status' => 'approved']); $changes = HandleStatusChange::recorded(); // $changes is an array of ColumnChange objects $this->assertCount(2, $changes); $this->assertEquals('submitted', $changes[0]->newValue); $this->assertEquals('approved', $changes[1]->newValue);
Disabling All Watchers
To disable all watchers globally (useful for migrations, seeding, or bulk operations):
use Ascend\LaravelColumnWatcher\ColumnWatcher; ColumnWatcher::disable(); // Watchers won't fire for any operations $request->update(['status' => 'archived']); ColumnWatcher::enable();
Testing Without Faking
You can also test handler side-effects directly:
public function test_status_change_sends_notification(): void { Notification::fake(); $request = Request::factory()->create(['status' => 'draft']); $request->update(['status' => 'submitted']); Notification::assertSentTo($request->submitter, RequestSubmitted::class); }
Cleaning Up Fake State
When mixing tests that use fake() with tests that need watchers to actually execute, call stopFaking() in your tearDown() to prevent state pollution between tests:
protected function tearDown(): void { HandleStatusChange::stopFaking(); parent::tearDown(); }
Without this, a test that calls fake() will leave the watcher in fake mode, causing subsequent tests that expect the watcher to run to fail silently.
Testing Queued Handlers with DatabaseTransactions
When using Laravel's DatabaseTransactions trait, queued handlers won't execute because they wait for DB::afterCommit(), which never fires (transactions are rolled back, not committed).
Call withoutAfterCommit() in your setUp() to bypass this:
use Ascend\LaravelColumnWatcher\ColumnWatcher; protected function setUp(): void { parent::setUp(); ColumnWatcher::withoutAfterCommit(); } public function test_status_change_sends_notification(): void { Notification::fake(); $request = Request::factory()->create(['status' => 'draft']); $request->update(['status' => 'submitted']); // This now works with DatabaseTransactions Notification::assertSentTo($request->submitter, RequestSubmitted::class); }
Note: withoutAfterCommit() is safe to call unconditionally - it only affects behaviour when there's an active transaction.
Edge Cases & Limitations
Model Creation
Watchers fire on both create() and update() operations. When a model is created, the "old value" will be null for all columns:
$user = User::create(['status' => 'active']); // Watcher fires with: oldValue = null, newValue = 'active'
Infinite Loop Protection
The package automatically prevents infinite loops when a watcher saves the same model:
class StatusWatcher extends ColumnWatcher { protected function execute(ColumnChange $change): void { // This save won't trigger StatusWatcher again for the same column $change->model->updated_at = now(); $change->model->save(); } }
However, be careful with watchers that modify different columns watched by other handlers.
Transaction Safety
Queued handlers are dispatched after the database transaction commits. If a transaction rolls back, the queued job will not be dispatched:
DB::transaction(function () { $order->status = 'approved'; $order->save(); // Handler queued but not dispatched yet throw new Exception('Oops!'); // Transaction rolls back }); // Queue job is never dispatched - correct behavior!
Deleted Models and Queued Handlers
If a model is deleted before a queued handler processes, Laravel will throw a ModelNotFoundException. Handle this in your handler if needed:
class SyncToExternal extends ColumnWatcher implements ShouldQueue { protected function execute(ColumnChange $change): void { // Model is guaranteed to exist here because SerializesModels // throws ModelNotFoundException before execute() is called // if the model was deleted } public function failed(\Throwable $exception): void { if ($exception instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { // Model was deleted, handle gracefully return; } throw $exception; } }
Queueable Handlers Must Use Timing::SAVED
You cannot use queueable handlers with Timing::SAVING. This is enforced at registration time:
use Ascend\LaravelColumnWatcher\Enums\Timing; // This will throw InvalidTimingException #[Watch('status', QueueableHandler::class, Timing::SAVING)] class Order extends Model {}
Requirements
- PHP 8.2+ (PHP 8.3+ required for Laravel 13)
- Laravel 11, 12, or 13
License
MIT
