Skip to main content
Wintertrace
Browse the documentation

Building modules

Permissions & roles

Register a module's own permissions and role templates so administrators can grant exactly the access your features need.

Wintertrace controls access through a role-based permission system. A module does not need to handle login, sessions, or user management — it only registers its own permissions and checks them at the right points. This page explains how the system fits together and why separating permissions, roles, and users is the right approach.

The three building blocks

The system has three layers that build on each other:

  • Permissions — granular individual capabilities, e.g. customers.view or settings.edit. A permission describes exactly one allowed action.
  • Roles — named collections of permissions, e.g. “Admin” or “Dispatcher”. A role bundles what a function in the organisation is actually allowed to do.
  • Users — assigned one or more roles.

The advantage of this separation: permissions are fine-grained and stable; roles are what changes day to day. Giving a staff member more access means changing their role, not adjusting every individual permission by hand.

How authorisation works

The path from a registered permission to a check in a controller runs through several steps:

  1. PermissionRegistry holds all available permissions (core and module).
  2. Permissions are stored in the permissions database table.
  3. Roles are stored in the roles table, linked via the role_permission pivot table.
  4. Users receive roles via a role_user pivot table.
  5. The HasRoles trait on the User model provides methods like hasPermission() and hasRole().
  6. Gate::before() grants admin users all permissions unconditionally.

Because the PermissionRegistry holds core and module permissions together, a permission registered by your module appears in exactly the same places as a core permission — in role management, in checks, everywhere.

The admin bypass

In AppServiceProvider::boot(), a check runs before all others:

Gate::before(function ($user) {
    if ($user->isAdmin()) {
        return true; // Admins bypass all permission checks
    }
});

This means admin users pass every Gate::allows() and @can() check automatically.

This is intentional: when you introduce a new permission in your module, you do not need to handle the admin case separately — admins already have access. You check against the specific permission you defined, and the bypass takes care of admins.

Registering module permissions

A module registers its own permissions through the PermissionRegistry, typically in the boot() phase of the ServiceProvider:

$permissions = app(PermissionRegistry::class);

$permissions->registerPermission(
    slug: 'documents.view',
    label: 'View documents',
    group: 'documents',
    module: 'documents', // identifies which module owns this permission
);

$permissions->registerPermission(
    slug: 'documents.upload',
    label: 'Upload documents',
    group: 'documents',
    module: 'documents',
);

$permissions->registerPermission(
    slug: 'documents.delete',
    label: 'Delete documents',
    group: 'documents',
    module: 'documents',
);

The module parameter has a practical purpose: it ties each permission to its module. When the module is removed, removeByModule() cleans up exactly those permissions — no orphaned entries remain in the permissions table.

As a convention, slugs follow the pattern group.action (e.g. documents.upload). This keeps related permissions together in the UI and makes them readable in code.

Core permissions

These permissions are registered by the application itself. They are available to any module for checking — a navigation item may reference customers.view, for example, without your module needing to define that permission itself:

GroupSlugDescription
dashboarddashboard.viewView dashboard
customerscustomers.viewView customers
customerscustomers.editEdit customers
customerscustomers.deleteDelete customers
driversdrivers.viewView drivers
driversdrivers.editEdit drivers
driversdrivers.deleteDelete drivers
vehiclesvehicles.viewView vehicles
vehiclesvehicles.editEdit vehicles
vehiclesvehicles.deleteDelete vehicles
jobsjobs.viewView jobs
jobsjobs.editEdit jobs
jobsjobs.deleteDelete jobs
workshiftsworkshifts.viewView work shifts
reportsreports.viewView reports
alertsalerts.viewView alerts
alertsalerts.resolveResolve alerts
settingssettings.viewView settings
settingssettings.editEdit settings
dsgvodsgvo.viewView GDPR info
dsgvodsgvo.editEdit GDPR settings
gpsgps.viewView GPS data
helphelp.viewView help
usersusers.viewView users
usersusers.editEdit users
usersusers.deleteDelete users
crontaskscrontasks.viewView cron tasks
crontaskscrontasks.manageManage cron tasks

Role templates

Role templates are predefined role configurations that an administrator can apply as a starting point when creating a new role. This way nobody has to assemble a sensible permission set from scratch — your module can ship a ready-made template:

$templates = app(RoleTemplateRegistry::class);

$templates->registerTemplate(
    slug: 'accountant',
    name: 'Buchhaltung',
    description: 'Zugriff auf Dokumente, Berichte und Exporte',
    permissions: [
        'dashboard.view',
        'documents.view',
        'reports.view',
        'customers.view',
    ],
    module: 'documents',
);

A template is a suggestion, not a fixed role: the administrator decides whether and how to apply it. The module parameter here also ties the template to your module.

Checking permissions in your module

Checks belong wherever an action is performed or a control is displayed. Three locations are typical.

In controllers

use Illuminate\Support\Facades\Gate;

public function index()
{
    Gate::authorize('documents.view');
    // ... render the view
}

Gate::authorize() aborts with a permission error if the user lacks the permission — the simplest way to protect the entry point of an action.

In Blade views

@can('documents.upload')
    <button>Upload Document</button>
@endcan

This shows a button only to users who are allowed to use it. Note that this does not replace the check in the controller: visibility alone is not a security measure — the actual action still needs to be protected server-side.

In code

if ($user->hasPermission('documents.view')) {
    // User has the permission through one of their roles
}

if ($user->hasRole('admin')) {
    // User has the admin role
}

hasPermission() checks the permission across all of the user’s roles.

The HasRoles trait

The User model uses the HasRoles trait, which provides:

$user->roles();                     // BelongsToMany relationship
$user->hasRole('admin'): bool;      // check role by slug
$user->assignRole('dispatcher');     // add role (slug or Role model)
$user->removeRole('dispatcher');     // remove role
$user->hasPermission('jobs.view'): bool; // check permission via any role
$user->loadPermissions(): array;     // get all permission slugs (cached)
$user->flushPermissionCache(): void; // clear in-memory cache

A user’s permissions are cached in memory (loadPermissions() returns the cached list). If you change roles or permissions at runtime, flushPermissionCache() clears this cache so the next check reflects the updated state.

User roles (enum)

The UserRole enum defines the base role types:

enum UserRole: string {
    case Admin = 'admin';
    case Driver = 'driver';
}

The User model provides two convenience methods:

$user->isAdmin(): bool;
$user->isDriver(): bool;

isAdmin() is precisely the method the admin bypass described above relies on.

Using permissions in navigation and widgets

Both the NavigationRegistry and the DashboardWidgetRegistry support permission-based visibility. This means the UI shows each user only what their permissions allow:

// Only users with 'documents.view' see this nav item
$nav->addItem(
    group: 'stammdaten',
    slug: 'documents',
    label: 'my-module::messages.nav_documents', // translation key — resolved at render time
    route: 'admin.documents.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 — see navigation-dashboard
    permission: 'documents.view',
);

// Only users with 'documents.view' see this widget
$widgets->registerWidget('recent-documents', [
    'view' => 'documents::widgets.recent',
    'permission' => 'documents.view',
]);

This is the same idea as @can in a view, one level higher: a menu item or widget disappears for everyone who lacks the required permission. The linked action still needs protection in the controller.

How a module registers permissions, role templates, and all other registries in the ServiceProvider is covered in The ServiceProvider pattern. The complete API of the registries mentioned here is in Registries.