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 | removeRole |
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::accessibleBy() |
SyncRule::manageableBy() |
create, with an extra pre-check |
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: removeRole¶
Prevents a SuperAdmin from stripping their own super_admin role. 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. EditUser::afterSave() consults it before calling syncRoles().
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::accessibleBy(), so the rule's tenant must be reachable. update and delete are overridden to call canManageRecord() over SyncRule::manageableBy(), which requires at least one of the rule's two connections to be fully accessible. See ADR-0012.
protected function canAccessRecord(User $user, Model $model): bool
{
if (! $model instanceof SyncRule) {
return false;
}
return SyncRule::accessibleBy($user)->whereKey($model)->exists();
}
protected function canManageRecord(User $user, Model $model): bool
{
if (! $model instanceof SyncRule) {
return false;
}
return SyncRule::manageableBy($user)->whereKey($model)->exists();
}
Overridden gate: create¶
create receives no model, so the one-accessible-side rule cannot be checked there. It asks the weaker question instead: does this user hold sync_rules.create, and do they have at least one fully accessible connection at all?
public function create(User $user): bool
{
if (! $user->hasPermissionTo("{$this->resourceKey()}.create")) {
return false;
}
if ($user->hasRole(SystemRole::SuperAdmin->value)) {
return true;
}
return SystemConnection::fullyAccessibleBy($user)->exists();
}
The rule's actual source/target pair is validated by the form and by SyncRule::booted() 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