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:
- Static listeners on the model class:
User::creating(fn($user) => ...). Stored in a static$listenersmap. - Observer classes: A dedicated class with methods matching event names (
creating(),updated(), etc.). Registered withUser::observe(UserObserver::class). - 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());