Skip to main content
Wintertrace
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);

PDF report sections

A module can append its own HTML sections to the generated PDF reports — the standalone job record and the customer and object collective reports — without editing a core template or writing a controller. The core renders an @pdfExtensionSlot(...) Blade directive at fixed points in the PDF; each slot is backed by one of the hooks below, and a module registers an HTML-returning callback on the matching hook.

Why this exists: the customer-facing PDF is frequently the actual deliverable of a winter service. A module — one that tracks consumption, say — may want its figures on the job record without forking the core’s PDF views. These hooks give it a defined seam, and because nothing is registered by default, the seam is completely inert on a core running without modules.

Per-job hooks

schneespur.pdf.job.after_details, schneespur.pdf.job.after_weather and schneespur.pdf.job.end each render once per job — once in the standalone job report, and once per job page inside the customer and object collective reports, which share the same job partial. Register once, and the section appears everywhere a job is rendered.

Applied in: the job report view and the shared job-section partial (via the @pdfExtensionSlot directive) Value: string[] — HTML fragments to append at that point Context: $job (the Job model being rendered)

HookPosition in the job block
schneespur.pdf.job.after_detailsafter the job-details section
schneespur.pdf.job.after_weatherafter the weather section
schneespur.pdf.job.endat the end of the job block (after photos)
use App\Models\Job;

$filters->register('schneespur.pdf.job.end', function (array $sections, Job $job): array {
    // Render your own DomPDF-friendly Blade view to a string and append it.
    $html = view('my-module::pdf.consumption', ['job' => $job])->render();
    if ($html !== '') {
        $sections[] = $html;
    }

    return $sections;
}, 100);

Collective-cover hook

schneespur.pdf.collective.cover_end renders once on the cover page of the customer and object collective reports, after the table of contents. It is the place for an aggregate figure across every job in the range — a total-consumption line, for instance.

Applied in: the collective report cover view (via @pdfExtensionSlot) Value: string[] — HTML fragments to append on the cover page Context: $customer (Customer), $jobs (Collection of Job), $from (Carbon), $to (Carbon)

use App\Models\Customer;
use Illuminate\Support\Collection;
use Carbon\Carbon;

$filters->register('schneespur.pdf.collective.cover_end', function (array $sections, Customer $customer, Collection $jobs, Carbon $from, Carbon $to): array {
    $sections[] = view('my-module::pdf.cover-summary', [
        'customer' => $customer,
        'jobs' => $jobs,
        'from' => $from,
        'to' => $to,
    ])->render();

    return $sections;
}, 100);

Working with the PDF slots

  • Return the (extended) array — the same contract as every other filter hook.
  • Keep the HTML DomPDF-friendly: simple, inline-heavy CSS, as in the core PDF views. See PDF & reports for the rendering constraints.
  • You cannot break the customer PDF: the registry catches a throwing callback, logs it, and skips only that contribution — the PDF itself stays intact. Consume the seam guarded, so the module also does nothing when no registry is present:
if ($this->app->bound(FilterRegistry::class)) {
    // register your PDF hooks here
}

Admin job-detail sections

The same idea reaches the admin side. A module can add its own HTML section to the job-detail page at /admin/jobs/{id} — a material-consumption panel, for instance — without editing a core view. This uses the generic @filterSlot Blade directive, the plain-HTML counterpart to @pdfExtensionSlot: the core renders it at the end of the job-detail content, backed by the hook below.

Applied in: the admin job-detail view, at the end of the page content (via the @filterSlot directive). Added in 1.1.8. Value: string[] — HTML fragments to append Context: $job (the Job model being shown)

$filters->register('schneespur.admin.job.detail.after', function (array $html, $job): array {
    $html[] = view('my-module::admin.job-consumption', ['job' => $job])->render();
    return $html;
});

The contract matches the PDF seams exactly: return the extended array, and the registry isolates each callback’s exceptions, so a failing module section can never break the admin page. The seam stays inert until a module registers on it — consume it guarded, just as with the PDF hooks.


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

  1. Always return the modified value — a filter must return the (possibly modified) value.
  2. Use a priority above 100 for module filters so that core filters run first.
  3. Name custom hooks with your module slug as a prefix: my-module.hook.name.
  4. Handle missing data gracefully — the value may already have been modified by another filter.
  5. 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.