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 | |
|---|---|---|
| Registry | SlotRegistry | FilterRegistry |
| Data | Frozen at append() / replace() time — registration, usually your ServiceProvider::boot() | Passed fresh on every render — request time |
| Consumer sees | Exactly the $data your module passed at registration, nothing more | Whatever runtime arguments the core hands the hook (an entity, a job, a customer) |
| Typical use | Static content, optionally shown or hidden per user through a permission gate: a banner, a nav link, a sidebar panel | Content 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 name | Location | Use case |
|---|---|---|
admin.head.after | Inside <head>, after core CSS | Custom stylesheets, meta tags |
admin.sidebar.before-nav | Above navigation in sidebar | Module branding, notices |
admin.sidebar.after-nav | Below navigation in sidebar | Additional links, status info |
admin.content.before | Before main content area | Banners, warnings |
admin.content.after | After main content area | Footer widgets, debug info |
Driver layout (layouts/driver.blade.php)
| Slot name | Location | Use case |
|---|---|---|
driver.head.after | Inside <head>, after core CSS | Custom stylesheets |
driver.topbar.actions | In top bar, action area | Quick action buttons |
driver.content.before | Before main content | Notices, alerts |
driver.content.after | After main content | Additional features |
driver.bottom-nav.after | After bottom navigation | Extra nav items |
Portal layout (layouts/portal.blade.php)
| Slot name | Location | Use case |
|---|---|---|
portal.head.after | Inside <head>, after core CSS | Custom stylesheets |
portal.nav.after | After portal navigation items | Additional nav links |
portal.content.before | Before main content | Notices, seasonal info |
portal.content.after | After main content | Additional sections |
portal.footer.before | Before footer | Legal 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.