Header Actions
Header actions let a module contribute small Livewire components to the header of every list and every detail view — for example an icon rendered next to the search field on every list. The core knows nothing about the individual actions: it only mounts what modules registered.
Concept
- Separate slots for lists and details. The registry keeps two independent lists: list actions render in the list header controls (injected by
modal-titleviatable/list-controlsfor everyNoerdListhost — including custom header slots), detail actions render in the detail header (modal-title). An action that should appear in both contexts must be registered twice — there is no shared slot. - One action, one function, one Livewire component. Every action is its own minimal Livewire component. It renders exactly one button (or nothing) and contains no logic for the other context.
- Actions own their visibility. The core always mounts every registered action. The action itself decides in
mount()whether it has something to show (permissions, current app, available configuration) and renders an empty root when hidden.
Registering Actions
Register from your module service provider's boot() via the HeaderActionsRegistry singleton (noerd core):
use Noerd\Services\HeaderActionsRegistry;
public function boot(): void
{
$registry = app(HeaderActionsRegistry::class);
// List headers only:
$registry->registerListAction('my-module::list-header-action-export');
// Detail headers only:
$registry->registerDetailAction('my-module::detail-header-action-history');
// A universal action must be registered for BOTH slots explicitly:
$registry->registerListAction('my-module::universal-header-action-play-button');
$registry->registerDetailAction('my-module::universal-header-action-play-button');
}The registered name is a Livewire component name — typically an anonymous view-file component in your module's resources/views/components/ folder, resolved through your module's Livewire namespace (Livewire::addNamespace('my-module', viewPath: ...)).
Registration-based on purpose: when a module is removed, its registration (and its actions) disappear with it — no config cleanup needed.
The Component Contract
Every action component is mounted with the same two parameters, in both contexts:
| Param | Value |
|---|---|
model | The host's declared model class ($listModel on lists, $detailModel on details) — null when the host declares none |
component | The host's Livewire alias, e.g. customer::customers-list or customer::customer-detail |
Rules for the component itself:
- Single collapsing root. Use
<div class="contents">as the root and render the button inside it only when visible. A hidden action must render an empty root — never@ifaround the root element. - Gate in
mount(). Compute visibility and any derived state once inmount()and store it in#[Locked]properties. Authorization is entirely the action's responsibility. - Mount-time params only. The params are passed once at mount. The header re-renders on every Livewire update of the host (e.g. each search keystroke), but nested Livewire components with stable keys are skipped on parent re-renders — your action is mounted once per page lifecycle and never re-runs its gating per keystroke. Do not read live host state.
- Open modals with the noerd modal system (the Alpine
$modal(...)magic orNoerd::modal(...)), never with a hand-rolled overlay.
Data Conventions: $listModel and $detailModel
Actions that need to know what the header shows read the host's declared Eloquent model class:
// List component
new class extends Component {
use NoerdList;
public $listModel = Customer::class;
public $detailComponent = 'customer::customer-detail';
};
// Detail component
new class extends Component {
use NoerdDetail;
public $detailModel = Customer::class;
};- A host without the declaration passes
model: null— an action that depends on the model must hide in that case. There is deliberately no fallback to guessing from the component name. - From the model class an action can derive everything else:
- the table:
(new $model())->getTable() - the list component/YAML name:
StaticConfigHelper::modelToListComponent($model)→Customer::classbecomescustomers-list - the detail component/YAML name:
StaticConfigHelper::modelToDetailComponent($model)→Customer::classbecomescustomer-detail
- the table:
Full Example
A minimal list action that shows an icon for admins and opens a modal:
{{-- my-module/resources/views/components/list-header-action-export.blade.php --}}
<?php
use Livewire\Attributes\Locked;
use Livewire\Component;
new class () extends Component {
#[Locked]
public ?string $table = null;
public function mount(?string $model, string $component): void
{
if ($model === null || ! auth()->user()?->isAdmin()) {
return;
}
$this->table = (new $model())->getTable();
}
};
?>
<div class="contents">
@if($table !== null)
<x-noerd::button variant="icon" icon="arrow-down-tray" type="button"
title="{{ __('Export') }}"
x-data
@click="$modal('my-module::export-modal', { table: '{{ $table }}' })">
<span class="sr-only">{{ __('Export') }}</span>
</x-noerd::button>
@endif
</div>Registered with $registry->registerListAction('my-module::list-header-action-export') — that is all; the core renders it in every non-picker, non-compact list header.
Where Actions Do NOT Render
- Compact/embedded lists (no header at all)
- Picker lists (
returnsSelection) - Quick-create detail dialogs
Design Guidance
Keep each action a SINGLE-purpose component — one function per component, and register list and detail variants separately: the two slots share no markup, and a combined component ends up branching on its context everywhere.