Principles
- Check permissions, never roles.
- Prefer one permission per model (for example,
manage_users) instead of CRUD sets.
Configure enum classes
Point Core to your app enums inconfig/core.php:
Define permissions
Permissions come from two enums:Inly\Core\Enums\CorePermission(platform-level)App\Enums\Permission(app-level)
app/Enums/Permission.php
Define roles
Roles are declared inapp/Enums/Role.php. Assign permissions in permissions() using enum cases or '*' for all permissions.
app/Enums/Role.php
Sync TypeScript and database
permissions:generate will:
- Create/update permissions from both enums.
- Delete permissions removed from enums.
- Create roles from
app/Enums/Role. - Sync role permissions from
permissions().
Viewable scope and viewableBy()
On models that implementDomainObjectContract (via the HasDomainObject trait), define which records a user can see by overriding viewableBy(). That scope is used for both querying (e.g. listing only allowed records) and for user()->can('view', $model) / Gate::allows('viewAny', Model::class) when you don’t have a policy for the model.
Defining viewableBy()
Override the protected methodviewableBy(Builder $query, Authenticatable $user): Builder|bool:
- Return
true— The user can see all records. - Return
false— The user can see no records. The trait applies thedeny()scope so the query returns no rows. - Return a Builder — Apply your own constraints (e.g. scope to the user’s team or own records).
Model::query()->viewable($user). Omitting the user uses the authenticated user: Model::query()->viewable().
Models that do not implement
DomainObjectContract are not restricted by the viewable scope and are treated as globally accessible when no policy exists.The deny() scope
When you want to allow no records for a user, returnfalse from viewableBy() (the trait applies deny() for you), or call $query->deny() inside viewableBy() to force an empty result. That ensures both list queries and checks like viewAny treat the model as inaccessible for that user.
Using can() and viewAny without a policy
If you don’t register a policy for a model, you can still use:user()->can('view', $order)— Allowed when the given instance is in the set of records returned by the model’sviewable()scope for that user.Gate::allows('viewAny', Order::class)— Allowed when the user can see at least one record (i.e. the viewable scope can return rows).
create follows the same rule as viewAny. Global search uses viewAny, so search results respect the same viewable rules.
Viewable use cases
- Permission-based global vs self — With a permission (e.g.
VIEW_USERS), returntrue; otherwise scope to the current user, e.g.$query->where('id', $user->id). - Permission-based all or none — Return a boolean from a permission check so either all or no records are viewable.
- Related model visibility — Restrict to records whose related model is viewable by the user, e.g.
$query->whereHasMorph('subject', '*', fn ($q) => $q->viewable($user)).
Use permissions in code
Use permissions everywhere you enforce access.- Controller
- Outside a controller
- Frontend
Use
$this->authorize() in controllers:app/Http/Controllers/UserController.php
Unified permission checks (User and AppClient)
Thehas_permission() helper checks whether the current auth context — User or AppClient — has a given permission.
user() first, then falls back to client() (the Passport AppClient). Always use has_permission() instead of user()->can(). Calling user()->can() only works for web-authenticated users and silently fails in API client credential flows where user() is null.
Policies for both User and AppClient
Make theUser parameter nullable so Laravel’s Gate calls the policy even when there’s no authenticated user (API client credentials flow):
Auth context helpers
| Helper | Returns | Purpose |
|---|---|---|
user() | ?User | Authenticated web user |
client() | ?AppClient | Authenticated API client (Passport client credentials) |
has_permission($p) | bool | Check permission against whichever context is active |
Helpers for core code
When core code needs role data, use helpers instead of referencingApp\Enums\Role directly:
Troubleshooting
- Permission missing in TypeScript: Run
composer generate-ts. - Permission missing in the database: Run
php artisan permissions:generate. - Access denied for an expected role: Confirm the role includes the permission or uses
'*'. viewAnyorviewdenied for a model with no policy: Ensure the model implementsDomainObjectContractand thatviewableBy()allows at least some rows for that user. If it returnsfalseor a builder that can never match,viewAnyand per-instance checks will deny access.