Skip to main content
Wintertrace
Browse the documentation

Building modules

Registries reference

The core registries a module writes to — navigation, widgets, permissions, routes, notifications and more — with the exact API for registering each capability.

Registries are the heart of the module system: through them a module announces its capabilities without touching the core. This page is a look-up reference — you do not need to read it cover to cover; jump to whichever registry you need right now.

All extension registries extend App\Services\Extension\ExtensionRegistry (or follow its pattern). The base class provides:

abstract class ExtensionRegistry
{
    protected array $items = [];

    public function register(string $slug, mixed $entry): void;  // add entry (warns on overwrite)
    public function resolve(string $slug): mixed;                 // get entry or null
    public function all(): array;                                 // get all entries
    public function has(string $slug): bool;                      // check existence
    public function remove(string $slug): void;                   // unregister
}

All registries are registered as singletons in AppServiceProvider. Resolve them from the container:

$registry = app(NavigationRegistry::class);
// or via constructor injection in your ServiceProvider

Class: App\Services\Extension\NavigationRegistry

Manages the admin sidebar navigation.

Methods

// Add a navigation group (section header)
addGroup(string $key, string $label, int $order = 100): void

// Get all groups sorted by order
getGroups(): array

// Add a navigation item
addItem(
    string $group,        // group key (e.g. 'system', 'stammdaten')
    string $slug,         // unique item identifier
    string $label,        // display text
    string $route,        // Laravel named route
    string $icon,         // RAW SVG path geometry (d= content), '||'-separated for
                          // multi-path icons — NOT a 'heroicon-o-*' name.
                          // See 10-NAVIGATION-DASHBOARD.md "Icon format".
    int $order = 100,     // sort order within group
    ?string $permission = null,    // required permission slug
    ?string $routeCheck = null,    // route name; item is hidden unless Route::has() — set this!
    ?string $activePattern = null, // route pattern for active state
    ?string $badge = null,         // badge HTML/text
): void

// Get grouped items, filtered by user permissions
getItems(?User $user = null): array
// Returns: array<string, array<int, array{slug, label, route, icon, order, permission}>>

Core navigation groups

KeyLabelOrder
topDashboard0
stammdatenMaster data10
einsaetzeOperations20
auswertungenReports30
systemSystem40

Example

$nav = app(NavigationRegistry::class);

$nav->addItem(
    group: 'system',
    slug: 'my-module-settings',
    label: __('my-module::messages.settings'),
    route: 'admin.my-module.settings',
    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 — see navigation-dashboard
    order: 200,
    permission: 'settings.view',
    routeCheck: 'admin.my-module.settings',
);

Always set routeCheck: to the item’s own route name. If the route is absent — for example because the module is currently disabled — the menu item disappears cleanly rather than triggering a 500 error. Why this matters with route caching is covered in Module lifecycle — Routes, config, and caching.


PortalNavigationRegistry

Class: App\Services\Extension\PortalNavigationRegistry

Manages the customer portal navigation (desktop + mobile menus) — the portal counterpart to NavigationRegistry. Added in 1.1.3.

Differences from the admin NavigationRegistry:

  • Runs against the customer guard. Customers have no Gates/permissions, so visibility is controlled by an optional condition closure that receives the Customer.
  • label stores a translation key, not a translated string. The portal locale is set per-request (per-customer) in EnsureCustomer, after boot — so the layout resolves the key with __() at render time. (The admin registry stores already-translated labels.)
  • No icon — portal tabs are text-only.

Methods

// Add a portal navigation item
addItem(
    string $slug,                  // unique item identifier
    string $label,                 // TRANSLATION KEY (resolved with __() at render)
    string $route,                 // Laravel named route, e.g. 'portal.documents.index'
    int $order = 100,              // sort order
    ?string $activePattern = null, // route pattern for active state (defaults to $route)
    ?\Closure $condition = null,   // optional fn(Customer $c): bool — return false to hide
): void

// Items sorted by order, filtered by each item's condition closure for this customer
getItems(?Customer $customer = null): array
// Returns: array<int, array{slug, label, route, order, active_pattern, condition}>

Core portal items

