larapack/hooks
Larapack/Hooks adds a lightweight hook system to Laravel, letting you register custom action and filter points and run them throughout your app. Extend or modify behavior without touching core code, using a simple API for adding and firing hooks.
Install
composer require larapack/hooks
On Laravel 5.5+ the Larapack\Hooks\HooksServiceProvider is auto-discovered. On older versions register the provider in config/app.php and add the facade alias:
'providers' => [Larapack\Hooks\HooksServiceProvider::class],
'aliases' => ['Hooks' => Larapack\Hooks\Facades\Hooks::class],
Where to look first
src/HooksServiceProvider.php — find the container binding key so you can resolve the manager directly (app('hooks') or similar) when you don't want the facade.src/ for the facade and the class behind it — this is your real API surface. The package is unmaintained since 2020, so treat the source as the spec; the README examples are the reliable subset.App\Providers\HookServiceProvider — the place you will actually spend your time.First use case: fire an action, then listen to it
Emit a hook from the place where the domain event happens:
// app/Http/Controllers/OrderController.php
Hooks::doAction('order.placed', $order);
Listen from a separate provider so the listener is decoupled from the emitter:
// app/Providers/HookServiceProvider.php
public function boot()
{
Hooks::addAction('order.placed', function ($order) {
Log::info('order placed', ['id' => $order->id]);
});
Hooks::addFilter('invoice.line_items', function ($lines, $order) {
return $lines->push(['label' => 'Shipping', 'amount' => 500]);
});
}
Then pull the value back out with Hooks::applyFilters('invoice.line_items', $lines, $order).
The mental model is one sentence: actions are fire-and-forget side effects, filters are value transformers that must return the value. Everything else is detail.
Actions vs. filters — pick deliberately
| Intent | Use | Return |
|---|---|---|
| "Something happened, react to it" | addAction / doAction |
ignored |
| "Give me a modified value" | addFilter / applyFilters |
the (possibly modified) value |
If a callback mutates an object passed by reference and returns nothing, that's an action wearing a filter's clothes — it will break the chain when someone adds a second filter. Keep the two clearly separated in your codebase.
Centralise hook names as constants
Stringly-typed hook names are the number-one source of silent failures. Put them on a class:
final class Hook
{
public const ORDER_PLACED = 'order.placed';
public const INVOICE_LINES = 'invoice.line_items';
}
Then Hooks::doAction(Hook::ORDER_PLACED, $order). Typos now fail at the call site instead of vanishing into the void.
One provider per module, not one provider for everything
The patterns that age well treat hooks as a lightweight plugin contract. Each feature module (billing, notifications, reporting) gets its own provider that registers its own listeners. Your application core only ever emits; it never knows who is listening. That's the whole value proposition — keep it intact and resist the urge to reach into the hook manager from a controller to inspect listeners.
Bridge from Laravel's native events
You usually don't want two parallel event systems. Pick the boundary: native events for internal, strongly-typed domain events; hooks for anything that third-party or optional modules might extend. A one-line bridge keeps them in sync:
Event::listen(OrderPlaced::class, fn ($e) => Hooks::doAction(Hook::ORDER_PLACED, $e->order));
Register in boot(), not register()
Callbacks live in memory on the manager instance. Registering in boot() guarantees the container is ready and other providers' listeners already exist; registering in register() makes ordering depend on provider load order in a way that's hard to reason about later.
Testing
Because Hooks is a facade, Laravel's generic facade helpers work without any package support:
Hooks::spy();
$this->post('/orders', [...])->assertOk();
Hooks::shouldHaveReceived('doAction')->with(Hook::ORDER_PLACED, Mockery::type(Order::class));
For filter assertions, register the filter in the test itself and assert on the transformed output — that tests your contract, not the package's internals.
Integration tips
doAction after the transaction commits, not inside it. A listener that sends email or hits a queue inside a rolled-back transaction is a classic bug.($value, ...$context) and returns $value. Document the context arguments where you define the constant.actionExists / hasAction depending on version) so you can assert "this hook point is wired" in a health check or test.The package is frozen since March 2020. Pin the version in composer.json, read src/ rather than trusting blog posts, and assume no upstream fixes. If the hook surface you need outgrows it, the migration path is a thin wrapper class of your own that internally delegates to Event::dispatch/Event::until — then swapping implementations is a one-file change.
Filters that forget to return. A filter callback with no return silently nulls the value for every downstream listener. This is the most common production bug with any WordPress-style hook system. Assert the return in code review; better, add a test that registers two filters and checks both run.
Ordering is registration order, not priority. Unless you verify otherwise in src/, don't assume WordPress-style $priority arguments are honoured. If order genuinely matters, either (a) control it by provider boot order, or (b) collapse multiple listeners into one callback you control. Don't sprinkle priority integers and hope.
Silent no-ops on typos. Calling doAction('order.placd') with no listeners is not an error — it just does nothing. In development, log emitted hooks at debug level, or assert actionExists() in tests for the hook points you consider part of your public contract.
Nothing is persisted. Listeners exist only for the current request. Registering from a controller or a queue job means the registration is gone next request. If you need per-tenant or per-module listeners, resolve which providers boot from config, not whether to call addAction at runtime.
Config quirks. If the package ships a publishable config, publish it (php artisan vendor:publish) before editing — editing the vendor copy is lost on the next composer update. Check whether the provider merges config with mergeConfigFrom; if it does, your published values still win, but only for keys you actually define.
Debugging blind. When a hook "isn't firing," check in this order: (1) is the provider registered and booted? (2) does the hook name string match exactly, including case? (3) is the emit actually reached — is it inside a transaction that rolled back, or after an early return? Dumping the manager instance (dd(app('hooks'))) shows registered callbacks and settles it in seconds.
Don't nest filters recursively. A filter that internally calls applyFilters on the same hook will loop forever. Derive a distinct hook name for the inner transformation.
Extension points worth knowing. The service provider binds the manager into the container; you can rebind it to your own subclass if you need logging, priority support, or a deny-list for hooks. That's the cleanest escape hatch short of replacing the package — and it keeps Hooks:: calls in your application code untouched, so a future migration stays cheap.
How can I help you explore Laravel packages today?