Browse the documentation
Building modules
Routes & APIs
Add web and API routes from a module: the registrar, middleware, naming, and how module routes coexist with the core.
A module that provides its own screens or integrations needs its own routes. Wintertrace builds on Laravel’s standard routing — there is no module-specific routing language to learn on top. This page shows how to register web routes for admin, portal, and driver areas, and how to provide token-protected API endpoints via the ModuleApiRegistrar.
Registering web routes
A module registers web routes in the boot() method of its ServiceProvider. This is deliberately the same place where navigation, widgets, and settings are registered — all of a module’s extension points are in one location.
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('admin/my-module')
->name('admin.my-module.')
->group(function () {
Route::get('settings', [MySettingsController::class, 'index'])
->name('settings');
Route::post('settings', [MySettingsController::class, 'update'])
->name('settings.update');
});
}
Route naming convention
Route names follow a fixed pattern. The reason: other parts of the application and other modules refer to your pages via route('admin.my-module.settings'). Keeping to the pattern makes these references predictable and avoids collisions.
| Pattern | Example |
|---|---|
| Admin pages | admin.{module-slug}.{action} |
| Admin resource | admin.{module-slug}.{resource}.{action} |
The {slug} in the route name is always the module slug — the same identifier used for settings and API routes. This makes routes unambiguously attributable to a module.
Middleware
Middleware determines who can reach a route at all. Choose it based on the route’s intended audience, not habit — a driver route protected only by auth would also be accessible to admins and customers.
| Middleware | Purpose |
|---|---|
web | Session, CSRF, cookies |
auth | Authenticated users only |
auth:customer | Customer portal guard |
module.api:{slug} | Token authentication for module APIs |
Admin routes
Most module routes serve the admin backend. A full resource set typically looks like this:
Route::middleware(['web', 'auth'])
->prefix('admin/documents')
->name('admin.documents.')
->group(function () {
Route::get('/', [DocumentController::class, 'index'])->name('index');
Route::get('/create', [DocumentController::class, 'create'])->name('create');
Route::post('/', [DocumentController::class, 'store'])->name('store');
Route::get('/{document}', [DocumentController::class, 'show'])->name('show');
Route::get('/{document}/download', [DocumentController::class, 'download'])->name('download');
Route::delete('/{document}', [DocumentController::class, 'destroy'])->name('destroy');
});
Portal routes (customer-facing)
If your module extends the customer portal, protect routes with the auth:customer guard and the EnsureCustomer middleware. This ensures customers can only reach areas that have been released for them — and only when the portal is enabled at all.
Route::middleware(['web', 'auth:customer', EnsureCustomer::class])
->prefix('portal/documents')
->name('portal.documents.')
->group(function () {
Route::get('/', [PortalDocumentController::class, 'index'])->name('index');
Route::get('/{document}/download', [PortalDocumentController::class, 'download'])->name('download');
});
Driver routes
Routes for the driver interface combine EnsureDriver with EnsureDsgvoInformed. The second guard ensures that the driver has confirmed the data protection notice before reaching any page that processes personal data — a deliberate step, not an optional extra.
Route::middleware(['web', 'auth', EnsureDriver::class, EnsureDsgvoInformed::class])
->prefix('driver/my-feature')
->name('driver.my-feature.')
->group(function () {
Route::get('/', [DriverFeatureController::class, 'index'])->name('index');
});
Module API routes
Alongside web routes, a module can provide machine-readable endpoints — for example a webhook or a status endpoint called by an external system. The ModuleApiRegistrar handles this. It creates authenticated API endpoints with a consistent URL and naming structure, so you do not have to manage versioning and token validation yourself.
Scope note: This covers the module-specific API mechanism that is present and usable today. A broader, public REST API for the application is a separate item and currently planned — it is not the subject of this page.
Registration
protected function registerApiRoutes(): void
{
$registrar = app(ModuleApiRegistrar::class);
$registrar->routes('my-module', 1, function () {
Route::get('status', [MyApiController::class, 'status'])->name('status');
Route::post('webhook', [MyApiController::class, 'webhook'])->name('webhook');
});
}
The second parameter (1) is the API version. It is part of the generated URL — this lets you add a v2 later without breaking existing integrations that still point to v1.
Generated URL structure
From the registration above, the registrar builds paths using this schema:
/api/mod/{slug}/v{version}/{endpoint}
The example above produces /api/mod/my-module/v1/status. The slug and version are embedded in the URL, making it immediately clear from outside which module and version is responding.
Route names
API route names follow the pattern module.{slug}.api.v{version}.{name}, so for example module.my-module.api.v1.status. The fixed schema ensures that API routes from different modules never collide.
API authentication
Module API routes are protected by the module.api:{slug} middleware (AuthenticateModuleApi) — they are not publicly accessible and require a valid token. The process on each request:
- Expects a Bearer token in the
Authorizationheader - Validates the token hash against the
module_api_tokenstable - Checks token abilities and expiration
- Returns 401 JSON if invalid
Authorization: Bearer <token>
API token model
Only the token hash is stored, never the plaintext token. This is why a token is displayed once after creation and cannot be recovered afterwards — the original cannot be derived from the hash.
// ModuleApiToken fields:
module_slug // which module this token belongs to
name // human-readable token name
token_hash // SHA-256 hash of the actual token
abilities // JSON array of allowed abilities
expires_at // optional expiration datetime
last_used_at // updated on each use
Tokens are managed through the admin UI (AdminModuleApiTokenController) — you do not need to create them directly in the database.
API controller example
An API controller typically returns JSON. Validate incoming data before processing it — validate() rejects unsuitable requests with a clear error response rather than letting them through unchecked:
namespace Schneespur\Module\MyModule\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MyApiController
{
public function status(): JsonResponse
{
return response()->json([
'status' => 'ok',
'module' => 'my-module',
'version' => '1.0.0',
]);
}
public function webhook(Request $request): JsonResponse
{
$payload = $request->validate([
'event' => 'required|string',
'data' => 'required|array',
]);
// Process webhook...
return response()->json(['received' => true]);
}
}
Core middleware reference
Before writing custom middleware, check the existing guards — many access rules are already covered:
| Middleware | Class | Purpose |
|---|---|---|
EnsureAdmin | App\Http\Middleware\EnsureAdmin | Require admin role |
EnsureDriver | App\Http\Middleware\EnsureDriver | Require driver role |
EnsureCustomer | App\Http\Middleware\EnsureCustomer | Require customer guard auth + portal enabled |
EnsureDsgvoInformed | App\Http\Middleware\EnsureDsgvoInformed | Require GDPR acknowledgment |
AuthenticateOwntracks | App\Http\Middleware\AuthenticateOwntracks | HTTP Basic for OwnTracks GPS |
AuthenticateModuleApi | App\Http\Middleware\AuthenticateModuleApi | Bearer token for module APIs |
InstallerGuard | App\Http\Middleware\InstallerGuard | Protect installer routes |
RedirectToInstaller | App\Http\Middleware\RedirectToInstaller | Redirect if not installed (robots.txt is exempt) |
SetInstallerLocale | App\Http\Middleware\SetInstallerLocale | Set installer language |
SecurityHeaders | App\Http\Middleware\SecurityHeaders | Baseline hardening headers + per-request X-Robots-Tag |
SecurityHeaders is appended to the web group, so it runs for every web response — including your module’s routes. Besides the usual hardening headers (clickjacking, MIME, referrer, HSTS) it sets X-Robots-Tag: noindex, nofollow on every response, except the public paths a frontpage module opts in through PublicHomepageRegistry. The practical consequence: serve any public module route through the web group, or it will be told not to be indexed.
Public homepage & robots.txt
A module can take over the public root URL / and expose additional public, indexable pages while the rest of the installation stays private. This is its own subsystem, built on PublicHomepageRegistry, a dynamic /robots.txt route, the X-Robots-Tag header and a <meta name="robots"> opt-in. The full model — including the matching rules and the deployment caveats for existing installations — is on its own page: Public homepage & SEO.
Writing custom middleware
If none of the existing guards fit, register a custom middleware with an alias in your ServiceProvider. Your routes then reference it by alias, exactly like the core guards:
public function boot(): void
{
$router = app('router');
$router->aliasMiddleware('my-module.check', MyModuleCheckMiddleware::class);
}
More on the ServiceProvider as the central entry point is in The ServiceProvider pattern; which roles and permissions the guards check is covered in Permissions & roles.