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
NavigationRegistry
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
| Key | Label | Order |
|---|---|---|
top | Dashboard | 0 |
stammdaten | Master data | 10 |
einsaetze | Operations | 20 |
auswertungen | Reports | 30 |
system | System | 40 |
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
customerguard. Customers have no Gates/permissions, so visibility is controlled by an optionalconditionclosure that receives theCustomer. labelstores a translation key, not a translated string. The portal locale is set per-request (per-customer) inEnsureCustomer, 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
| Slug | Route | Order |
|---|---|---|
home | portal.home | 10 |
jobs | portal.jobs.index | 20 |
reports | portal.reports.index | 30 |
notifications | portal.notifications.index | 40 |
profile | portal.profile.edit | 50 |
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:
| Case | Value |
|---|---|
LifecyclePoint::ShiftStart | shift.start |
LifecyclePoint::ShiftEnd | shift.end |
LifecyclePoint::JobStart | job.start |
LifecyclePoint::JobEnd | job.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:
- Namespace your field keys (
lager_salt_used, notnotes). Contributions merge last-wins, so a generic key from one module can quietly overwrite another’s. - A
permissionkey gates persistence too, not just whether the field is shown. A user who cannot see the field also cannot write it. - 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.