sourceboat/laravel-static-permission
Laravel Static Permission lets you define permissions in code or configuration instead of database tables. Check access with familiar Gate, middleware, and Blade helpers—ideal for apps with fixed, predictable authorization rules and no permission-table overhead.
Install it alongside the permission backend it abstracts — in practice this is almost always spatie/laravel-permission, since the package is a thin authoring/sync layer rather than a full ACL implementation:
composer require sourceboat/laravel-static-permission
php artisan vendor:publish --tag=static-permission
Publish the config first and read it end-to-end before touching anything else — that file is where your roles and permissions actually live, and it is the closest thing this package has to documentation. Most confusion ("where do I add a role?", "why isn't my permission showing up?") is answered by the config's array shape.
The first real use case is replacing DB-seeded permissions with a config-driven source of truth:
// config/static_permission.php (shape varies by version; check the published file)
return [
'permissions' => [
'posts.view', 'posts.create', 'posts.update', 'posts.delete',
'users.manage',
],
'roles' => [
'admin' => ['*'],
'editor' => ['posts.*'],
'viewer' => ['posts.view'],
],
];
Then sync it into whatever tables the underlying permission package uses:
php artisan static-permission:sync # command name depends on version — check `php artisan list`
From that point on the developer workflow is normal Laravel authorization: $user->can('posts.update'), $user->hasRole('editor'), @can in Blade, can: middleware on routes. The package does not change how you check permissions — only how you declare them.
Config-as-source-of-truth on deploy. Commit the config, and call the sync command as part of your deploy pipeline (after migrate, before any code that assigns roles to users). This is the central pattern the package exists to support: reviewers see permission changes in diffs, and environments converge automatically.
# deploy step
- php artisan migrate --force
- php artisan static-permission:sync
- php artisan cache:forget spatie.permission.cache # if not handled internally
Role definitions as groups, not per-user grants. Define roles with wildcard/grouped permission lists and assign roles to users at the application layer (registration flows, admin UI, seeders for demo data). Keep user→role assignment out of the static config — it's per-instance state, not deployable code.
$user->assignRole('editor');
Pair with Policies for row-level rules. Static permissions answer "may this user ever update posts?"; policies answer "may they update this post?". Use the permission as the first gate in the policy, then layer ownership checks:
public function update(User $user, Post $post): bool
{
return $user->can('posts.update')
&& ($user->hasRole('admin') || $post->author_id === $user->id);
}
Environment-specific maps. Where the config supports it, vary the role/permission map per environment (e.g. richer roles in local/demo) by keeping environment overrides in .env-selected config files, or by conditionally reading from an env var. Don't fork the config wholesale — you'll drift.
Testing pattern. In setUp() of feature tests, run the sync (or call the underlying registrar) so the test database has the permissions your gates expect. Tests that assert $user->can(...) will silently pass false if the permission row was never created — that's the most common false-negative in this style of setup.
can() returns false for unknown abilities rather than erroring. If a feature "works locally but not in prod," check whether the deploy ran sync before rolling out the code that checks the new ability.prune/delete_* option.spatie/laravel-permission caches the permission map. If your sync command doesn't flush it automatically, you'll see stale results after deploying a config change. Wire cache:forget spatie.permission.cache (or the modern equivalent) into the sync flow.php artisan config:cache, the static config is frozen at cache time. A deploy that caches config before pulling the new file will sync the old map — order your deploy steps explicitly.web won't resolve for an api guard user; check the config's guard key.posts.* is typically expanded by the package at sync time, not resolved at check time. can('posts.update') only works because the wildcard was expanded into a concrete row — so verify expansion happened if checks fail.composer.json and re-read the published config after any upgrade rather than assuming backward compatibility.How can I help you explore Laravel packages today?