SlugRouteOrder
homeportal.home10
jobsportal.jobs.index20
reportsportal.reports.index30
notificationsportal.notifications.index40
profileportal.profile.edit50

Example

$nav = app(PortalNavigationRegistry::class);

$nav->addItem(
    slug: 'contracts',
    label: 'my-module::portal.nav_contracts', // translation key
    route: 'portal.contracts.index',
    order: 25,
    activePattern: 'portal.contracts.*',
    condition: fn (\App\Models\Customer $c) => true, // optional per-customer visibility
);

Items appear in both the desktop nav and the mobile menu automatically.


LocaleRegistry

Class: App\Services\Extension\LocaleRegistry

Registry of available UI locales. The core registers de and en; language-pack modules register additional locales (e.g. cs). Added in 1.1.3.

Methods

add(string $code, string $label): void   // register a locale, e.g. add('cs', 'Čeština')
codes(): array                           // ['de', 'en', ...] — for validation (Rule::in)
labels(): array                          // ['de' => 'Deutsch', ...] — for locale pickers
has(string $code): bool                  // to guard App::setLocale()

Example

// In a language-pack module's ServiceProvider::boot()
app(LocaleRegistry::class)->add('cs', 'Čeština');

Boot timing: module locales are registered during ModuleManager::boot() (inside the app()->booted() callback). The app-wide default_locale is applied after that, so an all-Czech install (default_locale = cs) resolves through a module-provided locale.


PublicHomepageRegistry

Class: App\Services\Extension\PublicHomepageRegistry. Added in 1.1.6.

By default a Wintertrace installation is a private application: every URL redirects to login and nothing is exposed to search engines. This registry lets a module take over the public root URL / to serve a real homepage instead of the login redirect — and it is the single source of truth for search-engine exposure. It is what powers the frontpage module, which turns an installation into a small public website for an operator who has none of their own.

Why route everything through one registry? Because indexing is enforced at three independent layers (the dynamic robots.txt route, the X-Robots-Tag response header, and the per-page <meta name="robots"> tag). If each decided independently what is public, they could drift apart and leak a private page. Instead all three consult this registry at request time, so they can never disagree.

Methods

register(callable $handler): void   // serve "/"; handler returns Response|View|string. Auto-marks "/" crawlable.
has(): bool
render(): mixed

allowCrawling(string ...$paths): void  // declare extra public pages, e.g. '/services'
crawlablePaths(): array                 // list<string>
isCrawlable(string $path): bool         // root matches exactly; sections match sub-paths, not mere prefixes
setSitemapUrl(string $url): void        // advertised in robots.txt (module serves the sitemap itself)
sitemapUrl(): ?string

Example

// In the frontpage module's ServiceProvider::boot()
$home = app(PublicHomepageRegistry::class);
$home->register(fn () => view('frontpage::homepage'));     // serves "/"
$home->allowCrawling('/services', '/imprint');             // extra public pages
$home->setSitemapUrl(url('/sitemap.xml'));

Everything you do not register stays private — the model is default-deny. The / route consults the registry at request time, so it keeps working even with route:cache enabled. The complete indexing model is documented on its own page: Public homepage & SEO.


JobTypeRegistry

Class: App\Services\Extension\JobTypeRegistry. Added in 1.1.6.

The core ships a fixed set of job types (the built-in JobType enum), registered on boot. This registry makes that set extensible so a module can add its own — a grounds-care module adding a gruenpflege type, for example. A job’s type is stored as a plain string, so a new type needs no database migration.

Methods

registerType(string $value, string $labelKey, int $order = 100, ?string $module = null): void
hasType(string $value): bool
values(): array          // string[] of type values, ordered
types(): array           // App\ValueObjects\JobTypeValue[]
label(string $value): string  // translated label via __() of the registered label_key

The labelKey is a translation key, not the visible text — so the new type’s name is localised through the normal translation system (see Translations) and reads correctly in every installed language.

Example

app(JobTypeRegistry::class)->registerType(
    value: 'gruenpflege',
    labelKey: 'gruenpflege::job.type_gruenpflege',
    order: 100,
    module: 'gruenpflege',
);

LifecycleFieldRegistry

Class: App\Services\Extension\LifecycleFieldRegistry. Added in 1.1.6.

