0

Model events — simple observer hooks on save/delete

Advanced5 min read·fw-08-008

Concept

Model events are lifecycle hooks that fire at specific points: before/after creation, update, deletion, hydration. They enable observer-style reactions to model changes without coupling the model to specific behaviors.

Event types:

  • creating / created: Before/after INSERT.
  • updating / updated: Before/after UPDATE.
  • saving / saved: Before/after INSERT OR UPDATE (fires for both).
  • deleting / deleted: Before/after DELETE.
  • retrieved: After a model is fetched from the database (hydrated).

returning false from "before" events: If a creating, updating, or deleting listener returns false, the operation is cancelled. Useful for validation or conditional prevention.

Implementation approaches:

  1. Static listeners on the model class: User::creating(fn($user) => ...). Stored in a static $listeners map.
  2. Observer classes: A dedicated class with methods matching event names (creating(), updated(), etc.). Registered with User::observe(UserObserver::class).
  3. Framework event dispatcher: Each model event fires a framework event. Listeners registered in the event system. More decoupled but more indirection.

ObservedBy PHP attribute (Laravel 10+): #[ObservedBy(UserObserver::class)] on the model class — auto-registers the observer without explicit registration code.

Timestamps: The save() method fires saving → creating/updating → saved → created/updated.

Code Example

php
<?php
namespace Framework\Orm;

abstract class Model
{
    protected static array $globalListeners = []; // [class][event] => [callable, ...]

    public static function creating(\Closure $callback): void
    {
        static::$globalListeners[static::class]['creating'][] = $callback;
    }

    public static function created(\Closure $callback): void
    {
        static::$globalListeners[static::class]['created'][] = $callback;
    }

    public static function observe(string|object $observer): void
    {
        $instance = is_string($observer) ? new $observer() : $observer;
        foreach (['creating', 'created', 'updating', 'updated', 'saving', 'saved', 'deleting', 'deleted'] as $event) {
            if (method_exists($instance, $event)) {
                static::$globalListeners[static::class][$event][] = [$instance, $event];
            }
        }
    }

    protected function fireEvent(string $event): bool
    {
        $listeners = static::$globalListeners[static::class][$event] ?? [];
        foreach ($listeners as $listener) {
            if ($listener($this) === false) {
                return false; // cancel the operation
            }
        }
        return true;
    }

    protected function performInsert(): bool
    {
        if ($this->fireEvent('creating') === false) return false;
        if ($this->fireEvent('saving') === false)   return false;

        // ... SQL INSERT ...

        $this->fireEvent('created');
        $this->fireEvent('saved');
        return true;
    }

    protected function performUpdate(): bool
    {
        if ($this->fireEvent('updating') === false) return false;
        if ($this->fireEvent('saving') === false)   return false;

        // ... SQL UPDATE ...

        $this->fireEvent('updated');
        $this->fireEvent('saved');
        return true;
    }

    public function delete(): bool
    {
        if ($this->fireEvent('deleting') === false) return false;
        // ... SQL DELETE ...
        $this->fireEvent('deleted');
        return true;
    }
}

// Observer class
class UserObserver
{
    public function creating(User $user): void
    {
        $user->uuid = \Ramsey\Uuid\Uuid::uuid4()->toString(); // set UUID before insert
    }

    public function created(User $user): void
    {
        \App\Jobs\SendWelcomeEmail::dispatch($user);
    }

    public function deleting(User $user): bool|void
    {
        if ($user->hasActiveSubscription()) {
            return false; // cancel deletion — can't delete users with active subscriptions
        }
    }
}

// Registration
User::observe(UserObserver::class);
// or: User::creating(fn($u) => $u->uuid = Uuid::uuid4()->toString());