Browse the documentation
Building modules
The example module in detail
A guided read-through of the bundled reference module, section by section, showing each extension point in a real, runnable module.
The application ships a complete reference module under modules/example/. It demonstrates every extension point in one place — from navigation and filter hooks through template slots to routes and API endpoints. This page walks through that module: it shows the code and explains why each piece is structured the way it is.
The module is disabled by default and only loads when EXAMPLE_MODULE_ENABLED=true is set in .env. This means a complete working template ships alongside the application without running during normal operation. How the boot guard works — and why production modules should not use the same approach — is covered in The ServiceProvider pattern.
Directory structure
A module is a self-contained directory. The structure alone tells you what capabilities it brings: a src/ directory with the ServiceProvider and supporting classes, resources/views/ for Blade templates, database/migrations/ for its own tables, dist/ for compiled assets, and lang/ for translations.
modules/example/
├── module.json # Module manifest
├── src/
│ ├── ExampleServiceProvider.php # Main entry point
│ ├── Http/Controllers/
│ │ ├── ExampleSettingsController.php # Admin settings page
│ │ └── ExampleApiController.php # REST API endpoint
│ ├── Dispatch/
│ │ └── FirstAvailableStrategy.php # Dispatch strategy implementation
│ ├── Auth/
│ │ └── DummyTwoFactorMethod.php # 2FA method implementation
│ └── Notification/
│ └── DummyLogChannel.php # Notification channel implementation
├── resources/views/
│ ├── settings.blade.php # Settings page view
│ ├── slot-demo.blade.php # Admin slot demo content
│ ├── driver-slot-demo.blade.php # Driver layout slot demo
│ ├── portal-slot-demo.blade.php # Portal layout slot demo
│ └── widgets/
│ └── example-card.blade.php # Dashboard widget view
├── database/migrations/
│ └── 2026_01_01_000001_create_mod_example_logs_table.php
├── dist/
│ ├── manifest.json # Asset manifest
│ └── example-abc123.css # Compiled CSS
└── lang/
├── de/messages.php # German translations
└── en/messages.php # English translations
module.json
The module.json is the manifest. It declares the namespace, the ServiceProvider as the entry point, and the minimum application version the module was built against. The requires and conflicts fields express dependencies on and incompatibilities with other modules. Each field is described in detail in the Module manifest.
{
"name": "Example Module",
"version": "1.0.0",
"namespace": "Schneespur\\Module\\Example",
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
"description": "Reference module demonstrating all extension points.",
"min_schneespur_version": "1.0.0",
"requires_permissions": [],
"default_enabled": false,
"requires": {},
"conflicts": []
}
default_enabled is deliberately false: the reference module should not start running after installation unless someone deliberately enables it for learning purposes.
ExampleServiceProvider — annotated
The ServiceProvider is the module’s single entry point. Here the core principle is visible: register() does nothing, because this module only extends through registries and does not bind its own services into the container. All the work is in boot() — by that point every other provider is already registered, so resolving registries is safe. Why the separation between the two phases matters is explained in the Module lifecycle.
class ExampleServiceProvider extends ServiceProvider
{
public function register(): void
{
// Nothing to bind — this module only extends via registries
}
public function boot(): void
{
// Guard: only load when explicitly enabled via .env
if (! $this->shouldBoot()) {
return;
}
// Register the Blade view namespace
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module');
// Register into every extension point
$this->registerSettings(); // ModuleManager settings
$this->registerNavigation(); // Admin sidebar
$this->registerWidget(); // Dashboard widget
$this->registerFilters(); // Filter hooks
$this->registerSlots(); // Template slots
$this->registerEventListeners(); // Domain events
$this->registerNotificationChannels(); // Notification transport
$this->registerTwoFactorMethods(); // 2FA method
$this->registerDispatchStrategy(); // Job dispatch algorithm
$this->registerRoutes(); // Admin web routes
$this->registerApiRoutes(); // Module API endpoints
}
}
The boot() method is deliberately split into small, named private methods — each one registers exactly one extension point. This is not a technical requirement; it is a convention that keeps the module readable. If you want to understand how a particular point works, you jump directly to the responsible method.
Settings registration
protected function registerSettings(): void
{
app(ModuleManager::class)->registerSettings('example', [
'greeting' => 'Hello from Example Module',
'enabled_features' => 'all',
]);
// Creates settings: example.greeting, example.enabled_features
// Skips if already exist (won't overwrite user changes)
}
Each key is stored prefixed with the module slug example — so example.greeting and example.enabled_features. This prefix is why a module’s settings can be cleaned up reliably when it is removed, and why existing values are not overwritten on a subsequent boot. More on this in The ServiceProvider pattern.
Navigation registration
protected function registerNavigation(): void
{
$nav = app(NavigationRegistry::class);
$nav->addItem(
group: 'system', // add to System group
slug: 'example', // unique ID
label: 'example::messages.nav_label', // translation key
route: 'admin.example.settings', // target route
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, // sort position (after core items)
);
}
The item goes into the system group of the admin navigation. An order value of 200 places it after core items — modules deliberately sit behind the defaults rather than displacing them. The full NavigationRegistry API is in Navigation & dashboard.
Dashboard widget
protected function registerWidget(): void
{
$widgets = app(DashboardWidgetRegistry::class);
$widgets->registerWidget('example-card', [
'label' => 'example::messages.widget_label', // translation key
'view' => 'example-module::widgets.example-card',
'order' => 200,
'size' => 'half',
]);
}
The widget references a Blade template through the example-module view namespace, which was registered earlier in boot(). size controls the width on the dashboard. Ordering works the same way as navigation.
Filter hooks
protected function registerFilters(): void
{
$filters = app(FilterRegistry::class);
// Add item to navigation via filter (alternative to NavigationRegistry)
$filters->register('schneespur.navigation.items', function (array $grouped): array {
$grouped['modules'][] = [
'group' => 'modules',
'slug' => 'example-filter',
'label' => 'example::messages.filter_nav_label', // translation key
'route' => 'admin.example.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' => 250,
'permission' => null,
'route_check' => null,
'active_pattern' => 'admin.example.settings',
'badge' => null,
];
return $grouped;
}, 150);
// Add widget via dashboard filter
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
$widgets[] = [
'key' => 'example-filter-widget',
'label' => 'example::messages.filter_widget_label', // translation key
'view' => 'example-module::widgets.example-card',
'order' => 250,
'size' => 'half',
];
return $widgets;
}, 150);
// Observe notification recipients
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
Log::info('ExampleModule: notification recipients filter', [
'job_id' => $job->id ?? null,
'recipient_count' => count($recipients),
]);
return $recipients;
}, 150);
}
Filters are the second way to hook into an extension point: instead of writing directly into a registry, a filter modifies a value as the core passes it through. The first two examples show this for navigation and the dashboard — the same effect as the respective registry calls, but via the filter path. The third example does not modify anything; it only observes the recipient list of a notification and returns it unchanged. That is the distinction between a mutating and an observing filter. The number 150 at the end is the priority, which controls the order when multiple filters target the same hook. Details in Filter hooks.
Template slots
protected function registerSlots(): void
{
$slots = app(SlotRegistry::class);
$slots->append('admin.content.after', 'example-module::slot-demo');
$slots->append('driver.content.after', 'example-module::driver-slot-demo');
$slots->append('portal.content.after', 'example-module::portal-slot-demo');
}
Slots are named positions in the layouts where a module can inject its own Blade content without modifying any core layout files. This example deliberately covers all three interfaces — admin, driver, and portal — showing that a single module can reach into each area. Which slots exist and how append differs from replace is covered in Template slots.
Event listeners
protected function registerEventListeners(): void
{
$events = $this->app['events'];
$events->listen(JobCompleted::class, function (JobCompleted $event) {
Log::info('ExampleModule: JobCompleted', [
'job_id' => $event->job->id,
'weather_available' => $event->weatherAvailable,
]);
});
$events->listen(WorkShiftStarted::class, function (WorkShiftStarted $event) {
Log::info('ExampleModule: WorkShiftStarted', [
'shift_id' => $event->workShift->id,
'user_id' => $event->user->id,
]);
});
// Also listens to: CustomerUpdated, UserLoggedIn, ModuleEnabled
}
Events let a module react to what happens in the core — for example a job being completed or a shift starting. The reference module writes only a log line here, because the point is to show when each event arrives and what data it carries. In a production module, the actual business logic would go in its place. Which events the core dispatches is listed in Events.
Notification channel
protected function registerNotificationChannels(): void
{
app(NotificationChannelRegistry::class)->register('dummy-log', DummyLogChannel::class);
}
A notification channel is a transport for messages. The module registers an additional channel under the slug dummy-log. The class implements NotificationChannelInterface and writes notifications to the Laravel log — useful for testing without actually sending e-mail or push messages:
class DummyLogChannel implements NotificationChannelInterface
{
public function send(Job $job, string $type, array $context): void
{
Log::info('DummyLogChannel: notification dispatched', [
'job_id' => $job->id,
'type' => $type,
'recipient_count' => count($context['recipients'] ?? []),
]);
}
public function name(): string { return 'Log (Demo)'; }
public function slug(): string { return 'dummy-log'; }
public function isEnabled(): bool { return true; }
}
The pattern is consistent throughout: a class implements the interface defined by the core, and the ServiceProvider registers it in the appropriate registry. More on channels in Notifications, and on the interfaces in Interfaces.
Two-factor authentication
protected function registerTwoFactorMethods(): void
{
app(TwoFactorMethodRegistry::class)->registerMethod('dummy-2fa', DummyTwoFactorMethod::class);
}
A 2FA method can also be added through a module. The demo class accepts any code in verify() — it is intended solely for exploration and must never be used in production:
class DummyTwoFactorMethod implements TwoFactorMethodInterface
{
private static array $enabled = [];
public function slug(): string { return 'dummy-2fa'; }
public function name(): string { return 'Dummy 2FA'; }
public function enable(User $user): void { self::$enabled[$user->id] = true; }
public function disable(User $user): void { unset(self::$enabled[$user->id]); }
public function verify(User $user, string $code): bool { return true; }
public function isEnabled(User $user): bool { return self::$enabled[$user->id] ?? false; }
}
The verify() that simply returns true is an explicit simplification for demonstration purposes. A real method would check the code against a shared secret or an external service at this point.
Dispatch strategy
protected function registerDispatchStrategy(): void
{
app(DispatchStrategyRegistry::class)->register('first_available', FirstAvailableStrategy::class);
}
A dispatch strategy determines the algorithm by which a job is assigned to a driver. The demo strategy simply picks the first driver in the list:
class FirstAvailableStrategy implements DispatchStrategyInterface
{
public function assign(Job $job, Collection $drivers): ?User
{
return $drivers->first();
}
public function canHandle(Job $job): bool { return true; }
public function label(): string { return 'First Available (Demo)'; }
}
canHandle() lets a strategy decide whether it is applicable to a particular job at all. This allows multiple strategies to coexist, each handling a different subset of jobs. The connection to job scheduling is in Weather & scheduling.
Web routes
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('admin/example')
->name('admin.example.')
->group(function () {
Route::get('settings', [ExampleSettingsController::class, 'index'])
->name('settings');
});
}
A module’s web routes live under their own path prefix (admin/example) and name prefix (admin.example.). This keeps the module’s routes separate from the core’s and prevents name collisions. The auth middleware ensures that only authenticated users can reach the settings page.
API routes
protected function registerApiRoutes(): void
{
app(ModuleApiRegistrar::class)->routes('example', 1, function () {
Route::get('status', [ExampleApiController::class, 'status'])
->name('status');
});
}
// Creates: GET /api/mod/example/v1/status
// Middleware: module.api:example (Bearer token auth)
Module API routes are registered through the ModuleApiRegistrar. It automatically composes the path /api/mod/example/v1/... — with the module slug and version number in the URL — and applies the appropriate Bearer token middleware. Each module therefore gets a versioned, isolated API area. More in Routes & APIs.
Migration
Schema::create('mod_example_logs', function (Blueprint $table) {
$table->id();
$table->string('level')->default('info');
$table->text('message');
$table->json('context')->nullable();
$table->timestamps();
});
A module’s own tables carry the mod_ prefix and the module slug in the name (mod_example_logs). This naming makes it immediately visible that a table belongs to a module and not to the core. How module migrations are run and rolled back is in Database migrations.
Asset manifest
[
{"type": "css", "file": "example-abc123.css"}
]
The manifest points to the compiled frontend files in the dist/ folder. The hash in the filename (example-abc123.css) ensures browsers do not serve a new version from cache. How modules build and include assets is covered in Assets & frontend.
Translations
// lang/de/messages.php
return [
'hello' => 'Hallo aus dem Beispielmodul!',
'description' => 'Dies ist das Referenzmodul.',
'greeting' => 'Willkommen',
];
// lang/en/messages.php
return [
'hello' => 'Hello from the Example Module!',
'description' => 'This is the reference module.',
'greeting' => 'Welcome',
];
Translations are placed per language under lang/ and are loaded automatically by the ModuleManager. They are namespaced with the module slug and referenced like this:
__('example::messages.hello')
You do not need to register translations yourself — the ModuleManager handles that. More detail in Translations.
Where to go next
This reference module is designed as a lookup: every extension point appears here in one place. If you want to build your own module from scratch, the Quick start walks through a smaller example step by step. The full source code of the application is publicly available on GitHub.