Xgenious/ docs

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:

  1. 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.
  2. 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.

Note

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 @pluginHook directive.
  • 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);
});
MethodSignatureDefault 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.

TypeHookFired inArgumentsPurpose
actionadmin.layout.headbackend/partials/header.blade.php$requestAdd tags to the admin <head>.
actionadmin.layout.footerbackend/admin-master.blade.php$requestAdd markup and scripts before </body> on admin pages.
actionuser.dashboard.before_contentclient-dashboard/layout/app.blade.php$requestAdd content above every dashboard page.
actionuser.dashboard.after_contentclient-dashboard/layout/app.blade.php$requestAdd content below every dashboard page.
filteruser.dashboard.nav.itemsUserDashboardNav::applyNavFilter()nav groups array, $actingClientAdd, remove or reorder dashboard sidebar groups.
filterchat.visitor_message.before_storeChatbotMessageRepositorymessage text, ChatbotSessionChange 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')).'">';
});
Warning

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
});
Warning

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.

Tip

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.

Warning

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
MessageSentA visitor sent a message.
MessageReceivedThe AI produced a reply.
MessageSentByHumanAgentA staff member sent a reply.
ChatHandoffRequestedA 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);
    }
});
Warning

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']);
Tip

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 acceptedArgs against the parameter list. This is the most common mistake.
  • Filter change ignored? Confirm you return the value. A filter callback that returns null replaces the value with null.
  • 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 failed or Plugin hook filter failed in the log, then the plugin's own lines in storage/logs/plugins.log.
Still stuck?
Our support team is ready to help you get set up.
Get support