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

Permissions::fake() swaps in a recording PermissionsFake and returns it. Everything still runs against the database — grants land and the Gate answers — while every write is recorded, whether it came through the facade, an injected manager, a for() handle, the traits, Role::findOrCreate() / Permission::findOrCreate() or the artisan commands. Names are normalized, so an enum matches its string:

use RoundlyConsulting\Permissions\Facades\Permissions;

$fake = Permissions::fake();

$user->assignRole('editor');
Permissions::syncFrom(PostPermission::class);

$fake->assertRoleAssigned($user, RoleName::Editor);
$fake->assertSyncedFrom(PostPermission::class);
$fake->assertNoPermissionGranted();

Because the fake is also bound in the container, a class that injects PermissionsManager is covered too:

use RoundlyConsulting\Permissions\Facades\Permissions;

it('makes new staff editors', function () {
    $fake = Permissions::fake();
    $user = User::factory()->create();

    app(OnboardEditor::class)->handle($user); // injects PermissionsManager

    $fake->assertRoleRegistered('editor');
    $fake->assertRoleAssigned($user, 'editor');

    expect($user->hasRole('editor'))->toBeTrue(); // still performed — the row exists
});

Every assertion

AssertionNegativeRecorded from
assertRoleRegistered($name)assertNoRoleRegistered()role(), Role::findOrCreate()
assertPermissionRegistered($name)assertNoPermissionRegistered()permission(), Permission::findOrCreate()
assertSyncedFrom($enum)assertNothingSyncedFrom()syncFrom(), syncRolesFrom()
assertRoleAssigned($holder, ?$role)assertNoRoleAssigned()assignRole()
assertRoleRemoved($holder, ?$role)assertNoRoleRemoved()removeRole()
assertRolesSynced($holder, ?$roles)assertNoRolesSynced()syncRoles() — exact set
assertPermissionGranted($holder, ?$permission)assertNoPermissionGranted()givePermissionTo()
assertPermissionRevoked($holder, ?$permission)assertNoPermissionRevoked()revokePermissionTo()
assertPermissionsSynced($holder, ?$permissions)assertNoPermissionsSynced()syncPermissions() — exact set
assertAuthorizationForgotten($holder)assertNoAuthorizationForgotten()forgetAllAuthorization()
assertOrphansPruned()assertNoOrphansPruned()pruneOrphans(), permissions:prune-orphans
assertCacheForgotten()assertCacheNotForgotten()cache()->forget(), permissions:cache-reset
assertMemoFlushed()assertMemoNotFlushed()cache()->flushMemo()

The holder-scoped assertions take the role or permission as an optional second argument; the synced ones compare the exact set. Every negative takes no arguments:

// Catalog
$fake->assertRoleRegistered('editor');                // role(), Role::findOrCreate()
$fake->assertPermissionRegistered('posts.edit');      // permission(), Permission::findOrCreate()
$fake->assertSyncedFrom(PostPermission::class);       // syncFrom(), syncRolesFrom()

// Roles on a holder — the role argument is optional
$fake->assertRoleAssigned($user);                     // any role
$fake->assertRoleAssigned($user, RoleName::Editor);   // this role
$fake->assertRoleRemoved($user, 'administrator');
$fake->assertRolesSynced($user, ['editor']);          // exactly this set

// Direct permissions on a holder or a role
$fake->assertPermissionGranted($role, 'posts.edit');
$fake->assertPermissionRevoked($role, 'posts.edit');
$fake->assertPermissionsSynced($user, [PostPermission::Edit]); // exactly this set

// Cleanup and cache
$fake->assertAuthorizationForgotten($user);
$fake->assertOrphansPruned();
$fake->assertCacheForgotten();
$fake->assertMemoFlushed();

// The negatives take no arguments
$fake->assertNoRoleRegistered();
$fake->assertNoPermissionRegistered();
$fake->assertNothingSyncedFrom();
$fake->assertNoRoleAssigned();
$fake->assertNoRoleRemoved();
$fake->assertNoRolesSynced();
$fake->assertNoPermissionGranted();
$fake->assertNoPermissionRevoked();
$fake->assertNoPermissionsSynced();
$fake->assertNoAuthorizationForgotten();
$fake->assertNoOrphansPruned();
$fake->assertCacheNotForgotten();
$fake->assertMemoNotFlushed();

A refused write — an unknown name or the wrong holder — is not recorded. The package’s own cache housekeeping is not recorded either: the invalidation after every grant and the memo reset per job or request. The cache assertions see only explicit Permissions::cache() calls and permissions:cache-reset.

End-to-end tests

Without the fake, test against a real database with RefreshDatabase — the migrations you published run like any other. Grant through the facade, then assert through the model or through HTTP:

use RoundlyConsulting\Permissions\Facades\Permissions;

it('lets editors view users', function () {
    Permissions::permission('auth.users.view');
    Permissions::for(Permissions::role('editor'))->givePermissionTo('auth.users.view');

    $user = User::factory()->create();
    Permissions::for($user)->assignRole('editor');

    expect($user->hasPermissionTo('auth.users.view'))->toBeTrue()
        ->and($user->can('auth.users.view'))->toBeTrue();

    $this->actingAs($user)->get('/users')->assertOk();
});

it('forbids users without the permission', function () {
    Permissions::permission('auth.users.view');

    $this->actingAs(User::factory()->create())
        ->get('/users')
        ->assertForbidden();
});

Factories

Role and Permission ship with factories; names default to a unique role-… or permission-… slug:

use RoundlyConsulting\Permissions\Facades\Permissions;
use RoundlyConsulting\Permissions\Models\Permission;
use RoundlyConsulting\Permissions\Models\Role;

$role = Role::factory()->create();                                // name: "role-…"
$permission = Permission::factory()->create(['name' => 'reports.export']);

Permissions::for($role)->givePermissionTo($permission);

Cache in tests

Every Eloquent write invalidates the catalog, so tests need no manual flushing. If a test seeds permissions with DB::table() or insert(), call Permissions::cache()->forget() afterwards.

The package’s own suite

To run the package’s test suite from a checkout:

composer test

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.