Self-hosted email marketing with full source code. Pay once, own forever. Get AcelleMail — $74 →

Mount a chatbox, a sidebar group, or a page card — without forking a Blade.

The host application reserves four kinds of slot for plugins to render into: three layout-level slots that fire on every page that extends the master app / admin layouts, plus a per-page slot for finer-grained injection. All four use the REGISTRY pattern — every plugin that registers contributes an HTML string (or null to skip), and the host iterates the array, filters falsy returns, and emits each fragment unescaped. This page covers the contract, the args-bag, the canonical acelle/ai implementations, and the anti-patterns that look right but break in production.

Why UI injection exists

A plugin that adds a feature usually needs to surface that feature somewhere in the host UI. The naïve options are bad in different ways: forking the host's Blade layouts forces the plugin to maintain its own copy through every host upgrade; patching them at install time leaves the host's source out of sync with what runs in production. Both lock the plugin and the host into a maintenance burden that grows with every release.

The plugin system avoids that trade-off by reserving named slots inside the host's master layouts. Each slot is a REGISTRY hook — every plugin that registers contributes an HTML string, the host collects them at render time, filters out the falsy contributions, and emits each fragment in registration order. Plugins never see the host's Blade source; the host never knows which plugins contributed what.

The three layout slots

Three REGISTRY hooks fire from the master app and admin layouts. Together they cover almost every UI extension a plugin will ever need — head-of-document assets, body-end widgets, and admin sidebar groups.

Hook keyWhere the call site livesArgs bagUsed for
layout.head.assets resources/views/refactor/layouts/{app,admin}.blade.php, just before [$layout, $context] <link> / <style> / <script> tags that must load before page-specific content — chatbox CSS, sparkle popover scripts
layout.body.before_close Same files, just before </body> [$layout, $context] Floating widgets that mount once per page — chatbox bubble, modal overlays, sparkle popovers
admin.sidebar.groups resources/views/refactor/components/nav/admin-sidebar.blade.php (no args) Plugin-contributed admin sidebar sections — every entry renders as a <div class="mc-nav-group">...</div> fragment

All three are collected through the same idiom on the host side. The Blade snippet that ships in resources/views/refactor/layouts/admin.blade.php reads:

@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
    {!! $html !!}
@endforeach

Three things to read out of that snippet: collect takes an args bag (here ['admin']), array_filter drops every null / false / '' contribution, and the surviving HTML is emitted with {!! !!} — unescaped, because it is already-rendered Blade.

The contract — return HTML or null

A REGISTRY callback for any of the three layout slots returns one of two things:

  1. An HTML string — typically the result of view('myname::partials.foo')->render(). The host emits it verbatim with {!! !!}.
  2. null (or any falsy value — false, '', 0). The host's array_filter drops it. This is the conventional way to gate a contribution by feature flag, plugin status, environment, or per-request context.

Returning null is preferred over not registering at all. Plugin boot() runs once per process; deciding whether to contribute should happen on every render, not at boot. The aiPluginAvailable() check in storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 is the canonical example — the closure short-circuits to null whenever the AI module is gated off, leaving every other plugin's contribution untouched.

Returned HTML must be self-contained. The host drops the fragment into the document at the call site without additional escaping or wrapping. Anything the fragment depends on — CSS, JS, font files — has to already be loaded by the time the rendering happens. That is why layout.head.assets exists in addition to layout.body.before_close: head fragments load first, body fragments mount last, and the plugin can split its asset registration across both slots when it needs to.

The args-bag — $layout and $context

layout.head.assets and layout.body.before_close both pass two positional args: $layout (a string identifying which master layout fired the slot — 'app', 'admin', etc.) and $context (an optional array carrying surface-specific props the layout chooses to expose).

// Plugin (in ServiceProvider::boot())
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
    if (! $this->aiPluginAvailable()) {
        return null;
    }

    return view('ai::partials.head_assets', [
        'layout'  => $layout,
        'context' => $context,
    ])->render();
});

The single shared hook key fires from every master layout — app, admin, the email builder, the form builder, the automation editor. The plugin's partial dispatches internally on $layout to render the right asset set or chatbox configuration. There is no separate layout.app.head.assets / layout.admin.head.assets hook; the layout name is just a discriminator inside one shared bag.

