Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Hookable Laravel Package

sofa-support/hookable

Hookable adds a lightweight hook system to Laravel Eloquent models, letting you register callbacks that run before or after events like create, update, and delete without scattering logic across observers. It keeps model behavior tidy, composable, and easy to test.

View on GitHub
Deep Wiki
Context7

Getting Started

sofa-support/hookable (published from jarektkaczyk/hookable) adds a class-based hook layer on top of Eloquent's model events. Instead of scattering logic across closures in boot() or fat observers, you point a model event at a dedicated hook class. That is the whole value proposition: a created hook becomes a first-class, injectable, testable object you can reuse across models.

1. Install

composer require sofa-support/hookable

Before you commit, check the constraint against your framework version. The last tagged release is December 2020 (Laravel 6/7/8 era), so on Laravel 10/11 you will likely need --ignore-platform-reqs, a "replace" entry, or a fork with the composer.json constraints widened. Composer will tell you immediately; do not discover this at deploy time.

2. Where to look first

  • src/ — the Hookable trait and whatever contract the hook classes implement (an interface with a handle()-style entry point is the seam you will code against).
  • tests/ — the canonical usage examples. With 65 stars and no dependents, the test suite is a better spec than the README.
  • composer.json — the Laravel/Illuminate version constraints, so you know how much surgery you are signing up for.

3. First use case

Take one observer that has grown three responsibilities and split it. Model side:

use Sofa\Hookable\Hookable;

class Post extends Model
{
    use Hookable;

    // Illustrative — mirror the exact key/format from the package's tests.
    protected $hooks = [
        'created' => [SlugGenerator::class],
        'saved'   => [FlushPostCache::class],
    ];
}

Hook side:

class SlugGenerator
{
    public function handle(Post $post): void
    {
        if (blank($post->slug)) {
            $post->slug = Str::slug($post->title);
            $post->saveQuietly(); // do NOT re-enter the hook chain
        }
    }
}

Pick a single low-risk event (created on a model with no downstream listeners) as your pilot. Confirm the hook fires, then port the rest. Do not migrate every observer in one PR — the point of the pilot is to learn this package's event-name mapping and lifecycle semantics before they touch billing or auth.

Implementation Patterns

One hook class per concern. The package's win over observers is granularity: SendPostPublishedNotification, ReindexPostInSearch, TouchAuthorLastActivity are separately resolvable from the container, separately unit-testable, and can be attached to different models without a base observer class. If you find yourself writing a 200-line handle(), you have recreated an observer with extra indirection.

Share hooks across models. A hook that only uses Model-level APIs can be attached to any model's $hooks. Audit logging is the classic case:

abstract class AuditHook
{
    public function handle(Model $model): void
    {
        AuditLog::create([
            'model'    => $model::class,
            'model_id' => $model->getKey(),
            'event'    => $this->eventName(), // derive from the hook class or a passed context
            'diff'     => $model->getDirty(),
        ]);
    }
}

Prefer one hook per event over a single hook trying to branch on "which event am I?" — the latter needs context the package may not hand you cleanly, and it re-couples the concerns you just separated.

Keep hooks idempotent and side-effect-scoped. A hook should either mutate the model (before persistence) or perform an external effect (after persistence), not both. Mutating hooks should guard on state (if (! $model->exists), if ($model->wasChanged('status'))) so retries and duplicate fires are harmless.

Defer expensive work. Hooks run on the save path, synchronously. Anything doing HTTP, mail, or a big reindex should dispatch a job:

public function handle(Post $post): void
{
    ReindexPost::dispatch($post->id)->afterCommit();
}

afterCommit() matters if your saves happen inside transactions — without it the job can run before the row is visible and fail with a "model not found" that is maddening to reproduce.

Testing. Because hooks are plain classes, test them directly with a model instance, no database event plumbing required:

$post = new Post(['title' => 'Hello World']);
(new SlugGenerator)->handle($post);
$this->assertSame('hello-world', $post->slug);

Then keep one or two integration tests per model that assert the wiring ($post->save() actually fires the hook). Unit-test the logic, integration-test the registration.

Composition with observers. You do not have to choose. Leave booted() closures for trivial model-local concerns and use hooks for anything reused or worth mocking. Migrating incrementally is safer than a flag-day conversion, especially on a package this lightly maintained.

Extension point. If the package resolves hooks through the container (typical for this family of packages), you can bind a decorator or a test double for a hook class in a service provider and short-circuit it in tests without touching the model. Check src/ for the resolver before relying on it.

Gotchas and Tips

This is a low-maintenance dependency — plan for that. 65 stars, zero dependents, last release 2020, opportunity score 2.16. That is not "don't use it"; it is "vendor the source or fork it on day one." A private fork with widened composer.json constraints costs an hour and removes the single biggest risk. Do not build your architecture around the assumption that a Laravel 12 compatibility release is coming.

Know which Eloquent events map to what. creating/created fire only on insert; updating/updated only on update; saving/saved fire on both. Attaching a slug generator to saved means it runs twice on create-then-update paths in ways that surprise people. When in doubt, attach to the narrowest event that covers your case.

Infinite recursion is the #1 footgun. Any hook that calls $model->save() re-enters the event chain. Use saveQuietly() (Laravel 8+) or withoutEvents() when a hook must persist follow-up changes, and verify that quiet saves actually bypass this package's hooks — the trait may intercept at a level withoutEvents doesn't cover. Test this explicitly; it is the one behavior most likely to differ from stock observers.

Soft deletes and force deletes. deleting/deleted fire for soft deletes; forceDeleting/forceDeleted do not unless you register them. Cleanup hooks keyed on deleted will silently not run on a hard delete. Register both if the resource must be cleaned either way.

Dirty state timing. In a saving-level hook, getDirty() reflects pre-save values and getOriginal() is the pre-update snapshot. In an updated-level hook the model is already persisted and wasChanged() is your friend. Getting this wrong produces hooks that check the wrong value and fire (or don't) nondeterministically.

Transactions. Hooks are unaware of transaction boundaries. External side effects performed synchronously in a hook will not be rolled back when the surrounding transaction fails. Either move side effects to afterCommit jobs or dispatch them from a DB::afterCommit() callback. This is the bug that shows up in production as "user got the email but the order doesn't exist."

Ordering and multiple hooks. If several hooks touch the same attribute, ordering matters. The package's ordering guarantees (array order? registration order?) need to be read from the source, not assumed. When order is load-bearing, merge the hooks into one class with explicit sequencing rather than relying on registration order.

Debugging. Start crude: Log::info(static::class, ['id' => $model->getKey()]) at the top of each handle(), save the model, and confirm the fire order. If nothing fires, check (a) the trait is on the model, (b) the event name string matches exactly what Eloquent emits, (c) the class is resolvable — a container binding or constructor dependency that can't be autowired fails at the worst possible moment, sometimes swallowed. Wrap the resolver in a try/catch temporarily to surface it.

Config quirks. There is likely little or no publishable config. Do not go looking for a config/hookable.php to solve a problem — read src/ and the trait's $hooks handling instead. With a package this small, the source is the documentation.

When not to use it. If your app has a handful of observers with genuinely model-local logic, stock observers or booted() closures are fine and carry zero dependency risk. Reach for this package when you have the specific pain it solves: reusable, injectable, individually testable hook classes shared across many models. Otherwise you are paying a maintenance tax for indirection you don't need.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums