Browse the documentation
Building modules
Filter hooks
Filter hooks let a module transform values as they pass through the core. How to register a filter, what it receives, and what it must return.
The FilterRegistry is a WordPress-style hook system. Modules register callbacks on named hooks; the core applies them at specific points and passes data through them to be transformed. This lets a module intervene in the data flow without touching a single core file.
How filters work
A filter has two sides: a module registers a callback on a hook, and the core applies that hook to a value. The callback receives the value, optionally modifies it, and returns it.
// Module registers a filter
$filters = app(FilterRegistry::class);
$filters->register('schneespur.navigation.items', function (array $items): array {
// Modify and return
$items['custom-group'][] = [...];
return $items;
}, 150); // priority 150
// Core applies the filter
$result = $filters->apply('schneespur.navigation.items', $grouped);
Why this approach: The core never knows your module exists and never calls your callback directly. It only calls apply() with a value. Which modules have registered on that hook is determined at runtime by the registry. Core and module remain fully decoupled.
Priority
Multiple modules can register on the same hook. The execution order is controlled by priority:
- Lower numbers run first.
- Default priority is
100. - When priorities are equal, registration order decides.
- If a callback throws an exception, the previous value is restored — no data loss.
Why a priority above 100 is useful for modules: Core filters run at the default priority. Registering your module with a higher number means it runs after the core and sees the core’s result — you modify the fully assembled value rather than pre-empting it.
Error recovery
A single faulty filter must not break the entire chain. If a callback throws, the registry catches the exception, logs a warning, restores the previous value, and continues with the remaining filters:
// If this callback throws, the previous value survives
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
throw new \Exception('Bug in my filter');
// → warning logged, previous value restored, next filter continues
}, 200);
Why this matters: A module that modifies navigation or the dashboard should, if it fails, only lose its own contribution — not bring down the UI for every other module. That said, do not rely on this as a safety net: a filter that throws loses its change silently. See the best practices at the end of this page.
Available filter hooks
The core provides a fixed set of named hooks. Each has a precise application point, a value type, and sometimes additional context.
schneespur.navigation.items
Applied in: NavigationRegistry::getItems()
Value: array<string, array> — grouped navigation items
Context: none
Lets modules add, remove, or reorder navigation items after the core has assembled the grouped list.
$filters->register('schneespur.navigation.items', function (array $grouped): array {
// Add an item to the 'modules' group
$grouped['modules'][] = [
'group' => 'modules',
'slug' => 'my-custom-nav',
'label' => 'My Module',
'route' => 'admin.my-module.index',
'icon' => 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0z', // raw SVG path geometry — see navigation-dashboard
'order' => 100,
'permission' => null,
'route_check' => null,
'active_pattern' => 'admin.my-module.*',
'badge' => null,
];
return $grouped;
}, 150);
For the standard case — registering a navigation item — the NavigationRegistry offers a more direct path (see Navigation & dashboard). This filter is for situations where you need to reorder the fully assembled, grouped list as a whole, or influence entries registered by other modules.
schneespur.dashboard.kpis
Applied in: Admin\DashboardController
Value: array — widget configurations
Context: none
Lets modules add or modify dashboard widgets after the core widgets are assembled. Unlike the DashboardWidgetRegistry, this filter operates on the already-prepared widget array.
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
$widgets[] = [
'key' => 'my-custom-kpi',
'label' => 'Custom KPI',
'view' => 'my-module::widgets.kpi',
'order' => 250,
'size' => 'half',
];
return $widgets;
}, 150);
schneespur.job.notification.recipients
Applied in: SendJobCompletedNotification listener
Value: array — recipient list
Context: $job (the completed Job model)
Lets modules add or filter notification recipients when a job is completed. This hook receives a second parameter — the completed job model — so you can determine recipients based on the specific job’s data.
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
// Add the dispatcher to recipients
if ($dispatcher = $job->workShift?->user) {
$recipients[] = ['email' => $dispatcher->email, 'name' => $dispatcher->name];
}
return $recipients;
}, 100);
schneespur.job.notification.channels
Applied in: NotificationChannelRegistry::dispatch()
Value: array<string, NotificationChannelInterface> — enabled channels
Context: $job (the Job model being notified about)
Lets modules control which notification channels are used for a specific job. This makes it straightforward to suppress a channel based on job type:
$filters->register('schneespur.job.notification.channels', function (array $channels, $job): array {
// Disable Telegram for Kontrolle-type jobs
if ($job->type === JobType::Kontrolle) {
unset($channels['telegram']);
}
return $channels;
}, 100);
Creating custom hooks in your module
A filter hook is not reserved for the core. You can define your own hooks inside your module so that other modules can extend your data:
// In your module's code
$filterRegistry = app(FilterRegistry::class);
$reportData = $filterRegistry->apply('my-module.report.data', $baseData, $customer);
Other modules can then register on my-module.report.data to modify the data. Why this is useful: it makes your module itself extensible — the same decoupling the core gives you, passed on to downstream modules, without you knowing their code.
Best practices
- Always return the modified value — a filter must return the (possibly modified) value.
- Use a priority above
100for module filters so that core filters run first. - Name custom hooks with your module slug as a prefix:
my-module.hook.name. - Handle missing data gracefully — the value may already have been modified by another filter.
- Do not throw exceptions — they will be caught, but your modification will be lost.
How modules register their extension points in boot() is covered in The ServiceProvider pattern. Closely related are Events: filters transform a value and return it, events announce something that happened without expecting a return value.