Permissions & Approval
Capell ships with site-scoped RBAC on top of Spatie’s laravel-permission package, plus page-type role restrictions and the workspace approval workflow (documented in PublishingStudio & Versions, supplied by the capell/publishing-studio add-on).
For an end-to-end walkthrough with a flow diagram and the email notifications sent at each transition, see Page creation and approval flow in the Publishing Studio docs.
Site-scoped roles
Section titled “Site-scoped roles”Spatie’s team feature is enabled with team_foreign_key = site_id. Every role and permission assignment is scoped to a single site, so an editor for Site A does not implicitly have the same role on Site B.
- Middleware —
SetSitePermissionScopesets the active Spatie team to the current request’ssite_idbefore any authorization check runs. Registered on the admin route group. - Trait — user models use
HasSitePermissions, which wraps Spatie’s role/permission helpers with site-aware variants (e.g.assignRoleOnSite,hasRoleOnSite). - Admin — the
Siteedit page has a Permissions relation manager where you add users, assign roles for this site, and grant per-user overrides.
Page-type role restrictions
Section titled “Page-type role restrictions”The page_role_restrictions table uses a polymorphic restrictable morph so restrictions are defined once per page type rather than per page. On the page type configurator admin tab a multi-select syncs the allowed roles for that page type. Users without any of the listed roles cannot see or edit pages of that type, enforced through PagePolicy.
Model: Capell\Core\Models\PageRoleRestriction.
Approval workflow
Section titled “Approval workflow”Approvals are tracked at the workspace level, not per page. A workspace collects all edits that should ship together and moves through the state machine open → in_review → approved → publishing → published. WorkspaceApproval is the immutable audit row for every submit / approve / reject event, supporting multi-level approval via required_approval_levels.
See PublishingStudio & Versions — Approval lifecycle for the full machine and the Filament actions that drive it.
Policies
Section titled “Policies”Gate-based access control throughout the admin:
| Policy | Model (Shield subject) | Covers |
|---|---|---|
PagePolicy | Page | view/create/update/delete/publish + page-type role restriction check |
SitePolicy | Site | view/manage sites and the Permissions relation manager |
LayoutPolicy | Layout | layout access |
NavigationPolicy | Navigation | navigation CRUD |
RedirectPolicy | PageUrl | redirect CRUD + import/export gates (redirects are stored in PageUrl) |
WorkspacePolicy | Workspace | workspace CRUD + submit/approve/publish/rollback gates |
All Filament resources resolve their actions through these policies — there are no inline Gate::allows() checks in Blade or resource classes.
Policy registration (do NOT rely on Filament-only discovery)
Section titled “Policy registration (do NOT rely on Filament-only discovery)”Policies must be globally registered via Gate::policy() in AdminServiceProvider::registerPolicies(). Capell’s policies live in Capell\Admin\Policies\* and gate models in Capell\Core\Models\*; Laravel’s convention-based resolver looks for App\Policies\{Model}Policy and will not find them. Filament’s resource system wires them up for requests going through a panel — but anything invoked outside that context (Actions dispatched from CLI, bulk actions, queued jobs, tests) goes through the raw Gate facade, which returns “denied by default” for every ability when the policy isn’t globally registered.
The bulk-move-pages bug (April 2026) was this exact mistake: only WorkspacePolicy was registered, so every other policy silently returned denied for bulk actions. PolicyRegistrationTest (tests/Admin/Feature/Policies/PolicyRegistrationTest.php) locks the registration contract.
Permission naming — Shield configuration matters
Section titled “Permission naming — Shield configuration matters”Spatie permission names are generated by Filament Shield using the host app’s config/filament-shield.php:
permissions.case(defaultpascal) — controls the casing of the affix and subject.permissions.separator(default:) — the joiner between affix and subject.
With the defaults, the permission for PagePolicy::update() is Update:Page. Legacy Shield setups used lower_snake + _, producing update_page. Policies must not hardcode either format — $user->hasPermissionTo('update_page') throws PermissionDoesNotExist if the host uses the other convention, and the Gate layer swallows the exception as “denied”, hiding the real cause.
Use the ResolvesShieldPermission trait (packages/admin/src/Policies/Concerns/ResolvesShieldPermission.php) in every policy:
use Capell\Admin\Policies\Concerns\ResolvesShieldPermission;
class PagePolicy{ use ResolvesShieldPermission;
private const SUBJECT = 'Page';
public function update(User $user, Page $page): bool { return $user->hasPermissionTo(self::permission('update', self::SUBJECT)) && $page->isAccessibleByUser($user); }}Custom abilities (e.g. manage_restrictions on PagePolicy, import / export on RedirectPolicy, update_own / manage_permissions on SitePolicy) produce permission names like ManageRestrictions:Page — these are not auto-generated by Shield’s resource scan. Host apps must register them in filament-shield.php → custom_permissions.
Advanced presentation permission
Section titled “Advanced presentation permission”presentation.manage_advanced gates advanced presentation and delivery controls. Users without it can still use the basic editor-facing presentation and interaction controls, but they do not see lower-level delivery settings such as lazy fragment delivery, loading strategy, connection requirement, viewport range, or custom width.
The install and upgrade permission sync grants this permission to super_admin by default. Assign it deliberately to editors who understand delivery/performance tradeoffs.
Related files
Section titled “Related files”| Concern | File |
|---|---|
| Middleware | packages/admin/src/Http/Middleware/SetSitePermissionScope.php |
| User trait | packages/core/src/Models/Concerns/HasSitePermissions.php |
| Page-type restriction | packages/core/src/Models/PageRoleRestriction.php |
| Workspace approval model | packages/core/src/Models/WorkspaceApproval.php |
| Approval action enum | packages/core/src/Enums/WorkspaceApprovalActionEnum.php |
| Filament approval actions | packages/admin/src/Filament/Resources/PublishingStudio/Actions/{SubmitForApproval,Approve,Reject}Action.php |
| Policies | packages/admin/src/Policies/*Policy.php |