Drivers move through a few lifecycle steps in the field — starting and ending a shift, starting and ending a job. This registry lets a module inject extra form fields into those flows to capture, validate and persist module-specific data without patching the core forms. It is the groundwork for modules such as a depot/stock module (recording salt used) or a grounds-care module.

The four injection points are the App\Enums\LifecyclePoint enum:

CaseValue
LifecyclePoint::ShiftStartshift.start
LifecyclePoint::ShiftEndshift.end
LifecyclePoint::JobStartjob.start
LifecyclePoint::JobEndjob.end

Methods

registerField(LifecyclePoint $point, string $slug, array $contribution): void
contributions(LifecyclePoint $point, ?Authenticatable $user = null): array  // ordered, permission-filtered
rules(LifecyclePoint $point, ?Authenticatable $user = null): array          // merged validation rules
fieldKeys(LifecyclePoint $point): array                                     // keys to pull from the request
render(LifecyclePoint $point, ?Authenticatable $user = null): string        // concatenated view HTML
persist(LifecyclePoint $point, Model $entity, array $validated, User $user): void

The contribution array

A registered field is described by one array. Each key has a clear job: view is the Blade snippet rendered into the form, rules are the Laravel validation rules merged in, persist is what runs after validation, and permission optionally gates the whole field.

[
    'view' => 'lager::fields.salt_used',   // Blade view rendered into the form (receives $user)
    'rules' => ['lager_salt_used' => 'nullable|numeric|min:0'],  // namespace your keys!
    'persist' => fn (Model $entity, array $validated, User $user) => /* ... */,  // Closure | class-string | LifecycleFieldHandler
    'order' => 100,
    'permission' => null,   // optional Gate; applies to render(), rules() AND persist()
]

Rendering — the @lifecycleFields directive

You never call the renderer yourself. The core forms already mark each injection point with a Blade directive, and your registered field appears there automatically:

@lifecycleFields(\App\Enums\LifecyclePoint::ShiftStart)

Example

app(LifecycleFieldRegistry::class)->registerField(
    LifecyclePoint::JobEnd,
    'lager_salt_used',
    [
        'view' => 'lager::fields.salt_used',
        'rules' => ['lager_salt_used' => 'nullable|numeric|min:0'],
        'persist' => \Schneespur\Module\Lager\Lifecycle\RecordSaltUsage::class,
        'permission' => 'lager.record',
    ],
);

Three things to watch for:

  1. Namespace your field keys (lager_salt_used, not notes). Contributions merge last-wins, so a generic key from one module can quietly overwrite another’s.
  2. A permission key gates persistence too, not just whether the field is shown. A user who cannot see the field also cannot write it.
  3. Manual job entry bypasses the lifecycle hooks. Fields are captured only on the driver shift/job flows, so do not rely on them for jobs entered by hand in the admin area.

The persist handler can be a closure, a class-string, or a class implementing the LifecycleFieldHandler interface — the explicit, type-checked form.


DashboardWidgetRegistry

Class: App\Services\Extension\DashboardWidgetRegistry

Manages dashboard cards on the admin dashboard.

Methods

// Register a dashboard widget
registerWidget(string $slug, array $config): void

// Get visible widgets for a user (permission + condition filtered, sorted by order)
getWidgets(?User $user = null): array
// Returns: array<int, array{slug, label, view, data, order, permission, size, error}>

Config array

[
    'slug' => 'my-widget',           // auto-set from first param
    'label' => 'Widget Title',       // display label
    'view' => 'my-module::widgets.card', // Blade view to render
    'dataCallback' => fn () => [...],    // optional: callable returning view data
    'order' => 100,                  // sort position (lower = higher)
    'permission' => 'dashboard.view', // optional: required permission
    'condition' => fn () => true,    // optional: callable, return false to hide
    'size' => 'full',               // 'full' or 'half'
]

Example

$widgets = app(DashboardWidgetRegistry::class);

$widgets->registerWidget('telegram-status', [
    'label' => 'Telegram Status',
    'view' => 'telegram::widgets.status',
    'dataCallback' => fn () => ['connected' => TelegramService::isConnected()],
    'order' => 150,
    'size' => 'half',
    'permission' => 'settings.view',
]);

