millat/laravel-hooks
A lightweight Laravel package for adding hooks and filters to your application, letting you modify behavior at runtime without editing core code. Simple to install and use.
Install and let Laravel's package discovery wire it up:
composer require millat/laravel-hooks
Version note (as of 1.4.0): the 1.4.0 release is a composer.json bump that adds Laravel 12 support (PR #10, first-time contributor). No API changes, no deprecations — so if you're on Laravel 12 you need >=1.4.0; on older Laravel any 1.x works. Pin to "millat/laravel-hooks": "^1.4" and treat 1.4.0 as the floor.
If the package ships a config file, publish it once so you can see the available knobs:
php artisan vendor:publish --tag=laravel-hooks # verify the exact tag in the README
Where to look first (in this order):
README.md — pin down the exact facade class, the helper names (add_action / do_action / add_filter / apply_filters), and whether helpers are globally autoloaded or opt-in.src/ — the whole package is small enough to read end to end in one sitting. You want the Hook class (registration arrays, priority sorting, accepted_args handling) and the service provider (what's bound as singleton, and under what key). Also diff composer.json against 1.3.2 so you know exactly which Laravel/PHP constraints you're agreeing to.config/ — anything controlling helper functions, sorting strategy, or logging.First use case: a filter on a value your core owns.
The canonical entry point is "let other code tweak this before I use it." Pick one value you already compute — a cart total, a menu array, a mail payload — and expose it.
// app/Providers/HooksServiceProvider.php
use Illuminate\Support\ServiceProvider;
class HooksServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Signature mirrors WordPress: (hook, callback, priority, accepted_args)
add_filter('checkout.total', function (float $total, $order) {
return $order->hasCoupon('FREESHIP') ? $total - 4.99 : $total;
}, 10, 2);
}
}
// anywhere in the checkout flow
$total = apply_filters('checkout.total', $subtotal, $order);
And the action side, which is fire-and-forget side effects:
add_action('order.completed', function ($order) {
Log::info('order completed', ['id' => $order->id]);
}, 10, 1);
do_action('order.completed', $order); // returns null
If you're only going to do one thing with this package, do this: convert one hard-coded extension point into a filter, register the callback in a dedicated provider, and write a test that asserts the filtered output. That proves the round-trip before you build a plugin system on it.
1. Dedicated hooks provider, not scattered AppServiceProvider calls.
Keep every add_action / add_filter in one provider (or one per module). Hooks are silently ignored when unregistered, so a centralized file is the only reliable map of "what is extensible in this app."
final class HooksServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->registerCheckoutHooks();
$this->registerMailHooks();
$this->registerAdminMenuHooks();
}
}
2. Plugin/module architecture with a namespaced naming convention. This is the package's whole reason to exist — let a module or a customer-specific package inject behavior without touching core. The discipline that makes it survivable is a hook name scheme, because names are strings with no compiler checking:
app.checkout.total // filter
app.checkout.before_render // action
admin.menu.items // filter
Prefix by domain, verb form by type. Never total or items — collisions across packages are invisible until they bite.
3. Filter chains as a pipeline (the WordPress the_content model).
Each callback receives a value and returns a modified value; priorities order the stages. This is a genuinely nice fit for content/HTML/payload transformations:
add_filter('mail.body', fn ($html) => str_replace('{app}', config('app.name'), $html), 10);
add_filter('mail.body', fn ($html) => strip_tags($html, '<p><a><br>'), 20);
add_filter('mail.body', fn ($html) => trim($html), 30);
$body = apply_filters('mail.body', $rawBody, $user); // chain runs in priority order
Prefer this over a hand-rolled array of callables whenever the order matters and contributors live in different files.
4. Runtime add/remove as a feature flag mechanism. Because registration is just an array mutation, you can conditionally drop callbacks — useful for tenancy, A/B tests, or licensing tiers:
if (! tenant()->hasFeature('advanced_invoicing')) {
remove_filter('invoice.totals', [AdvancedInvoicing::class, 'addTaxLines'], 20);
}
if (! has_filter('checkout.total')) {
Log::warning('No checkout.total filters registered — pricing extensions missing?');
}
The has_filter / has_action guards are worth more than they look: they convert a silent no-op into a diagnosable log line.
5. Interop with Laravel's own event system — deliberately, and documented. Use hooks when you need value transformation with ordered priorities and late runtime registration (filters), or cheap fire-and-forget (actions). Use Laravel events when you need queued listeners, event discovery, broadcasting, or model lifecycle integration. Don't let both conventions drift into the same subsystem — a new developer will not know whether "the thing happens" is an event listener or a hook callback.
6. Testing: register a spy, assert, then clean up.
public function test_checkout_total_filter_applies(): void
{
add_filter('checkout.total', fn ($t) => $t + 10, 10, 1);
$this->assertSame(110.0, apply_filters('checkout.total', 100.0, $order));
remove_all_filters('checkout.total'); // critical — see gotchas
}
Hooks registered in a test leak into every subsequent test in the same process because the container singleton is never rebuilt between tests. A base test case that calls whatever the package exposes for "clear all" in setUp() is non-negotiable once you have more than a couple of hook tests.
7. Wrapping third-party calls as extension points. The highest-leverage pattern in a real app: put a filter around any payload you send to an external service, so a later developer can adjust it without editing your integration class.
$options = apply_filters('client.http.options', $options, $endpoint);
$response = Http::withOptions($options)->post($endpoint, $payload);
Filters that don't return destroy the value. The single most common bug in this paradigm: a callback with no return returns null, and the pipeline happily forwards null to the next stage. Nothing errors — your total is just null three layers down. Make it a code-review rule: every add_filter callback has an explicit return, even if it's return $value; on the branch where it does nothing.
Actions return null; filters return the value. do_action() gives you nothing back. Code like $x = do_action('foo') is always a bug, and it will never warn you.
Missing hooks are silent in both directions. apply_filters('checkout.totl', $x) — typo — returns $x unchanged with zero indication. add_action('checkout.totl', ...) registers into a bucket nothing ever fires. In a small codebase, grep the hook string at review time. In a large one, add a dev-only assertion or a debug-mode registry dump:
// local/dev only — verify the accessor names in src/
if (app()->isLocal()) {
dd(Hooks::getFilters('checkout.total'));
}
State persists across requests in Octane and long-lived workers. The hook registry is a singleton. Registering a callback inside a conditional branch evaluated from the current request (if ($request->is('admin/*'))) means in Octane the first request's decision is baked in forever. Register unconditionally in boot() and check the condition inside the callback, or explicitly flush on RequestTerminated.
Boot ordering bites you with package-provided hooks. Package service providers boot before AppServiceProvider in the default order. If a package fires do_action('pkg.ready') during its own boot(), and your app registers a callback in AppServiceProvider::boot(), you will fire before you register — the callback never runs, silently. Fix: register in register() (runs before all boot()), or have the package fire in booted().
Tied priorities fall back to registration order. Two callbacks at priority 10 execute in the order add_* was called, which means file include order, which means provider order, which means "sometimes." If two stages are order-sensitive, give them distinct priorities (10, 20, 30) rather than relying on registration sequence.
accepted_args defaults to 1. If your filter needs the second context argument, you must declare it at registration: add_filter('x', $cb, 10, 2). Callbacks that expect more args than declared get fewer; the failure mode is a missing-argument error at call time, not at registration.
Self-recursion is a real footgun. A filter callback that internally calls apply_filters on the same hook, guarded only by "it probably won't loop," will hang the request. If a callback can re-enter its own hook, pass a marker argument or a static flag.
Filters can change type. PHP won't stop a callback from returning a string where you expected a float. If the value feeds something strict (a DB column cast, a typed method param), assert or cast at the boundary rather than trusting the chain.
Don't put hot-path loops behind hooks. A handful of callbacks per request is free. A filter fired once per row in a 50k-row export, with sorting on every fire, is measurable. If you're inside a loop, resolve the filtered value once outside it or cache the result.
Prefer binding a subclass over monkey-patching. If you want telemetry (which hooks fire, how often, which have no listeners), check whether the registry is bound as a singleton in the provider — then $this->app->singleton(...) with your own decorator in a provider that runs first. That's the clean extension point; editing the vendor directory after every composer update is not.
Constrain the version, and upgrade deliberately. 1.4.0 only changed composer.json (Laravel 12 support), so it's a safe bump — but that also means 1.4.0 is the release that dictates your framework/PHP resolution. Pin "millat/laravel-hooks": "^1.4", and if you support both Laravel 11 and 12, run your hook tests in CI against both resolutions so a future constraint change surfaces at install time, not at runtime.
Zero dependents is a statement about ecosystem, not quality. At 57 stars and no downstream packages, you are one of the first users integrating it with your stack. That means: read the source rather than trusting blog posts, and wrap the handful of helper calls you use in your own tiny Hooks wrapper class so that if the package stalls or the API shifts, you swap the implementation in one file instead of grepping the codebase. The helpers are global functions — a wrapper also keeps you from accidentally shipping a naming collision with another package that does the same thing.
How can I help you explore Laravel packages today?