Hooks Reference
This is the complete reference for extending HelpNest from a plugin. There are two ways to react to what happens in the app:
- Plugin hooks — a WordPress-style action and filter system, registered through
$context->hooks(). Best for injecting markup into a layout and for changing a value the core is about to store. - Laravel events and model events — the app already dispatches events (a message was sent, a contact was created). Best for reacting to something that happened, usually by queueing a job. See Laravel events.
Both are covered below.
Hooks are registered from your provider's boot() method, and only while your plugin is active. See Anatomy and Manifest for the provider contract.
How hooks work
A hook is a named point in the app. Your plugin attaches a callback to that name. When the app reaches the point, it runs every callback for that name in priority order.
- An action lets you run code and produce output. It does not return a value. Actions are fired with
doAction()and rendered in Blade with the@pluginHookdirective. - A filter receives a value, optionally changes it, and must return the new value. Filters are fired with
applyFilters().
Every callback that any active plugin registers is merged into one shared App\Plugins\HookManager at boot, so all plugins' callbacks run at every matching hook.
Registering a callback
// Action: up to 1 argument (the default). Priority 10; lower runs first.
$context->hooks()->addAction('admin.layout.head', function ($request) {
echo '<meta name="x-my-plugin" content="1">';
});
// Filter: 2 accepted arguments (the default for addFilter):
// the value being filtered, plus one extra argument.
$context->hooks()->addFilter('chat.visitor_message.before_store', function (string $text, $session) {
return str_replace('secret', '[hidden]', $text);
});
| Method | Signature | Default arguments |
|---|---|---|
addAction($hook, callable, int $priority = 10, int $acceptedArgs = 1) | Runs side effects. | 1 |
addFilter($hook, callable, int $priority = 10, int $acceptedArgs = 2) | Receives and returns a value. | 2 |
Priority and ordering
Callbacks run from the lowest priority number to the highest, so priority 5 runs before priority 10. When two callbacks share a priority, they run in registration order. Use priority to run before or after another plugin, for example priority 5 to scrub text before a plugin that logs it at 20.
acceptedArgs
acceptedArgs caps how many arguments your callback receives:
- For actions, the number of arguments the firing code passes is sliced to
min(acceptedArgs, argumentCount). - For filters, the value is always the first argument; the extra arguments are sliced to
min(acceptedArgs, extraArguments + 1).
Set acceptedArgs to exactly the number of parameters your callback declares. A callback that declares two parameters but is registered with acceptedArgs: 1 receives only one and will fail on the missing second.
Error isolation
A callback that throws is caught and logged and never breaks the page. The framework logs a warning (Plugin hook action failed or Plugin hook filter failed) with your plugin id, the hook name and the error message. Despite this safety net, write callbacks that cannot throw into a visitor's request: validate input, wrap third-party calls, and prefer queued jobs for slow work.
Your own log lines go to a dedicated channel that writes to storage/logs/plugins.log:
$context->logger()->info('Delivered lead', ['contact_id' => $contact->id]);
Firing a hooked point in Blade
Blade views fire actions with the @pluginHook directive. The second argument is passed straight through as the hook's first argument:
@pluginHook('admin.layout.head', request())
The hook catalog
This is the complete set of hooks the framework fires today.
| Type | Hook | Fired in | Arguments | Purpose |
|---|---|---|---|---|
| action | admin.layout.head | backend/partials/header.blade.php | $request | Add tags to the admin <head>. |
| action | admin.layout.footer | backend/admin-master.blade.php | $request | Add markup and scripts before </body> on admin pages. |
| action | user.dashboard.before_content | client-dashboard/layout/app.blade.php | $request | Add content above every dashboard page. |
| action | user.dashboard.after_content | client-dashboard/layout/app.blade.php | $request | Add content below every dashboard page. |
| filter | user.dashboard.nav.items | UserDashboardNav::applyNavFilter() | nav groups array, $actingClient | Add, remove or reorder dashboard sidebar groups. |
| filter | chat.visitor_message.before_store | ChatbotMessageRepository | message text, ChatbotSession | Change a visitor message before it is stored and sent to the AI. |
The sections below describe each hook in detail.
admin.layout.head
Type: action · Fired in: resources/views/backend/partials/header.blade.php · Arguments: $request (Illuminate\Http\Request)
Runs inside the admin layout's <head>, so it fires on every admin page. Use it for admin-only stylesheets, meta tags or configuration scripts.
$context->hooks()->addAction('admin.layout.head', function ($request) {
// Only load on the pages that need it.
if (! $request->routeIs('plugin.my-plugin.admin.*')) {
return;
}
echo '<link rel="stylesheet" href="'.e(asset('plugin-assets/my-plugin/css/admin.css')).'">';
});
Output is echoed straight into the page head. Escape anything dynamic with e() or htmlspecialchars(), and never echo request input.
admin.layout.footer
Type: action · Fired in: resources/views/backend/admin-master.blade.php · Arguments: $request
Runs just before </body> on admin pages, after the app scripts. Use it for admin JavaScript, modals or a floating helper button. The same escaping rule applies.
$context->hooks()->addAction('admin.layout.footer', function ($request) {
if (! $request->routeIs('plugin.my-plugin.admin.*')) {
return;
}
echo '<script src="'.e(asset('plugin-assets/my-plugin/js/admin.js')).'" defer></script>';
});
user.dashboard.before_content
Type: action · Fired in: resources/views/frontend/client-dashboard/layout/app.blade.php · Arguments: $request
Runs just inside the customer dashboard's content area, above the page's own content. Use it for a site-wide banner, a plan-limit notice or an announcement. It fires on every dashboard page, so keep the output light and check the route if it should only appear somewhere specific.
user.dashboard.after_content
Type: action · Fired in: resources/views/frontend/client-dashboard/layout/app.blade.php · Arguments: $request
Runs below the page content. Use it for a floating helper, a footer script or a widget. Same escaping and route-checking guidance as above.
user.dashboard.nav.items
Type: filter · Fired in: App\Services\UserDashboardNav::applyNavFilter() · Value: the customer dashboard's menu groups · Extra argument: the acting client ($actingClient)
This lets a plugin add, remove or reorder dashboard sidebar groups and items. The value is an associative array of groups, each with a label, an icon, a collapsible flag and an items list. Every item has a label, a route (a full URL) and an icon.
$context->hooks()->addFilter('user.dashboard.nav.items', function (array $groups, $client) {
// Add a group of links for this customer.
$groups['my_tools'] = [
'label' => __('My Tools'),
'icon' => 'puzzle',
'collapsible' => true,
'items' => [
['label' => __('Reports'), 'route' => route('plugin.my-plugin.user.index'), 'icon' => 'chart'],
],
];
// Hide a group for a specific customer.
if ($client && $client->id === 42) {
unset($groups['billing']);
}
return $groups; // always return the array
});
Always return the (possibly modified) array. If two plugins filter this hook, the second receives whatever the first returned, so never assume the input shape is untouched. Try to modify namespaced keys rather than reordering everything.
For a normal single menu item you do not need this hook at all — use $context->menus()->add('user.dashboard.sidebar', new MenuItem(...)). Reach for this filter only when you need to change or hide groups that core or another plugin created.
chat.visitor_message.before_store
Type: filter · Fired in: App\Repositories\ChatbotMessageRepository · Value: the visitor's message text (string) · Extra argument: the ChatbotSession
Runs before a visitor message is saved. The returned string is what gets stored, what the conversation history shows, and what is sent to the AI provider. This makes it the right place to mask sensitive data or normalise input.
$context->hooks()->addFilter('chat.visitor_message.before_store', function (string $text, $session) {
// Mask a card number the visitor should not have typed.
return preg_replace('/\b(?:\d[ -]*?){13,19}\b/', '[CARD]', $text);
}, priority: 5);
The Privacy & Compliance plugin uses this hook to mask card numbers, US Social Security numbers, emails and phone numbers.
Return a string. The stored message, the AI provider and the chat history all see the returned value, so a mistake here changes what your records and your bot contain. Keep the original available if your business needs it, and prefer a low priority so your masking runs before plugins that read the text.
Laravel events and model events
When you need to react to something that already happened, the app dispatches ordinary Laravel events. Register listeners in boot() with Event::listen. This is the same mechanism the first-party plugins use.
Domain events
The app dispatches these whenever a message moves through a conversation:
Event (App\Events\…) | Fires when |
|---|---|
MessageSent | A visitor sent a message. |
MessageReceived | The AI produced a reply. |
MessageSentByHumanAgent | A staff member sent a reply. |
ChatHandoffRequested | A visitor asked for a human. |
Integrations Hub listens to all four to mirror chats into Slack, Discord and Telegram:
use App\Events\ChatHandoffRequested;
use App\Events\MessageReceived;
use App\Events\MessageSent;
use App\Events\MessageSentByHumanAgent;
use Illuminate\Support\Facades\Event;
Event::listen(MessageSent::class, [MySync::class, 'visitorMessage']);
Event::listen(MessageReceived::class, [MySync::class, 'aiReply']);
Event::listen(MessageSentByHumanAgent::class, [MySync::class, 'humanReply']);
Event::listen(ChatHandoffRequested::class, [MySync::class, 'handoff']);
Model events
Any Eloquent model can be observed. CRM Sync reacts when a lead becomes a contact:
use App\Models\Contact;
// Contacts are created inside the visitor's chat request: nothing here may throw into it.
Event::listen('eloquent.created: '.Contact::class, function (Contact $contact) {
// Create a delivery row, then queue the actual send.
DeliverContact::dispatch($contact->id);
});
Chat Translation does the same to translate a staff reply after it is stored:
use App\Models\ChatbotMessage;
use App\Enums\ChatMessageSenderType;
ChatbotMessage::created(function (ChatbotMessage $message) {
if ($message->sender_type === ChatMessageSenderType::Human) {
TranslateOutboundMessage::dispatch($message->id);
}
});
These listeners run inside the visitor's request. Never call a third party synchronously and never let an exception escape — catch it and log it. Do the slow work in a queued job (ShouldQueue) so a slow Slack, CRM or translation provider can never delay the visitor.
Firing your own hooks
A plugin can expose its own extension points so other plugins can extend it. The shared HookManager is available from the container, so any hook name works:
use App\Plugins\HookManager;
// Somewhere in your plugin, let others change a value:
$payload = app(HookManager::class)->applyFilters('my-plugin.payload.build', $payload, $contact);
// Or announce a moment:
app(HookManager::class)->doAction('my-plugin.after_import', $source);
Other plugins then extend you with the same API:
$context->hooks()->addFilter('my-plugin.payload.build', fn (array $payload) => $payload + ['source' => 'helpnest']);
Namespace your hook names with your plugin id (my-plugin.…) so they can never clash with core or another plugin.
Debugging hooks
- Nothing runs? Confirm the plugin row is active (
php artisan plugins:enable my-plugin). Hooks from inactive plugins are never registered. - Wrong number of arguments? Check your
acceptedArgsagainst the parameter list. This is the most common mistake. - Filter change ignored? Confirm you return the value. A filter callback that returns
nullreplaces the value withnull. - Order is wrong? Lower the priority number to run earlier. Inspect the full set of registered callbacks with
app(\App\Plugins\HookManager::class)->getAllActions()and->getAllFilters(). - A callback threw? Look for
Plugin hook action failedorPlugin hook filter failedin the log, then the plugin's own lines instorage/logs/plugins.log.