The dataCallback is executed only at render time — expensive queries run only when the widget is actually visible to the current user.


FilterRegistry

Class: App\Services\Extension\FilterRegistry

WordPress-style hook/filter system. Register callbacks on named hooks; the core applies them at specific points.

Methods

// Register a filter callback
register(string $hook, callable $callback, int $priority = 100): void

// Apply all registered filters for a hook
apply(string $hook, mixed $value, mixed ...$context): mixed

Priority

Lower priority numbers execute first. When priorities are equal, insertion order determines sequence. If a callback throws, the previous value is restored and a warning is logged — a faulty filter cannot crash the pipeline.

The available hooks with their signatures are listed in the Filter hooks reference.


SlotRegistry

Class: App\Services\Extension\SlotRegistry

Template injection system — add content to predefined points in Blade layouts.

Methods

// Append a view to a slot (multiple appends are rendered in order)
append(
    string $slotName,
    string $viewPath,
    array $data = [],
    int $order = 100,
    ?string $permission = null,
): void

// Replace a slot entirely (last-wins if multiple modules replace the same slot)
replace(
    string $slotName,
    string $viewPath,
    array $data = [],
    ?string $permission = null,
): void

// Render a slot's content (called by @extensionSlot Blade directive)
render(string $slotName, ?Authenticatable $user = null): string

// List all registered slot names
getSlotNames(): array

The available slot names by layout are described in the Template slots reference.


PermissionRegistry

Class: App\Services\Extension\PermissionRegistry

Registers authorisation permissions that can be assigned to roles.

Methods

registerPermission(
    string $slug,         // e.g. 'my-module.manage'
    string $label,        // human-readable label
    string $group,        // permission group name
    ?string $module = null, // module slug (for cleanup on removal)
): void

getPermissions(): array          // all permissions
getByGroup(string $group): array // filter by group
getByModule(string $module): array  // filter by module
removeByModule(string $module): void // remove all for a module

Example

$permissions = app(PermissionRegistry::class);

$permissions->registerPermission(
    slug: 'telegram.manage',
    label: 'Manage Telegram settings',
    group: 'telegram',
    module: 'telegram',
);

$permissions->registerPermission(
    slug: 'telegram.view-logs',
    label: 'View Telegram logs',
    group: 'telegram',
    module: 'telegram',
);

Always pass module: — it allows all a module’s permissions to be cleaned up in one step when the module is removed.


RoleTemplateRegistry

Class: App\Services\Extension\RoleTemplateRegistry

Pre-defined role configurations that admins can apply when creating roles.

Methods

registerTemplate(
    string $slug,
    string $name,
    string $description,
    array $permissions,     // array of permission slugs
    ?string $module = null,
): void

getTemplates(): array
getByModule(string $module): array
removeByModule(string $module): void

Example

$templates = app(RoleTemplateRegistry::class);

$templates->registerTemplate(
    slug: 'accountant',
    name: 'Buchhaltung',
    description: 'Zugriff auf Dokumente und Rechnungen',
    permissions: ['documents.view', 'invoices.view', 'invoices.export'],
    module: 'documents',
);

DispatchStrategyRegistry

Class: App\Services\Extension\DispatchStrategyRegistry

Manages job dispatch/assignment algorithms.

Methods

register(string $slug, string $class): void      // class-string<DispatchStrategyInterface>
resolve(?string $slug = null): DispatchStrategyInterface  // resolves from container; falls back to 'manual'
availableStrategies(): array   // array<string, array{name: string}>
activeSlug(): string           // currently configured strategy

The active strategy is stored in the dispatch_strategy setting. Default is 'manual'.


NotificationChannelRegistry

Class: App\Services\Notification\NotificationChannelRegistry

Manages notification delivery channels (email, Telegram, SMS, etc.).

Methods

register(string $slug, string $channelClass): void  // class-string<NotificationChannelInterface>

// Dispatch notification to all enabled channels (with filter hook)
dispatch(Job $job, string $type, array $context): array
// Returns: array<int, array{slug, status, error}>

// Get instantiated enabled channels
enabledChannels(): array  // array<string, NotificationChannelInterface>

