Architecture

Laravel Hooks and Filters: Extend Lara Dashboard Without Forking

By Lara Dashboard 9 views
Laravel Hooks and Filters: Extend Lara Dashboard Without Forking
Lara Dashboard has a WordPress-style hook system, so you can change how the core behaves without editing core files. Actions let your code react when something happens. Filters let your code change a value before the core uses it. You register both in a module's service provider, and your changes survive every core upgrade.
Disclosure: Lara Dashboard is our open-source, MIT-licensed Laravel CMS and admin. This is our own guide. Every class, enum and file name below comes from the repo's hooks documentation and the code in app/, as of November 2026 (v1.5.0, on Laravel 13 and PHP 8.3+).

Quick answer: hooks are how a module changes the core without a fork

If you fork a CMS to change one validation rule or add one menu item, you own that fork forever. Every upgrade becomes a merge. Hooks avoid that. The core calls a named hook at a known point, and any enabled module can attach code to it.
In Lara Dashboard, that looks like this:
Hook::addFilter(PostFilterHook::POST_STORE_VALIDATION_RULES, fn (array $rules) => [...$rules, 'excerpt' => 'required|string|max:160']);
That one line, inside your module's service provider, makes the excerpt required when someone creates a post in the admin. No core file changes. Disable the module and the rule is gone.
The system is built on the Eventy package (tormjens/eventy in composer.json). Lara Dashboard wraps it in a Hook facade and a set of PHP enums, so hook names are typed instead of loose strings.

Actions react, filters change values

There are two kinds of hook, and mixing them up is the most common first mistake.
Actions. Fire and forget. The core says "this just happened" and passes some context, such as the new user. Your listener can log, notify, queue a job or sync to another system. Its return value is ignored. You listen with Hook::addAction().
Filters. The core passes a value through every listener in turn, and uses whatever comes out the other end. Your listener must return the value, changed or not. You listen with Hook::addFilter().
If you have written WordPress plugins, this will feel familiar. The mapping is close to one to one:
JobWordPressLara Dashboard
Listen to an actionadd_action()Hook::addAction() or ld_add_action()
Fire an actiondo_action()Hook::doAction() or ld_do_action()
Listen to a filteradd_filter()Hook::addFilter() or ld_add_filter()
Apply a filterapply_filters()Hook::applyFilters() or ld_apply_filters()
Remove a listenerremove_action() / remove_filter()Hook::removeAction() / Hook::removeFilter()
Hook namesStringsPHP enum cases (strings still work)
Default priority1020
The default priority is the one detail that catches people. In Lara Dashboard it is 20, set in app/Support/HookManager.php. As in WordPress, a lower number runs earlier. If you want to run before the defaults, use a priority under 20.
The official WordPress hooks handbook is still a good mental model. The difference is what sits around the hooks: Laravel models, policies, migrations and tests, instead of a global plugin space. We wrote more about that difference in modular Laravel apps vs WordPress plugin spaghetti.

Enums make hook names safe to refactor

Every core hook is a case on a backed enum in app/Enums/Hooks. There are enums per area: posts, users, roles, permissions, terms, media, modules, settings, email, notifications, auth, dashboard, datatables, admin UI, AI and MCP. Each area usually has an ActionHook and a FilterHook enum, such as PostActionHook and PostFilterHook.
Each case maps to a plain string. PermissionFilterHook::PERMISSION_GROUPS is filter.permission.groups. The Hook facade and the ld_* helpers accept either the enum or the string, and convert the enum to its value before calling Eventy.
Use the enum. Your IDE autocompletes it, static analysis catches typos, and a renamed hook fails loudly instead of silently never firing. A typo in a string hook name is the worst kind of bug: no error, just nothing happens.

Register hooks in your module's service provider

Hooks belong in the boot() method of your module's service provider, or in a class it calls. The module scaffold already follows this pattern. When you run php artisan module:make, the generated ModuleService adds the module's menu through AdminFilterHook::ADMIN_MENU_GROUPS_BEFORE_SORTING and its permissions through PermissionFilterHook::PERMISSION_GROUPS. We walk through that scaffold in build a Laravel module step by step.
Because the hooks live in the module, they only run while the module is enabled. That is the whole point. Turning a feature off is one click, not a code revert.

Five hooks that cover most real extensions

The core ships many hooks. In practice, a handful do most of the work. All five below are called from core code on the develop branch, which we checked before writing this.
1. Add permissions with PERMISSION_GROUPS. PermissionService builds the core permission groups, then passes them through this filter. Append your own group with a group_name and a permissions array, for example invoice.view and invoice.create. They then appear on the role screen like any core permission. Our role-based access control guide covers what to do with them next.
2. Tighten validation with POST_STORE_VALIDATION_RULES. StorePostRequest passes its rules array through this filter, and UpdatePostRequest has a matching update filter. The user form requests have the same pair. This is the cleanest way to enforce house rules, such as a required excerpt or a maximum title length, without touching the form request.
3. React to new users with USER_CREATED_AFTER. The admin UserController fires this action after it creates a user, passing the user model. Use it to create a CRM contact, queue a welcome job or write an audit entry. Keep the listener short and push slow work onto a queue.
4. Add a field to a form with POST_FORM_AFTER_CONTENT. The post form calls this filter with an empty string and prints whatever comes back. Return a rendered Blade partial, and your field appears under the content editor. Many admin screens have similar slots: after breadcrumbs, before and after tables, after specific form fields.
5. Register MCP tools with McpFilterHook::TOOLS. McpRegistryService passes its list of tool classes through this filter. A module can append its own tool class, and AI clients such as Claude can then call it. A related filter, BRIEFING_PROVIDERS, lets a module add items to the daily briefing. We covered the MCP side in AI agents on your CMS.
Dashboard widgets work the same way. DashboardWidgetService collects module widgets through DashboardFilterHook::DASHBOARD_WIDGETS, and a widget can declare a permission so only the right roles see it.

