NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Permissions for Laravel

Scoped & time-boxed grants

Since 2.1 a role or direct permission can hold only inside a scope — any Eloquent model: a project, a team, a tenant — and only between two moments. It is opt-in behind permissions.assignments.enabled; with the flag off the package sends exactly the 2.0 queries. Scope and window hang off the same Permissions::for() handle:

use RoundlyConsulting\Permissions\Facades\Permissions;

// Writes — exactly one scope, an optional window
Permissions::for($user)->in($project)->assignRole('editor', 'reviewer');
Permissions::for($user)->in($tenant)->until(now()->addWeek())->assignRole('admin');
Permissions::for($user)->between($from, $until)->givePermissionTo('reports.export'); // unscoped, time-boxed

// Checks — one scope or several (any of them)
Permissions::for($user)->in($project)->hasPermissionTo('posts.edit');   // bool
Permissions::for($user)->in([$team, $parentTeam])->hasRole('editor');   // bool
$user->can('posts.edit', Permissions::scope($project));                  // through the Gate

Turning it on

Publish and run the three 2.1 migrations, then set the flag — see Installation for the upgrade notes and Configuration for scope_key_type and global_applies_in_scope:

php artisan vendor:publish --tag="permissions-migrations"
php artisan migrate
# Only when your scope models key differently from your holders — before migrating
PERMISSIONS_SCOPE_KEY_TYPE=ulid

# After the 2.1 migrations ran
PERMISSIONS_ASSIGNMENTS=true

php artisan about then shows Assignments … ON with the scope key type and whether global grants apply in scopes. While the flag is off, every in(), from(), until(), between(), at(), scope(), owner(), forgetScope() and pruneExpired() call throws PermissionException.

The rules

  • Scope — a scoped grant counts only for a check in that scope, matched by morph class and key: Team #1 and Project #1 are different scopes. It never satisfies an unscoped check such as $user->hasPermissionTo() or $user->can('x').
  • Global in scope — an unscoped grant, a 2.0 grant or an unscoped time-boxed one, counts in every scope while global_applies_in_scope is on.
  • Window — valid_from is inclusive, valid_until exclusive, both UTC to the second. Now is Date::now()->utc(), so travelTo() and Date::setTestNow() move it in tests.
  • Several windows — the same role in the same scope with another window is a separate row, and the holder has the role whenever any of them is open. The identical assignment twice is one row.
  • Checks take several scopes, meaning any of them — a team and its parent teams, say. Writes take exactly one. An empty list throws instead of reading as unscoped; pass in(null) for that.
  • Holders — only HasRoles models have assignments; Permissions::for($role)->in() throws, because a Role holds permissions only.

Reading inside a scope

The handle’s reads answer in its scopes, at its check time — at() moves that moment. Without in() they read unscoped and also work with the flag off, on 2.0 grants only:

$grants = Permissions::for($user)->in($project);

$grants->hasRole('editor');                  // any of the given roles
$grants->hasAnyRole('editor', 'reviewer');   // the same, variadic
$grants->hasPermissionTo('posts.edit');      // directly or through a role
$grants->roleNames();                        // Collection<int, string>
$grants->permissionNames();                  // Collection<int, string>
$grants->assignments();                      // Collection<int, Assignment>

$grants->at(now()->addWeek())->hasRole('editor'); // as of next week

assignments() lists everything as Assignment objects — on an unscoped handle the 2.0 grants (as unscoped and permanent) and every scope, with in() those scopes only. It is a live query with each scope eager-loaded, and serializing it carries no class name:

foreach (Permissions::for($user)->assignments() as $assignment) {
    $assignment->kind;        // AssignmentKind::Role or AssignmentKind::Permission
    $assignment->name;        // 'editor'
    $assignment->scope;       // ?Model — null when unscoped, or when the scope no longer exists
    $assignment->validFrom;   // ?CarbonImmutable, UTC, inclusive
    $assignment->validUntil;  // ?CarbonImmutable, UTC, exclusive
    $assignment->isActive;    // bool, at the handle's check time
}

Writing inside a scope

The 2.0 verbs route by what the handle carries. Without a scope or window they take the 2.0 path. With a window and no scope they write unscoped assignment rows. With a scope they write — or remove — rows of that scope only:

// The complete role set of this project — other scopes are untouched
Permissions::for($user)->in($project)->syncRoles(['viewer']);

