PHP code example of ascend / laravel-column-watcher

1. Go to this page and download the library: Download ascend/laravel-column-watcher library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

ascend / laravel-column-watcher example snippets


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
    }
}

// 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)

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);
        }
    }
}

// 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

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
}

HandleStatusChange::fake();

$request->update(['status' => 'approved']);

HandleStatusChange::assertTriggered();

class SyncToExternalApi extends ColumnWatcher implements ShouldQueue
{
    protected function execute(ColumnChange $change): void
    {
        // Runs in the background automatically
    }
}



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
    }
}



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
{
    // ...
}

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);
    }
}

#[Watch(['name', 'email', 'phone'], HandleContactInfoChange::class)]
class User extends Model {}

class HandleContactInfoChange extends ColumnWatcher
{
    protected function execute(ColumnChange $change): void
    {
        Log::info("User {$change->column} changed", [
            'from' => $change->oldValue,
            'to' => $change->newValue,
        ]);
    }
}

#[Watch('status', HandleStatusChange::class)]
#[Watch('status', NotifyAdmins::class)]
#[Watch('priority', HandlePriorityChange::class)]
class Request extends Model {}

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)]

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);
    }
}

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
    {
        // ...
    }
}

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
    }
}

#[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,
        };
    }
}

#[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(),
        ]);
    }
}

#[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}"
            );
        }
    }
}

#[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],
        ]);
    }
}

// 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);
}

// app/Models/Request.php
#[Watch('status', HandleStatusChange::class)]
#[Watch('priority', HandlePriorityChange::class)]
#[Watch(['name', 'description'], HandleMetadataChange::class)]
class Request extends Model {}

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()) {
    // ...
}

// config/column-watcher.php
return [
    'enabled' => env('COLUMN_WATCHER_ENABLED', true),
];

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);
});

// 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'],
];



namespace App\Watchers;

use Ascend\LaravelColumnWatcher\ColumnWatcher;
use Ascend\LaravelColumnWatcher\Data\ColumnChange;

class HandleStatusChange extends ColumnWatcher
{
    protected function execute(ColumnChange $change): void
    {
        //
    }
}

use Illuminate\Contracts\Queue\ShouldQueue;

class SyncToExternalService extends ColumnWatcher implements ShouldQueue
{
    protected function execute(ColumnChange $change): void
    {
        //
    }
}

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();
}

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);

use Ascend\LaravelColumnWatcher\ColumnWatcher;

ColumnWatcher::disable();

// Watchers won't fire for any operations
$request->update(['status' => 'archived']);

ColumnWatcher::enable();

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);
}

protected function tearDown(): void
{
    HandleStatusChange::stopFaking();
    parent::tearDown();
}

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);
}

$user = User::create(['status' => 'active']);
// Watcher fires with: oldValue = null, newValue = 'active'

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();
    }
}

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!

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;
    }
}

use Ascend\LaravelColumnWatcher\Enums\Timing;

// This will throw InvalidTimingException
#[Watch('status', QueueableHandler::class, Timing::SAVING)]
class Order extends Model {}
bash
php artisan make:watcher HandleStatusChange

# For queueable handlers (runs in background):
php artisan make:watcher SyncToExternalService --queued
bash
php artisan make:watcher SyncToExternalService --queued
bash
php artisan vendor:publish --tag=column-watcher-config
bash
php artisan watcher:list