Authorization
Reshape's authorization is built on Spatie Permission with its Teams feature turned on, where a "team" is a Company. A permission can be granted to a user within a specific company, or globally — and a small wrapper around Spatie's API makes writing that distinction natural to read.

Scoped vs. global permission checks
Spatie Permission's teams feature works by setting a "current team ID" before checking permissions. Reshape wraps that in two expressive methods on User (app/Concerns/HasRolesPermissions.php):
// Scope every check/write inside the callback to a specific company...
$user->in(Company|int|null $company) : App\Support\Authorization\Scope
// Scope every check/write to be global (no company)...
$user->everywhere() : App\Support\Authorization\Scope$user->in($company)->hasPermissionTo(Permission::CompaniesUpdate); // scoped to $company
$user->everywhere()->hasPermissionTo(Permission::PrivilegedCompaniesUpdate); // globalA third helper merges both scopes for display purposes:
// The user's roles in $in, merged with their global roles...
$user->getRoles(Company|int|null $in = null) : Illuminate\Support\CollectionBoth return an App\Support\Authorization\Scope — a readonly decorator that sets Spatie's permission team ID for the duration of one call (hasPermissionTo, assignRole, roles(), anything Spatie's HasRoles trait exposes), then restores the previous team ID. A null team ID means global — config/permission.php has 'teams' => true with 'team_foreign_key' => 'company_id', and a null company_id on the pivot is Spatie's convention for a team-independent grant.
Direct Spatie calls
Because HasRolesPermissions pulls in Spatie's HasRoles trait directly, $user->hasPermissionTo(...) will compile — but it uses whatever team ID happens to be set, which is easy to get wrong. Always go through ->in($company) or ->everywhere() so the scope is explicit at the call site.
The permission catalog
Every permission the application knows about is a case of one enum (app/Enums/Permission.php) — 156 of them, each a dotted string. The naming convention is:
{scope}.{resource}.{action}— a company-scoped ability (e.g.companies.roles.index,users.themes.view)privileged.{resource}.{action}— a global, admin-level ability, checked with->everywhere()
// Every permission whose name starts with "privileged."...
Permission::privileged() : arrayValues are a frontend contract
Permission values are read by the frontend's own authorization logic (web/src/lib/authorization). Never rename an existing case's string value — add a new case instead. The enum's own docblock repeats this warning for exactly this reason.
The catalog is seeded into the database by Database\Preparers\PermissionPreparer, run as part of php artisan db:prepare (see Installation).
Roles
Six roles ship out of the box (app/Enums/Role.php), each mapping to a curated list of Permission cases:
// The permissions granted to this role...
$role->permissions() : array
// Whether company owners can hand this role to a member...
$role->isAssignable() : bool| Role | Notes |
|---|---|
SuperAdministrator, Administrator | Every permission |
Moderator | Every privileged (global admin) permission, nothing company-scoped |
StandardUser | A curated, unprivileged list |
CompanyStaff | Company-scoped abilities, no delete/roles — isAssignable() |
CompanyOwner | Full company-scoped abilities incl. delete + roles — isAssignable() |
CompanyStaff and CompanyOwner are the only two roles marked isAssignable() — meaning they're the only roles a company owner can hand out to members through the UI. The others are platform-level and assigned outside the company member flow.
Actions (app/Actions/Roles/)
// Create a role (scoped to a company, or global if $companyId is null) with permissions...
CreateRole::create(array $input, array $permissions, ?int $companyId) : App\Models\Role
// Update a role's attributes and re-sync its permissions...
UpdateRole::update(Role $role, array $input, array $permissions) : App\Models\Role
// Delete a role...
DeleteRole::delete(Role $role) : voidBoth CreateRole and UpdateRole pin guard_name to web explicitly — Sanctum's auth:sanctum middleware changes the request's default guard, which Spatie's Role model would otherwise inherit.
Enforcing it
Two layers, applied together:
- Route middleware gates entire route groups by role — coarse, fast, first line of defense. The management area is gated by
role:super-administrator|administrator|moderator. - Policies make the fine-grained, per-resource decision — 18 of them under
app/Policies/. The pattern is consistent everywhere: check the privileged/global permission first, then fall back to a scoped check against the specific company (or other resource) in question, so a global admin always passes and a company member only passes for their own company.
Extending it
To add a new ability:
- Add a case to
Permission(never rename an existing one — see the warning above). - Add it to whichever roles in
Role::permissions()should have it. - Reference it from the relevant policy method with
$user->in(...)or$user->everywhere(). - Re-run
php artisan db:prepare(or just thePermissionPreparer) so the new permission exists in the database.