Hooks, Menus and Routes
All three are registered from your provider's boot() method.
Hooks
Hooks work like WordPress. An action runs your code at a point in the page. A filter receives a value, may change it, and returns it.
// Action: output something
$context->hooks()->addAction('admin.layout.footer', fn ($request) => '<!-- hi -->', priority: 10, acceptedArgs: 1);
// Filter: change a value (note the 4th and 5th arguments: priority, number of arguments you accept)
$context->hooks()->addFilter('chat.visitor_message.before_store', function (string $text, $session) {
return str_replace('secret', '[hidden]', $text);
}, 10, 2);
Core code fires hooks like this:
$html = app(\App\Plugins\HookManager::class)->applyFilters('user.dashboard.nav.items', $groups, $request);
and Blade views fire actions with a directive:
@pluginHook('admin.layout.head', request())
Hook catalog
The framework fires six hooks. They are summarised here; the Hooks Reference explains each one in full, including its exact arguments, priority guidance and examples.
| Type | Hook | Fired in | Payload |
|---|---|---|---|
| action | admin.layout.head | backend/partials/header.blade.php | $request |
| action | admin.layout.footer | backend/admin-master.blade.php | $request |
| action | user.dashboard.before_content | client-dashboard/layout/app.blade.php | $request |
| action | user.dashboard.after_content | client-dashboard/layout/app.blade.php | $request |
| filter | user.dashboard.nav.items | UserDashboardNav::applyNavFilter() | array of nav groups, $request |
| filter | chat.visitor_message.before_store | ChatbotMessageRepository | message text, ChatbotSession. Return the text to store. |
chat.visitor_message.before_store runs before a visitor message is saved, so the stored transcript, the chat history and the AI provider all see your returned text. The Privacy & Compliance plugin uses it to mask card numbers.
A hook callback that throws is caught and logged. It never breaks the host page. See the Hooks Reference for the full argument and error-handling details.
Listening to Laravel events
Plugins can also use ordinary Laravel events. For example, CRM Sync reacts when a contact is created:
Event::listen('eloquent.created: '.\App\Models\Contact::class, function (\App\Models\Contact $contact) {
// queue work; never throw into the visitor's request
});
The Slack integration listens to MessageSent, MessageReceived, MessageSentByHumanAgent and ChatHandoffRequested. When your listener runs inside a visitor's request, catch exceptions and prefer queued jobs or app()->terminating() so a slow third party never delays the visitor.
Menus
use App\Plugins\Menu\MenuItem;
use App\Plugins\Menu\MenuPosition;
$context->menus()->add('user.dashboard.sidebar', new MenuItem(
id: 'myplugin.index',
label: 'My Plugin',
route: 'plugin.my-plugin.user.index', // or url: 'https://…'
icon: 'las la-cog',
position: MenuPosition::end(10),
badge: 'PRO', // optional small tag next to the label
));
MenuItem accepts id, label, route or url, icon, permission (a string or array; null means always visible), position, weight, children and badge.
| Location | Rendered by | Anchors available |
|---|---|---|
admin.sidebar.top | backend/partials/sidebar.blade.php, below Dashboard | core.dashboard, core.end |
admin.sidebar | backend/partials/sidebar.blade.php, above Log Out | core.end |
user.dashboard.sidebar | UserDashboardNav::pluginGroups(), grouped under "Plugins" | none (end only) |
Position helpers are MenuPosition::before('anchor', priority), MenuPosition::after('anchor', priority) and MenuPosition::end(priority). An unknown anchor falls back to core.end and logs a warning. Admin items are permission-checked for each viewer.
Routes
Routes are registered through $context->routes(). The framework applies the right middleware group, URL prefix and route-name prefix for you:
| Method | Middleware | URL prefix | Route name prefix |
|---|---|---|---|
admin(fn) | web, auth:admin, admin language and variables | plugins/{id} | plugin.{id}.admin. |
user(fn) | web, auth:web, email verified, user language and variables, maintenance mode | user/plugins/{id} | plugin.{id}.user. |
web(fn) | web | {id} | plugin.{id}.web. |
$context->routes()->user(function ($r) {
$r->get('/', [MyController::class, 'index'], 'index'); // plugin.my-plugin.user.index
$r->post('save', [MyController::class, 'save'], 'save'); // plugin.my-plugin.user.save
});
The third argument is the route name, added to the prefix shown above.
Public endpoints (webhooks)
Third-party services such as Slack call your site without a session. Register a bare route outside the groups and protect it yourself:
$context->routes()->post('my-plugin/webhook/{token}', [WebhookController::class, 'handle'], 'plugin.my-plugin.webhook')
->middleware('throttle:120,1');
A bare route has no CSRF or login protection. Always authenticate the request yourself (for example by verifying the provider's signature) and add a throttle.
Getting the current customer
Inside user-dashboard controllers, use the acting_client() helper to get the account owner (it also works when an admin is impersonating a customer). Always scope every query by that client's id so one customer can never see another's data:
$client = acting_client() ?? abort(403);
$items = Item::where('client_id', $client->id)->get();

