Skip to main content
Roles and permissions are managed with spatie/laravel-permission and defined as enums. The database is generated from enums, and TypeScript types are generated from PHP.

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 in config/core.php:
return [
    'permissions' => \App\Enums\Permission::class,
    'roles' => \App\Enums\Role::class,
];

Define permissions

Permissions come from two enums:
  • Inly\Core\Enums\CorePermission (platform-level)
  • App\Enums\Permission (app-level)
Example app permission enum:
app/Enums/Permission.php
enum Permission: string
{
    case MANAGE_USERS = 'manage_users';

    public function label(): string
    {
        return match ($this) {
            self::MANAGE_USERS => __('Manage system users'),
        };
    }
}

Define roles

Roles are declared in app/Enums/Role.php. Assign permissions in permissions() using enum cases or '*' for all permissions.
app/Enums/Role.php
use App\Enums\Permission;
use Inly\Core\Enums\CorePermission;

public function permissions(): array|string
{
    return match ($this) {
        self::ADMIN => '*',
        self::STAFF => [
            CorePermission::VIEW_INLY_USER_GUIDE,
            Permission::MANAGE_USERS,
        ],
        self::USER => [],
    };
}

Sync TypeScript and database

composer generate-ts
php artisan permissions:generate
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 implement DomainObjectContract (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 method viewableBy(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 the deny() 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).
Use the scope in queries as Model::query()->viewable($user). Omitting the user uses the authenticated user: Model::query()->viewable().
// Allow all when user has permission; otherwise only their own
protected function viewableBy(Builder $query, ?AuthenticatableContract $user): Builder|bool
{
    if (has_permission(CorePermission::VIEW_USERS)) {
        return true;
    }
    return $query->where('id', $user?->id);
}

// All or nothing based on permission
protected function viewableBy(Builder $query, ?Authenticatable $user): Builder|bool
{
    return has_permission(CorePermission::MANAGE_SYSTEM);
}
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, return false 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’s viewable() 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.
Use a dedicated policy when you need different rules per ability or role. Use viewableBy() when access is “can this user see this set of records?” and you want view / viewAny to match your queries.

Viewable use cases

  • Permission-based global vs self — With a permission (e.g. VIEW_USERS), return true; 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.
Use $this->authorize() in controllers:
app/Http/Controllers/UserController.php
use App\Enums\Permission;

$this->authorize(Permission::MANAGE_USERS->value);

Unified permission checks (User and AppClient)

The has_permission() helper checks whether the current auth context — User or AppClient — has a given permission.
use App\Enums\Permission;

has_permission(Permission::MANAGE_USERS);   // accepts BackedEnum
has_permission('manage_users');              // or string
It checks the authenticated 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.
Never use user()->can() for permission checks. It does not account for API clients authenticating via client credentials and will silently deny access in that context. Use has_permission() everywhere.

Policies for both User and AppClient

Make the User parameter nullable so Laravel’s Gate calls the policy even when there’s no authenticated user (API client credentials flow):
use App\Enums\Permission;
use App\Models\User;

public function manage(?User $user, Order $order): bool
{
    if (has_permission(Permission::MANAGE_ORDERS)) {
        return true;
    }

    if ($user) {
        return $order->team_id === $user->current_team_id;
    }

    return false;
}
The pattern is: permission check first (covers both User and AppClient), then User-specific relation checks when a user is present.

Auth context helpers

HelperReturnsPurpose
user()?UserAuthenticated web user
client()?AppClientAuthenticated API client (Passport client credentials)
has_permission($p)boolCheck permission against whichever context is active

Helpers for core code

When core code needs role data, use helpers instead of referencing App\Enums\Role directly:
$roleClass = role_enum();
$roles = role_cases();
$options = role_options();

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 '*'.
  • viewAny or view denied for a model with no policy: Ensure the model implements DomainObjectContract and that viewableBy() allows at least some rows for that user. If it returns false or a builder that can never match, viewAny and per-instance checks will deny access.