Adding more positional args to an existing layout slot would break every plugin that already binds a closure with the original arity. New context belongs in the $context array (which can grow without changing the closure signature), or behind a separate hook key. The host's own aiHooks contributions handle this exactly — the builder and automation editor pass surface props through $context, and the plugin reads $context['kind'], $context['task'], etc., when present.

Worked example — the acelle/ai chatbox bubble

The canonical reference for layout injection lives at storage/app/plugins/acelle/ai/src/ServiceProvider.php, lines 678-728. The plugin contributes to all three layout slots, each behind the same aiPluginAvailable() gate. The full registration block, paraphrased to the contract above:

// In acelle/ai's ServiceProvider::boot()

private function registerLayoutInjections(): void
{
    Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.head_assets', [
            'layout'  => $layout,
            'context' => $context,
        ])->render();
    });

    Hook::add('layout.body.before_close', function ($layout = 'app', array $context = []) {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.body_assets', [
            'layout'  => $layout,
            'context' => $context,
        ])->render();
    });
}

private function registerAdminSidebarSection(): void
{
    Hook::add('admin.sidebar.groups', function () {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.admin_sidebar_group')->render();
    });
}

What lands in production from those three blocks: every page that extends the app or admin layout gets the chatbox CSS / JS in <head>, the chatbox bubble HTML before </body>, and (on admin pages only) an "AI" sidebar group rendered after the host's built-in groups. None of the host's Blade files were modified to make any of that work — the plugin contributes through the shared hook keys and the host renders whatever lands.

The plugin gates the contribution with aiPluginAvailable(), which checks ai_plugin_active() — a helper that ultimately resolves to Plugin::getByName('acelle/ai')->isActive(). When an admin disables the plugin from the admin Plugins page, every callback returns null on the next request, and the chatbox + sparkle UI disappears — without reloading routes, dropping registered services, or invalidating any cache.

admin.sidebar.groups is the simplest of the three layout hooks: no args, the callback returns a self-contained <div class="mc-nav-group">...</div> fragment. The host renders it after the built-in groups (Customers, Plans, Settings, ...) and before any closing layout. Order is registration order, so plugins that need to win render position should register late in boot() after dependencies.

The acelle/ai sidebar group lives at resources/views/partials/admin_sidebar_group.blade.php inside the plugin and renders an "AI" group with three or four children depending on plan flags. The same pattern works for any plugin that needs a top-level admin section — a loyalty-points plugin, a payment-gateway plugin, a regional sending-driver plugin.

Page-level slots — page.{controller}.{action}.{slot}

Layout-level injection covers what should appear on every page; page-level slots cover what should appear on a specific one. The naming convention makes the binding explicit:

page.{controller_slug}.{action}.{slot}

Examples:

  • page.maillist.show.body — extra cards rendered inside the mail-list detail page's body
  • page.maillist.verification.body — content rendered above the verification status block
  • page.campaign.index.sidebar — sidebar additions on the campaign index page
  • page.customer.edit.footer — footer additions on the customer edit page

The host's call site looks the same as the layout slots — collect, array_filter, emit each fragment with {!! !!}:


@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
    {!! $html !!}
@endforeach

And the plugin contribution looks just like a layout-slot one, with the slot-specific args bag passed through:

// Plugin (in ServiceProvider::boot())
Hook::add('page.maillist.show.body', function ($list) {
    $points = LoyaltyPoints::getTotalForList($list);
    return view('loyalty::list_points_card', [
        'list'   => $list,
        'points' => $points,
    ])->render();
});

Args bags for page-level slots tend to carry the relevant model — the mail list, the campaign, the customer — so the plugin can read whatever it needs without an extra database query. Following that convention keeps the hook signature stable through host model evolutions: adding a new field to MailList does not break any plugin's hook signature, because the plugin always receives the model.

Page redirects — the FILTER variant

A handful of page hooks use the FILTER pattern instead of REGISTRY — the typical case is "let plugins decide whether the user should be redirected before the core renders". The contract:

// Core controller
$redirect = \App\Library\Facades\Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
    return $redirect;
}
// continue rendering the page

// Plugin (in ServiceProvider::boot())
Hook::modify('page.maillist.show.redirect', function ($redirect, $list, $request) {
    if (MyPlugin::shouldRedirect($list, $request->user())) {
        return redirect()->route('my_plugin.custom_page', $list->uid);
    }
    return $redirect; // null means "do not redirect"
});

