Skip to main content
WintertraceLive demo
Browse the documentation

Building modules

Template slots

Inject or replace markup at defined points in core views from a module, using the slot registry — without editing core templates.

Template slots are predefined injection points in the application’s layouts. A module can output its own Blade content at these points without touching the core views. That is the essential point: you extend the UI without editing files that would be overwritten on the next update — keeping upgrades conflict-free.

How slots work

A slot has two halves: the core layout marks the location, and your module registers content for it.

In Blade layouts (core)

The layout contains a directive at the designated location:

@extensionSlot('admin.content.after')

This directive internally calls SlotRegistry::render('admin.content.after', $user) and outputs the collected HTML. You do not write this directive yourself — it already exists in the core layouts. Your only task is to register content for the appropriate slot name.

In your module (registration)

Registration goes through the SlotRegistry, resolved in your ServiceProvider’s boot():

$slots = app(SlotRegistry::class);

// Append content (multiple modules can append to the same slot)
$slots->append('admin.content.after', 'my-module::my-partial', ['key' => 'value']);

// Or replace a slot entirely (last-wins)
$slots->replace('admin.content.before', 'my-module::replacement');

The distinction between append and replace is intentional: append is additive and cooperative — any number of modules can contribute to the same slot. replace substitutes the entire slot content and should be used sparingly.

@extensionSlot vs @filterSlot — picking the right one

Two directives look interchangeable for “inject HTML at a fixed point in a layout”. They are not.

@extensionSlot (this page)@filterSlot
RegistrySlotRegistryFilterRegistry
DataFrozen at append() / replace() time — registration, usually your ServiceProvider::boot()Passed fresh on every render — request time
Consumer seesExactly the $data your module passed at registration, nothing moreWhatever runtime arguments the core hands the hook (an entity, a job, a customer)
Typical useStatic content, optionally shown or hidden per user through a permission gate: a banner, a nav link, a sidebar panelContent that depends on which record is on screen right now

SlotRegistry::render(string $slotName, ?Authenticatable $user = null): string uses $user only to evaluate an optional permission gate. It renders View::make($entry['view'], $entry['data']) with exactly the $data your module passed at registration. $user itself is never merged into that data, so a slot partial that writes {{ $user->name }} fails with an undefined-variable error unless your module put a user key into $data itself.

The deeper reason is timing. By the moment your module called append() in boot(), the customer, job or form record on today’s request did not exist yet, so there is no way for a slot to carry it. FilterRegistry::apply(string $hook, mixed $value, mixed ...$context) has no such restriction: the core passes whatever context it has at the point it calls apply(), which is exactly where the record is known.

This is why the two admin-form hooks added in 1.2.0 — schneespur.admin.customer_object.form.after and schneespur.admin.driver.form.after — are filter hooks rather than slots.

The rule of thumb: if your module needs the record being edited as context, reach for @filterSlot. Reach for @extensionSlot only when a boot-time-frozen payload, optionally gated per user, is enough.

Available slots

The slots available depend on which layout is in use. Three layouts provide injection points: the admin layout, the driver layout, and the portal layout.

Admin layout (layouts/admin.blade.php)

Slot nameLocationUse case
admin.head.afterInside <head>, after core CSSCustom stylesheets, meta tags
admin.sidebar.before-navAbove navigation in sidebarModule branding, notices
admin.sidebar.after-navBelow navigation in sidebarAdditional links, status info
admin.content.beforeBefore main content areaBanners, warnings
admin.content.afterAfter main content areaFooter widgets, debug info

Driver layout (layouts/driver.blade.php)

Slot nameLocationUse case
driver.head.afterInside <head>, after core CSSCustom stylesheets
driver.topbar.actionsIn top bar, action areaQuick action buttons
driver.content.beforeBefore main contentNotices, alerts
driver.content.afterAfter main contentAdditional features
driver.bottom-nav.afterAfter bottom navigationExtra nav items

Portal layout (layouts/portal.blade.php)

Slot nameLocationUse case
portal.head.afterInside <head>, after core CSSCustom stylesheets
portal.nav.afterAfter portal navigation itemsAdditional nav links
portal.content.beforeBefore main contentNotices, seasonal info
portal.content.afterAfter main contentAdditional sections
portal.footer.beforeBefore footerLegal notices, links

Slot API

Appending with append

Multiple modules can append to the same slot. Content is rendered in order, controlled by the order parameter:

$slots->append(
    slotName: 'admin.content.after',
    viewPath: 'my-module::partials.info-panel',
    data: ['message' => 'Hello from module'],
    order: 100,           // sort order (lower = first)
    permission: 'settings.view', // optional: only render if user has this permission
);

order determines where your content appears in the slot when other modules also contribute — a lower value moves it earlier. This keeps the ordering predictable even when you do not know which other modules are active.

Replacing with replace

replace substitutes the entire slot content. If multiple modules replace the same slot, the last one registered wins — and a warning is logged. Use replace sparingly; the warning is your signal that two modules are conflicting.

$slots->replace(
    slotName: 'admin.sidebar.before-nav',
    viewPath: 'my-module::sidebar-header',
    data: [],
    permission: null,
);

Permission gating

If a permission is specified, the slot content is only rendered for users who have that permission. This restricts content to specific roles without repeating the check inside the view itself:

$slots->append('admin.content.after', 'my-module::admin-only', [], 100, 'settings.edit');
// Only visible to users with 'settings.edit' permission

Which permissions exist and how a module registers its own is covered in Permissions & roles.

Example: adding a banner

In your ServiceProvider’s boot(), register the partial …

// In your ServiceProvider boot()
$slots = app(SlotRegistry::class);
$slots->append('admin.content.before', 'my-module::banners.update-available', [
    'version' => '2.0.0',
], 50);

… and the corresponding Blade view renders the actual content:

{{-- resources/views/banners/update-available.blade.php --}}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
    <p class="text-blue-800">
        Module-Update {{ $version }} verfügbar.
    </p>
</div>

Values passed via data are available in the view as variables — here $version.

Example: adding a tab to the portal

Often two slots work together: one for the navigation link, one for the content it reveals. To add a dedicated section to the portal:

// Append navigation link
$slots->append('portal.nav.after', 'my-module::nav.documents-link');

// Append content section
$slots->append('portal.content.after', 'my-module::portal.documents-section');

Slots are just one of several extension points a ServiceProvider registers. How the SlotRegistry fits alongside the other registries in a module’s boot sequence is shown in The ServiceProvider pattern; the full registry API is in Registries.