Skip to content

feat(orm): #[Hook] attribute system for declarative model lifecycle hooks#209

Merged
techmahedy merged 4 commits intodoppar:3.xfrom
techmahedy:techmahedy-3.x
Mar 7, 2026
Merged

feat(orm): #[Hook] attribute system for declarative model lifecycle hooks#209
techmahedy merged 4 commits intodoppar:3.xfrom
techmahedy:techmahedy-3.x

Conversation

@techmahedy
Copy link
Member

This PR introduces a PHP 8 attribute-based hook system for the Doppar ORM's model lifecycle. Previously, hooks could only be registered via the $hooks array property. Models can now declare lifecycle callbacks directly on methods using the #[Hook] attribute, keeping hook logic co-located with the method it belongs to.

Also fixes a pre-existing bug where mutations made inside before_created and before_updated hooks were silently discarded — the attribute snapshot was taken before hooks fired, so changes never reached the database.

New #[Hook] Attribute

A new repeatable attribute class Phaseolies\Database\Entity\Attributes\Hook allows annotating model methods as lifecycle hook handlers.

use Phaseolies\Database\Entity\Attributes\Hook;

class User extends Model
{
    // Fires before every create
    #[Hook('before_created')]
    public function generateSlug(): void
    {
        $this->slug = str()->slug($this->name);
    }

    // One method, two events
    #[Hook('before_created')]
    #[Hook('before_updated')]
    public function normalizeEmail(): void
    {
        $this->email = strtolower($this->email);
    }

    // Conditional — only fires when isPublished() returns true
    #[Hook('before_updated', when: 'isPublished')]
    public function stampPublishedAt(): void
    {
        $this->published_at = now();
    }

    public function isPublished(): bool
    {
        return $this->status === 'published';
    }
}

Both Formats Coexist

The array-based $hooks property continues to work unchanged. Both formats can be used in the same model and will fire in order: array hooks first, then attribute hooks.

class Post extends Model
{
    // Array format — unchanged
    protected $hooks = [
        'before_created' => AuditLogger::class,
    ];

    // Attribute format — new
    #[Hook('before_created')]
    public function generateSlug(): void
    {
        $this->slug = str()->slug($this->title);
    }
}

Tests

  • Added tests/Model/HookAttributeTest.php with 27 test cases covering:
  • Registration — event stored, repeat attribute registers both events, multiple methods, conditional when stored
  • Execution — method called on execute, fires on both events, after_deleted, non-registered event skipped
  • Conditional — fires when true, skips when false, callable receives correct model instance
  • Error cases — non-bool when method throws, missing when method throws
  • Coexistence — array hooks and attribute hooks fire together in correct order
  • Duplicate prevention — mirrors existing HookHandlerTest duplicate check for attribute-registered handlers
$ vendor/bin/phpunit tests/Model/HookAttributeTest.php
PHPUnit 12.5.1 · PHP 8.5.3

...........................   27 / 27 (100%)

Time: 00:00.008, Memory: 16.00 MB
OK (27 tests, 34 assertions)

@techmahedy techmahedy added bug Something isn't working feat new feature labels Mar 7, 2026
@techmahedy techmahedy merged commit 7d1b0da into doppar:3.x Mar 7, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant