Policies¶
Laravel policies enforce role-based access control at the record level. Each resource class has a dedicated policy that is auto-discovered by Laravel via naming convention and automatically consulted by Filament v5.
This page is the mechanical reference: class hierarchy, resource keys, gate signatures, and the record-level scope each gate applies. For the conceptual model — which roles exist, what each one may do, and why the set of records a person reaches is derived from the connection graph rather than assigned to them — see Roles and Permissions.
Class Hierarchy¶
AbstractResourcePolicy
├── UserPolicy
├── TenantPolicy
├── SystemPolicy
├── SystemConnectionPolicy
└── SyncRulePolicy
All concrete policies extend App\Policies\AbstractResourcePolicy.
AbstractResourcePolicy¶
Namespace: App\Policies\AbstractResourcePolicy
Provides the standard CRUD gate methods. Each method composes a Spatie permission check against {resourceKey()}.{action} with an optional record-level access check.
| Method | Signature | Behaviour |
|---|---|---|
viewAny |
(User $user): bool |
{key}.view permission only — no record check. |
view |
(User $user, Model $model): bool |
{key}.view permission and canAccessRecord(). |
create |
(User $user): bool |
{key}.create permission only. |
update |
(User $user, Model $model): bool |
{key}.update permission and canAccessRecord(). |
delete |
(User $user, Model $model): bool |
{key}.delete permission and canAccessRecord(). |
Abstract / overridable members¶
| Member | Kind | Purpose |
|---|---|---|
resourceKey(): string |
abstract | Returns the permission-name prefix (e.g. 'tenants'). |
canAccessRecord(User $user, Model $model): bool |
protected, overridable | Record-level gate; default implementation returns true. |
viewAny intentionally skips canAccessRecord — list-level scoping is handled by accessibleBy() model scopes, not by the policy.
Gate overview¶
| Policy | Resource key | Record gate on view |
Record gate on update / delete |
Further gates |
|---|---|---|---|---|
UserPolicy |
users |
none | none | changeRole |
TenantPolicy |
tenants |
Tenant::accessibleBy() |
Tenant::accessibleBy() |
— |
SystemPolicy |
systems |
none | create, update and delete always return false |
— |
SystemConnectionPolicy |
system_connections |
SystemConnection::accessibleBy() |
SystemConnection::fullyAccessibleBy() |
resync, gated on fullyAccessibleBy() |
SyncRulePolicy |
sync_rules |
SyncRule::withTrashed()->accessibleBy() |
SyncRule::withTrashed()->manageableBy() — also gates restore and forceDelete |
create, deleteAny, restoreAny, forceDeleteAny, each via canManageAnyRule() |
viewAny and create check the permission alone wherever the table does not say otherwise. A record gate reached through canAccessRecord() applies to every standard gate at once; where a policy instead overrides the individual gate method, the section below says so.
Which role holds which of those permissions is in Roles and Permissions.
UserPolicy¶
Namespace: App\Policies\UserPolicy
Resource key: users
Inherits all standard CRUD gates without overriding canAccessRecord — any user holding the required permission can access any user record. In practice that is SuperAdmin alone, because no other role holds a users.* permission.
Additional gate: changeRole¶
Gates any role change — assignment on create as well as change on edit, not stripping alone. Returns false when $user->is($model) and the acting user holds SystemRole::SuperAdmin. Otherwise delegates to the users.update permission.
This is a lockout guard — it ensures at least one SuperAdmin can always access the system. Both CreateUser::afterCreate() and EditUser::afterSave() consult it before calling syncRoles(); on denial, each sends a Notification::make()->danger() and calls $this->halt() rather than silently skipping the role sync. operatedSystems is synced unconditionally either way, so a denied role change does not block the rest of the save.
TenantPolicy¶
Namespace: App\Policies\TenantPolicy
Resource key: tenants
Overrides canAccessRecord only, so all four standard gates share one record check: the tenant must lie within the user's derived reach.
protected function canAccessRecord(User $user, Model $model): bool
{
if (! $model instanceof Tenant) {
return false;
}
return Tenant::accessibleBy($user)->whereKey($model)->exists();
}
Tenant::accessibleBy() short-circuits for SuperAdmin and otherwise resolves to every tenant holding a connection to a system the user operates — see Tenant reach is derived.
SystemPolicy¶
Namespace: App\Policies\SystemPolicy
Resource key: systems
create, update and delete are overridden to return false unconditionally, SuperAdmin included. systems.view is the only permission that exists for the resource, and canAccessRecord is not overridden.
Systems are seeder-owned because a System carries the direction matrix that sync rules are validated against; a panel-editable matrix could claim a direction the integration cannot perform, and rule validation would then enforce that fiction. The restriction lives in the policy rather than in the absence of buttons. See Systems are read-only.
SystemConnectionPolicy¶
Namespace: App\Policies\SystemConnectionPolicy
Resource key: system_connections
The only policy where reading and changing use different reach. view uses the inherited canAccessRecord() and admits any connection under a reachable tenant. update, delete and resync are overridden to call canFullyAccessRecord() instead, which admits only connections pointing at a system the user operates.
protected function canAccessRecord(User $user, Model $model): bool
{
if (! $model instanceof SystemConnection) {
return false;
}
return SystemConnection::accessibleBy($user)->whereKey($model)->exists();
}
protected function canFullyAccessRecord(User $user, Model $model): bool
{
if (! $model instanceof SystemConnection) {
return false;
}
return SystemConnection::fullyAccessibleBy($user)->whereKey($model)->exists();
}
A support user therefore sees a tenant's WooCommerce connection and its status, but cannot edit it, delete it or trigger a resync on it. See ADR-0007.
Additional gate: resync¶
Checks the system_connections.resync permission and canFullyAccessRecord(). It is the only write-shaped action PlatformSupport holds on a connection; that role has no system_connections.create, .update or .delete.
SyncRulePolicy¶
Namespace: App\Policies\SyncRulePolicy
Resource key: sync_rules
view uses the inherited canAccessRecord() over SyncRule::withTrashed()->accessibleBy(), so the rule's tenant must be reachable. update, delete, restore and forceDelete are all overridden to call canManageRecord() over SyncRule::withTrashed()->manageableBy(), which requires at least one of the rule's two connections to be one the user directly operates (SystemConnection::operatedBy()) — not merely fully accessible, so a shop connection's ADR-0030 cascade access cannot make a rule touching a status-only connection manageable. withTrashed() is there so a soft-deleted rule stays reachable by the user who may restore or force-delete it; for a live rule it changes nothing, since whereKey() pins the row either way. See ADR-0012.
protected function canAccessRecord(User $user, Model $model): bool
{
if (! $model instanceof SyncRule) {
return false;
}
return SyncRule::withTrashed()->accessibleBy($user)->whereKey($model)->exists();
}
protected function canManageRecord(User $user, Model $model): bool
{
if (! $model instanceof SyncRule) {
return false;
}
return SyncRule::withTrashed()->manageableBy($user)->whereKey($model)->exists();
}
Overridden gates: create, deleteAny, restoreAny, forceDeleteAny¶
None of these four receive a model, so the one-accessible-side rule cannot be checked per-record here. Each asks the weaker question instead, through a shared private helper: does this user hold the relevant permission, and do they have at least one fully accessible connection at all?
public function create(User $user): bool
{
return $user->hasPermissionTo("{$this->resourceKey()}.create")
&& $this->canManageAnyRule($user);
}
private function canManageAnyRule(User $user): bool
{
if ($user->hasRole(SystemRole::SuperAdmin->value)) {
return true;
}
return SystemConnection::fullyAccessibleBy($user)->exists();
}
deleteAny, restoreAny and forceDeleteAny follow the same shape and all gate sync_rules.delete through canManageAnyRule(). They back the header-level authorization for the delete, restore and force-delete actions on the rule page; delete(), restore() and forceDelete() above make the per-record decision once an action is available.
The rule's actual source/target pair is validated separately — by ValidSyncRuleFlow and ManageableSyncRulePair on the form, and by SyncRuleObserver::saving() on save. See Sync Rules.
Adding a Policy for a New Resource¶
- Create
app/Policies/{Resource}Policy.phpextendingAbstractResourcePolicy. - Implement
resourceKey()returning the permission-name prefix. - Seed the corresponding permissions (
{key}.view,{key}.create,{key}.update,{key}.delete) inRolesAndPermissionsSeeder, and grant them to the roles that need them. - If the resource is tenant-scoped, override
canAccessRecordfollowing theTenantPolicypattern. If reading and changing need different reach, override the individual gate methods followingSystemConnectionPolicy. - No manual registration is required — Laravel auto-discovers policies by naming convention.
See also¶
- Roles and Permissions — the roles, the permission matrix, and why reach is derived
- ADR-0007 — deriving admin access from system membership
- ADR-0012 — the one-accessible-side rule for sync rules