Shield and RBAC

Authorization on Qcentic Edge is role-based, not flag-based. There is no is_admin column. The template ships spatie/laravel-permission (v8 for Laravel 13) as the model and bezhansalleh/filament-shield (free 5.x plugin) as the panel UI on top of it.

The model: Spatie on the Gate

Spatie stores roles and permissions as rows, joined through role_has_permissions, model_has_roles, and model_has_permissions. Permissions are resource-module permissions (view_any_post, create_post, update_post) plus entity policies — never a single admin flag. Every check flows through Laravel's Gate, so a Filament resource action, an API ability, and a Blade @can all read the same rows.

The UI: Filament Shield

Shield is not a second permission model. It generates the per-resource permission names, renders the Roles page in the panel, and writes the rows Spatie already owns. When you add a Filament resource, Shield's RolePolicy and generated policies answer Gate::denies() against those rows.

Template roles

The template ships two role names only:

Role Behavior
super_admin Bypasses the Gate (Shield::isSuperAdmin()). Sees every panel and resource.
user Plain authenticated user; gets only the permissions a seeder assigns.

CMS roles such as editor and author belong on a docs-stack clone (see Build a product), not on a raw template/ clone. Do not seed them into the published template.

Shield permission matrix gotcha

A RoleSeeder that only inserts role names — and leaves permissions empty — breaks the Roles UI: counts read 0 and RolePolicy 403s on every request. The seeder must generate Shield permission rows, then attach them to roles.

// In RoleSeeder
$permissions = collect(ShieldPermissionMatrix::for(['Post', 'Page']));
foreach ($permissions as $perm) {
    Permission::firstOrCreate(['name' => $perm]);
}
$editor = Role::firstOrCreate(['name' => 'editor']);
$editor->syncPermissions($permissions->only(['view_any_post', 'view_post']));

Two escape hatches when counts still read 0:

  • Set define_via_gate true (Shield checks the Gate directly), or
  • Explicitly assign ViewAny:Role to the role that manages roles.

giveSuperAdminPermission() is a no-op while super_admin gate bypass is on — the bypass short-circuits before any permission row is read.

Guards and panel access

Each panel may declare its own guard via authGuard(). Separate guards mean separate sessions and separate user pools — an admin-panel user and an app-panel user are not the same session.

Cross-panel entry is gated by the FilamentUser contract:

public function canAccessPanel(Panel $panel): bool
{
    return $this->hasRole('super_admin') || $this->hasRole('user');
}

In production this returns the real answer; locally every user passes, so you can sign in as anyone while developing.

Flow

flowchart LR
    U[User] --> R[Role]
    R --> P[Permission rows]
    P --> G[Gate]
    G --> Pol[Entity Policy]
    Pol --> A[Resource action]
    SUI[Shield Roles UI] -.reads same rows.-> P
    API[Content API ability] -.same policies.-> Pol

Shield's Roles UI and the Content API both read the same permission rows — there is one source of truth. API abilities map 1:1 to module permissions; see Content API keys and abilities.

See also

Built by Qcentic