Roles And Permissions
The permission system is built on spatie/laravel-permission and driven by config/permission-resources.php.
Core Idea
Permissions are generated from resource names and abilities defined in config. Three sources contribute:
resources— standard CRUD-style resourcessub_resources— scoped variants under a parent resourcecustom_permissions— one-off entries that don't fit the resource pattern
Examples from the current project:
users.readusers.updateroles.updatesettings.updatefiles.readfiles.createfiles.updatefiles.deleteactivity-logs.readpulse.readapi-docs.read
Sub-resources are also supported:
users:student.readusers:guardian.update
Main Config Sections
resources— define resource names and which CRUD abilities to generatesub_resources— nested variants with their own permission stringscustom_permissions— arbitrary permission entries outside the resource modelpermission_groups— group permissions together for the Roles admin UIrole_groups— group roles together in the UIrole_permissions— seed which roles get which permissions by defaultdisplay_names— human-readable labels for the Roles admin UI
Default Roles
system_adminadminuser
system_admin is intended to bypass the normal permission restrictions.
Default role permissions are also defined in config/permission-resources.php.
FileManager abilities
FileManager's built-in global context checks four independent abilities; holding one does not imply any other:
| Permission | Allows |
|---|---|
files.read |
Browse trees, list favorites/trash, and download files |
files.create |
Upload files, create folders, and copy files |
files.update |
Rename/move items, change favorites, restore trash, and pass the context check for share/revoke |
files.delete |
Delete items, empty trash, and permanently delete trashed items |
After changing role assignments, run php artisan sk:seed-permissions so the seeded permission data matches the four-ability contract.
Role Hierarchy In User Management
User management is hierarchy-aware on both the admin panel and the API:
RoleSelectOptionsQueryonly returns roles the current actor is allowed to assignsystem_admincan assign any role- non-
system_adminusers only see roles at their own rank or lower - users who hold direct permissions but no role are treated as the lowest rank and cannot assign roles through the admin user flow
UpdateUserRequest::authorize()blocks lower-ranked users from editing higher-ranked targets even if they holdusers.updateUserDatatableQuery(used by both the admin user listing andGET /api/v1/users) hides users whose minimum rolesort_orderis lower than the actor's — so a non-system_adminAPI consumer withusers.readcannot enumerate higher-rank usersAdmin/RoleController::data(the JSON sibling ofedit) andAdmin/RoleController::editanddestroyall run the sameCanManageRoleQuerycheck, so a lower-rank admin cannot read higher-rank role JSON via the prefetch endpoint either
Regenerating Permissions
database/seeders/_01_RolePermissionSeeder.php is responsible for:
- creating configured permissions
- creating sub-resource permissions
- creating custom permissions
- removing orphaned permissions that no longer exist in config
- seeding and updating default roles
After editing permission config, rebuild the seeded data:
php artisan sk:seed-permissions --fresh
The admin panel also exposes a permission sync action for system_admin users.
Keeping the matrix in step with package updates
sk:update never writes to config/permission-resources.php — the file is yours, and an updater that merged into it would eventually overwrite a project's own authorization model. The consequence is that a resource or ability the kit adds in a later release does not reach an existing installation by itself, and the first sign of that is usually a 403 on a screen that used to work.
After an update, ask:
php artisan sk:doctor --only=permission-matrix
The check lists every resource and ability the package ships that your config does not declare (resources you added yourself are never reported). Add the listed entries by hand, then run php artisan sk:seed-permissions.
Automatic Route-to-Permission Mapping
Lvntr\StarterKit\Http\Middleware\CheckResourcePermission (vendor: vendor/lvntr/laravel-starter-kit/src/Http/Middleware/CheckResourcePermission.php) converts route names into permission strings automatically.
Examples:
users.index -> users.readusers.store -> users.createusers.edit -> users.updateusers.destroy -> users.delete
If an explicit permission is passed in middleware, that value is used directly.
Sub-Resource Support
The middleware also supports sub-resource permissions through the type query parameter.
Example:
- route permission:
users.read - current URL:
/users?type=student - resolved permission:
users:student.read
This only applies if the scoped permission exists in the database.
Frontend Usage
Composable
Use @/composables/useCan inside pages and components:
const { can, canAny, hasRole } = useCan();
Vue Directives
The frontend permission plugin registers:
v-canv-role
Examples:
<Button v-can="'users.create'" />
<Button v-can:any="['users.create', 'users.update']" />
<div v-role="'system_admin'">Only for system admins</div>
FormBuilder Form-Level Permission
SkForm also accepts .permission('users.update') to put the whole form into read-only mode. If the user lacks the ability, every field is disabled and the submit button is hidden. See the FormBuilder guide for details.
DataTable Row Actions
Each row action and menu action on SkDatatable supports a .visible(() => can('users.update')) callback — the button is not rendered at all when the user is not authorized.
Middleware Mapping
The project maps route intent to permission checks. A route name like users.index typically resolves to a users.read permission check. Routes protected with check.permission middleware benefit from this automatic resolution.
If the resolved permission does not exist in the database, the middleware's behavior depends on app()->environment() (fail-closed by default, since v13.6.9):
local: allow the request and log a warning, so day-to-day development is not blocked by a not-yet-seeded permission- every other environment —
production,staging,uat,demo,testing, etc.: deny the request with403so a forgotten permission row never silently exposes a route on a public host
Opt-out: set config('starter-kit.permissions.allow_unmapped') to true (env STARTER_KIT_ALLOW_UNMAPPED_PERMISSIONS=true) to restore the pre-v13.6.9 "allow on any non-production environment" behavior. production always denies regardless of this flag. See UPGRADE.md for the full migration note.
Unmapped vs. Unresolved
The two failure modes above are easy to conflate but are governed by separate config keys:
allow_unmapped— a permission WAS derived from the route name (admin.users.index→users.read), but no row with that name is seeded in the database. Covered above.allow_unresolved(configstarter-kit.permissions.allow_unresolved, envSTARTER_KIT_ALLOW_UNRESOLVED_ROUTES, defaulttrue) — NO permission could be derived at all: the route has no name, its name has fewer than two segments, or its action segment is not in the middleware's ability map. Historically this passed in total silence; with the defaulttrueit still passes, but the middleware logs a throttled warning naming the route so the gap is visible. Setting it tofalsedenies the request instead.php artisan sk:doctor --only=unresolved-routeslists every route currently in this state.
Production asymmetry, deliberate: unlike allow_unmapped, which production always clamps to deny, allow_unresolved keeps applying in production once flipped to false. An unmapped permission is a per-host data gap (fixed by seeding the row); an unresolved route is a structural mismatch between the route table and the ability map, fixable only by renaming the route or shipping code — so the escape hatch has to remain available on the host where the flip could otherwise lock out a route.
starter-kit.permissions.unrestricted_routes lists route-name patterns (Str::is wildcards, e.g. 'api.v1.auth.*') that are deliberately permission-free: they pass with no warning and are never denied, regardless of allow_unresolved. It is consulted only on the unresolved axis — it can never exempt a route whose permission does resolve — and it is checked once per request rather than for every pattern, so keep entries tight (list endpoints, not whole trees) to avoid silently exempting routes added later.
Who gets which default: sk:install writes STARTER_KIT_ALLOW_UNRESOLVED_ROUTES=false into a new project's .env, so a fresh app is fail-closed from the first request. An app that does not set the key falls through to the package's own constant, which is true — and no release changes that on its own, because a published config predating the key lands on the same constant and flipping it would alter authorization on a plain composer update. An existing install opts in by writing the line itself. See UPGRADE.md for the ordered remediation path to run first.
Octane / Long-Running Worker Deployments
CheckResourcePermission caches the seeded permission-name set with Cache::remember() under a short TTL (60 seconds) rather than for the whole request or worker lifetime. Both php artisan sk:seed-permissions and the Roles screen's permission sync (RoleController::syncPermissions() → SyncPermissionsAction) call CheckResourcePermission::flushCache() immediately after seeding, so a freshly seeded permission is honored at once instead of waiting out the TTL.
This makes the kit Octane-safe out of the box — no RequestReceived listener or manual cache-clearing workaround is required, on Octane (Swoole / RoadRunner) or standard PHP-FPM alike.
Residual caveat: if your cache store is per-process rather than centrally shared (for example the array driver), a worker other than the one that performed the seed/sync can still serve a stale permission-name list for up to the 60-second TTL — the short TTL exists specifically to bound that window.
Login Status Check
API login (POST /api/v1/auth/login) validates not only the credentials but also the user's status. LoginUserAction:
- Attempts
Auth::attempt()with the supplied credentials. - On success, inspects
user.status. - Any value other than
active(e.g.inactive,banned) triggersAuth::logout()and the action returnsnull.
The controller responds with 401 Invalid email or password in that case — so banned or inactive accounts cannot obtain a token even with a valid password.
Menu Filtering
useAdminMenu() defines the project-specific admin navigation tree, and useMenuBuilder() filters visible items based on the current user's permissions and roles.
Query-aware active state handling also lives in useMenuBuilder(), so links like /users?type=student can highlight the correct menu entry.
Practical Workflow for Adding a New Protected Area
- Add the resource and abilities to
config/permission-resources.php. - Run
php artisan sk:seed-permissions --fresh. - Protect the routes with
check.permission. - Use
useCan()orv-canin the frontend where needed.
Authorization Layers
The starter kit uses three stacked layers. They do not replace each other — pick the layer that matches the granularity you need.
| Layer | Where | Granularity | Example |
|---|---|---|---|
| 1. Route middleware | check.permission in route definitions |
Per route, broad permission (users.read) |
Route::get('/admin/users', …)->middleware('check.permission') |
| 2. Laravel Policy | app/Policies/*Policy.php |
Per model instance, optional row-level rules | $this->authorize('update', $role) |
| 3. FileManager ContextRegistry | Lvntr\StarterKit\Domain\FileManager\Support\ContextRegistry (vendor) |
Per pluggable FileManager context (owner model + custom rules) | Closure passed when registering a context |
When to use which
- Middleware only is enough for flat admin CRUD where any user with the permission may act on any row.
- Add a Policy when you need row-level rules (self-ownership, state-based gating, tenant scoping). Policies are auto-discovered:
App\Models\Foo→App\Policies\FooPolicy. - Register a FileManager context when a domain wants to expose a files tab bound to one of its models (users, organisations, projects). The context's authorizer closure controls
read,create,update, anddeleteaccess without duplicating logic in a controller.
Policy pattern
Policy methods accept the authenticated User as the first argument and the target model as the second. Keep checks permission-first; add self/state logic only when required.
namespace App\Policies;
use App\Models\Role;
use App\Models\User;
class RolePolicy
{
public function viewAny(User $actor): bool
{
return $actor->can('roles.read');
}
public function update(User $actor, Role $role): bool
{
// Add row-level rules here if needed, e.g. tenant scoping.
return $actor->can('roles.update');
}
}
The kit ships policies for User, Role, Setting, and FileFolder. They are additive — middleware-guarded routes keep working even if a controller never calls authorize().
FileManager ContextRegistry
ContextRegistry exposes a pluggable authorization hook: each file context (e.g. user, or a custom project context added by the host app) provides a closure that receives read, create, update, or delete. The kit never passes the deprecated write name. The default user-owned context delegates read to UserPolicy@view and every mutation to UserPolicy@update, so one policy drives both explicit authorize() calls and the files tab guard.
Keep context closures thin; delegate real rules to a Policy so logic stays in one place.