Fire your own hooks so other modules can extend yours

Hooks are not only for the core. If your module might be extended later, by you or by someone else, give it hooks of its own.
The pattern from the docs is simple. Create an enum in your module, such as Modules\Invoices\Enums\Hooks\InvoiceActionHook, with cases like INVOICE_PAID_AFTER = 'action.invoices.paid_after'. Prefix the string with your module name so it never clashes with the core or another module.
Then call the hook from your service, not your controller. For example, Hook::doAction(InvoiceActionHook::INVOICE_PAID_AFTER, $invoice); in the method that marks an invoice paid. Firing from the service means the hook runs whether the change came from the admin, an API call or a queued job.
Document every hook you add: what it passes, what a filter must return, and when it fires. An undocumented hook is a hook nobody will use.

Gotchas we want you to know before you rely on a hook

Hooks are simple, but a few details matter. These come straight from reading the code.
A filter must return its value. If your filter forgets the return, the next listener and the core get null. A missing return on the permission groups filter can empty the role screen.
Set accepted_args for extra arguments. Some filters pass more than the value. RoleFilterHook::ROLE_FORM_AFTER_NAME passes the HTML and the role. Your listener only receives the second argument if you register it with accepted_args: 2.
Prefer the facade for multi-argument actions. Hook::doAction() spreads multiple arguments to the listener. The ld_do_action() helper, when given more than one argument, passes them as a single array. Both work, but your listener has to match. We recommend the facade everywhere for consistency.
Check the hook fires on your code path. A hook only runs where the core calls it. On the develop branch, the post and user "created" actions are fired from the admin controllers. Posts created another way, such as the MCP create-post tool, which uses PostService directly, do not pass through that controller. The hooks reference also lists a few hooks, such as POST_PUBLISHED_AFTER, that we could not find a call site for in core as of this writing. Before you depend on any hook, search the repo for where it is applied. If you need a side effect on every save, a Laravel model observer or event may be the safer tool.
Escape the HTML you return. UI filters print their result unescaped with {!! !!}, because they expect markup. That makes your partial responsible for escaping. Render Blade views with {{ }} for any user data, and never return raw input.

When not to use a hook

Hooks are our recommended extension point, but they are not the answer to everything.
  • If the logic is part of your own module's domain, just call your own service. A hook adds indirection you do not need.
  • If the change must happen on every save, from every path, a model observer or a Laravel event is more reliable than a controller-level hook.
  • If you need a brand-new screen with its own data, build a module with routes and a CRUD, not a pile of UI filters.
  • If you find yourself replacing whole core screens through filters, talk to us on GitHub. A new extension point may be the cleaner fix.
  • If two modules both filter the same value, order matters. Set explicit priorities, and test both modules enabled together.
  • If you only need content, such as a landing page, LaraBuilder pages may need no code at all.
Hooks also make behaviour harder to trace than a direct call. When a value looks wrong, the cause might be any enabled module. Keep listeners small, named and documented, and the system stays easy to debug. Our post on custom post types vs Laravel models explains why we lean on plain models for product data and keep hooks for extension.

Self-check before you ship a hook

  1. The hook is registered in the module service provider, not in a route file or a view.
  2. You used the enum case, not a hand-typed string.
  3. You searched the core for where the hook is fired, and it fires on the path you care about.
  4. Every filter returns its value on every branch.
  5. accepted_args matches the number of arguments you read.
  6. The priority is set on purpose if order matters.
  7. Any HTML you return is escaped and rendered from a Blade view.
  8. Slow work in an action is queued, not run inline.
  9. Your own custom hooks use a module prefix and are documented.
  10. A feature test covers the behaviour with the module enabled and disabled.

Verdict

Hooks and filters are how Lara Dashboard stays upgradeable while still letting you bend it to a client's needs. Add permissions, tighten validation, react to events, add form fields and register AI tools, all from a module, with no fork to maintain. The enum-based names make it safer than the string hooks WordPress developers are used to.
They are not magic. Check where a hook fires, return your filter values, and reach for observers or events when a change must happen everywhere. If that sounds like the right balance for your next build, try Lara Dashboard, scaffold a module, and add your first filter in its service provider.

FAQ

What package powers Lara Dashboard hooks?

Eventy, an open-source action and filter package for Laravel. Lara Dashboard wraps it in the App\Support\Facades\Hook facade and a HookManager class, and adds ld_add_action, ld_do_action, ld_add_filter and ld_apply_filters helpers that accept enum cases.

What is the difference between an action and a filter?

An action tells listeners that something happened and ignores their return values. A filter passes a value through each listener and uses the final result, so every filter listener must return the value.

What is the default hook priority?

20. Lower numbers run earlier. Pass a priority below 20 to run before default listeners, or a higher one to run after them.

Can I use string hook names instead of enums?

Yes. The facade and helpers accept strings such as filter.permission.groups. We recommend the enum cases because typos then fail at analysis time instead of silently doing nothing.

Do hooks run when a module is disabled?

No. Hooks are registered when the module's service provider boots, and disabled modules do not boot. That is why hooks are a clean way to make a feature switchable.

Should I use a hook or a Laravel event?

Use a hook to extend a specific point the core exposes, such as validation rules, menus or form slots. Use Eloquent observers or Laravel events when the behaviour must happen on every save, whichever path created the record.
Laravel CMS Open source Module Architecture WordPress

Try Lara Dashboard for Free

Explore every feature live — no sign-up required.

Launch Live Demo