dispatch() applies the schneespur.job.notification.channels filter, allowing modules to influence which channels receive a given notification.


TwoFactorMethodRegistry

Class: App\Services\Extension\TwoFactorMethodRegistry

Manages two-factor authentication methods.

Methods

registerMethod(string $slug, string $methodClass): void  // class-string<TwoFactorMethodInterface>
getMethods(): array                    // all registered classes
getAvailableMethods(Container $container): array  // instantiated methods
getByModule(string $module): array     // filter by module namespace
removeByModule(string $module): void

BackupTargetRegistry

Class: App\Services\Backup\BackupTargetRegistry

Manages backup storage destinations.

Methods

register(string $slug, string $class): void  // class-string<BackupTargetInterface>
resolve(?string $slug = null): BackupTargetInterface  // falls back to 'local'
availableTargets(): array  // array<string, array{label, configured}>
activeSlug(): string

StorageBackendRegistry

Class: App\Services\Storage\StorageBackendRegistry

Manages file storage backends with automatic fallback to local.

Methods

register(string $slug, string $class): void  // class-string<StorageBackendInterface>
resolve(?string $slug = null): StorageBackendInterface
availableBackends(): array  // array<string, array{label, configured}>
activeSlug(): string

// Read with automatic fallback to local if active backend misses
retrieveWithFallback(string $relativePath): ?string

// URL with automatic fallback to local if active backend misses
urlWithFallback(string $relativePath): string

The built-in fallback means a temporarily unreachable remote backend does not immediately cause missing files — the local store is read instead.


WeatherProviderRegistry

Class: App\Services\Weather\WeatherProviderRegistry

Manages weather data providers.

Methods

register(string $slug, string $class): void  // class-string<WeatherProviderInterface>
resolve(?string $slug = null): WeatherProviderInterface
availableProviders(): array  // array<string, array{name, requires_api_key}>
activeSlug(): string

PdfRendererRegistry

Class: App\Services\Pdf\PdfRendererRegistry

Manages PDF generation engines (default: DomPDF).

Methods

register(string $slug, string $class): void  // class-string<PdfRendererInterface>
resolve(?string $slug = null): PdfRendererInterface

ReportFormatRegistry

Class: App\Services\Report\ReportFormatRegistry

Manages export formats (PDF, CSV, and custom formats).

Methods

register(string $slug, string $class): void  // class-string<ReportFormatInterface>
resolve(string $slug): ?ReportFormatInterface
availableFormats(): array

ScheduledTaskRegistry

Class: App\Services\Scheduler\ScheduledTaskRegistry

Manages cron tasks.

Methods

register(string $slug, string $class): void  // class-string<ScheduledTaskInterface>
resolve(string $slug): ?ScheduledTaskInterface
enabledTasks(): array            // instantiated enabled tasks
recordRun(string $slug, string $status, ?string $error, int $durationMs): void
lastRun(string $slug): ?object   // last execution record
allWithStatus(): array           // all tasks with their last run info

DiagnosticReporterRegistry

Class: App\Services\Diagnostic\DiagnosticReporterRegistry

Manages error/crash reporting backends.

Methods

register(string $slug, string $class): void  // class-string<DiagnosticReporterInterface>

The DiagnosticManager dispatches to all enabled reporters. Payloads are sanitised by DiagnosticPayloadSanitizer before sending, so sensitive fields do not leave the installation unfiltered.


ModuleAssetRegistry

Class: App\Services\Extension\ModuleAssetRegistry

Manages CSS/JS assets from modules.

Methods

registerAssets(string $slug, string $modulePath): void  // reads dist/manifest.json
getCss(): string[]  // URLs of all registered CSS files
getJs(): string[]   // URLs of all registered JS files
all(): array        // all assets with type, url, slug

ModuleApiRegistrar

Class: App\Services\Extension\ModuleApiRegistrar

Creates prefixed, authenticated API route groups for modules.

Methods

routes(string $slug, int $version, Closure $callback): void

Creates routes at /api/mod/{slug}/v{version}/ with module.api:{slug} middleware and module.{slug}.api.v{version}. route name prefix.


A concrete module combining navigation, a dashboard widget, settings, and routes is built step by step in the Quick start.