// Every window of the role in this project
Permissions::for($user)->in($project)->removeRole('editor');

// Everything the holder has in this project
Permissions::for($user)->in($project)->forgetAllAuthorization();
  • syncRoles() and syncPermissions() in a scope make the given set the complete set of that scope: in one transaction holding a row lock on the holder, every row in that exact scope that is not a target — same role and window — goes, then the targets are written. Concurrent syncs of one holder queue up, and exactly one caller’s set survives.
  • An unscoped time-boxed sync is the complete unscoped set, so it also detaches the holder’s 2.0 roles or permissions.
  • removeRole() in a scope removes every window of the role there; a name clears every role it can mean in that scope — the scope’s own and the global one. Removals follow the scope only: an unscoped removal clears every window.
  • Additive writes are upserts on a fingerprint of holder, role or permission, scope and window, so identical concurrent writes leave one row and different windows add rows.
  • A window whose end is not after its start, or that has already ended, throws PermissionException — a fully past window would grant nothing.

The 2.0 verbs with assignments on

Unscoped, the familiar verbs reach the new rows too, so a holder never keeps a grant you meant to take away:

  • hasRole(), hasPermissionTo() and the Gate also count unscoped time-boxed grants while their window is open.
  • syncRoles() and syncPermissions() also delete the holder’s unscoped assignment rows; scoped rows belong to their scope’s sync.
  • removeRole() and revokePermissionTo() also delete the holder’s unscoped assignment rows of those names, every window.
  • forgetAllAuthorization() also deletes every assignment of the holder in every scope — it is the teardown verb. For one scope, call it on Permissions::for($holder)->in($scope).
  • The roles and permissions relations, getRoleNames(), getDirectPermissions() and getAllPermissions() keep listing 2.0 grants only — use the handle’s assignments(), roleNames() and permissionNames() for the full picture.

On the model

HasRoles adds scoped shorthand. roleIn() also counts unscoped rows and 2.0 roles while global_applies_in_scope is on:

$user->hasRoleIn($project, 'editor');                    // = Permissions::for($user)->in($project)->hasRole('editor')
$user->hasPermissionIn([$team, $parent], 'posts.edit');  // any of the scopes
User::query()->roleIn($project, 'editor')->get();        // query scope, window checked at the current UTC time

When a scope is deleted

Scopes are polymorphic, so their rows cannot cascade. Call Permissions::forgetScope() from each scope model’s deleting hook — it deletes every assignment made in the scope and every role it owns, and returns how many went:

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Permissions\Facades\Permissions;

class Project extends Model
{
    protected static function booted(): void
    {
        static::deleting(fn (self $project) => Permissions::forgetScope($project));
    }
}

Pruning ended windows

An ended window grants nothing, so pruning is table hygiene, never correctness. Permissions::pruneExpired() deletes rows whose window ended by a moment you pass — now by default:

use RoundlyConsulting\Permissions\Facades\Permissions;

Permissions::pruneExpired();                     // int — rows whose window ended by now
Permissions::pruneExpired(now()->subDays(30));   // keep 30 days of history
php artisan permissions:prune-expired                    # every row whose window has ended
php artisan permissions:prune-expired --after="30 days"  # keep rows that ended less than 30 days ago

A --after value that is not a positive duration exits with code 2. No expiry events ship — they would need a sweep of their own.

Caching

Assignment rows are memoized per process, keyed by holder, and stored with their windows — never as a yes or no — so every check filters against the clock and a long-lived worker can never serve an expired grant. The memo is flushed per holder by every write, and wholesale at every Octane request, task and tick, every queued job, Permissions::cache()->flushMemo() and every transaction rollback. Nothing is shared between processes: a grant written by another process is seen at the next request or job. The shared catalog cache is unchanged, and roles — owned or not — never enter it.

Building on it

For a package or module that checks grants per team or project, the stable surface is Permissions::for($member)->in($scopes)->hasPermissionTo() and hasRole() with several scopes, resolveScopeUsing() for an ambient scope, in($scope)->forgetAllAuthorization() when a membership ends, forgetScope() when a scope is deleted, owner() for scope-defined roles and Permissions::scope() for Gate calls. Check PermissionRegistrar::assignmentsEnabled() first — they throw while the flag is off. The permissions bridge of teams-for-laravel is built exactly this way.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.