The shape is FILTER (chained transform of a value) rather than REGISTRY (multiple independent contributions) because only one redirect can happen per request. Returning the unchanged input from the closure is the conventional opt-out — the next plugin in the chain sees whatever the previous one decided. The first non-null value wins because the controller checks if ($redirect) after the filter chain finishes.

This pattern is what athena/evs uses to route the user to its own verification page when the email-verification plugin needs to take over the maillist verification surface. The full mechanic of FILTER lives in the Hook system deep-dive; the practical takeaway here is that page-level redirects use FILTER, while page-level rendering uses REGISTRY.

Asset publishing — bundling CSS / JS / images with the plugin

Layout slots are about where a plugin renders. Asset publishing is about what the rendered HTML can reference. Plugins that ship CSS, JavaScript, fonts, or images use Laravel's standard publishes() API, with the 'plugin' tag the host knows about:

// In ServiceProvider::boot()
$this->publishes([
    __DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');

On every Plugin::register(), the host runs artisan vendor:publish --tag=plugin --force, which copies the plugin's resources/assets/ tree into public/plugins/{vendor}/{name}/. The plugin's own partials reference assets through that path:


<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">

The icon-serving route from routes.php (the plugin.{vendor}.{name}.icon named route) is the alternative — instead of publishing a static SVG into public/, the plugin can expose an HTTP route that streams its icon directly out of storage/app/plugins/{vendor}/{name}/icon.svg. The trade-off: the published path is faster (CDN-cacheable) but requires a publish on every install; the routed path is self-contained but pays a Laravel boot per request.

Anti-patterns

1. Returning a Blade View object instead of a string

The host emits whatever the closure returns with {!! !!}. Returning view('foo') emits the object's __toString, which works most of the time but loses the chance for the closure to handle render errors gracefully. Fix: always call ->render() and return the resulting string, exactly as the acelle/ai registrations do.

2. Forgetting the gating null branch

A REGISTRY callback that always returns HTML keeps contributing whether the plugin is active or not — because autoloadWithoutDbQuery() loads inactive plugins too (see Plugin architecture § Why inactive plugins still affect the app). Fix: guard with Plugin::enabled('myvendor/myplugin') or a feature-flag helper at the top of every closure, return null when gated off.

3. Calling collect() with extra args that the host did not pass

Plugins do not call Hook::collect on their own — the host does. If a plugin needs the layout name for a custom decision, it reads it from the closure's first arg. Trying to Hook::collect a layout slot from inside a plugin runs every other plugin's callback an extra time. Fix: the closure receives all the args it needs; never re-invoke collect from inside a registered handler.

4. Rendering blocking work inside the closure

The closure runs once per page render — a Stripe API call or a 200ms DB query inside it adds that cost to every request. Fix: precompute, cache, or move to an async loader. The fragment can return a <div data-async-loader> placeholder that the plugin's JS hydrates from a background fetch.

5. Side effects in a REGISTRY callback

collect calls every callback in registration order. A callback that writes to a session, cache, or log as a side effect makes the hook non-deterministic. Two plugins might race to mutate the same key. Fix: keep add callbacks pure — they exist to contribute a value, not to do work. If you need a side effect, register an EVENT listener on a separate hook.

6. Overlapping CSS scopes

Two plugins both inject CSS through layout.head.assets; both define a class named .mc-popover. The order plugins are loaded is the order their CSS lands, and last-wins. Fix: namespace plugin CSS classes (.acmecorp-loyalty-popover), or scope with an attribute selector on a wrapper element. The host does not police plugin CSS — that is the plugin author's discipline.

Where to go next

UI injection is the most-asked surface for new plugin authors, but it is rarely the whole feature. Three pages take the same toolkit further:

  • Database & models — when a plugin needs its own tables, vendor-prefixed and isolated from the host schema, with migrations that run on activate and roll back on delete.
  • Sending drivers — the worked example for shipping a brand-new MTA backend as a plugin (Postal MTA), combining REGISTRY (driver registration) with the layout hooks for any per-driver UI.
  • The acelle/ai showcase — the full canonical plugin walked end-to-end. Every UI injection covered above is exercised in that one codebase.