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

Connections::fake() swaps the manager for a recording fake — in the facade and in the container, so injected ConnectionManagers, the permissions() sub-accessor and every HasConnections trait write are recorded too. Operations still hit your test database (so reads work) and are recorded once they succeed — a verb that throws records nothing — in the spirit of Bus::fake():

use RoundlyConsulting\Connections\Facades\Connections;

it('invites a member to a team', function () {
    $user = User::factory()->create();
    $team = Team::factory()->create();
    $otherTeam = Team::factory()->create();

    $fake = Connections::fake();

    Connections::between($user, $team)->withPermissions('view')->invite();
    $team->acceptConnectionFrom($user);                            // trait call — recorded too
    Connections::between($user, $team)->permissions()->grant('publish');

    $fake->assertInvited($user, $team);
    $fake->assertAccepted($user, $team);
    $fake->assertConnected($user, $team);                          // connect() or invite() recorded
    $fake->assertConnectedTimes(1);
    $fake->assertGranted($user, $team, 'publish');
    $fake->assertHasPermissionThrough($user, $team, 'view');
    $fake->assertNotConnected($user, $otherTeam);
    $fake->assertNothingDisconnected();

    // Pass-through: the rows were really written.
    expect($user->hasPermissionThroughConnection($team, 'publish'))->toBeTrue();
});

Every assertion

Each write has a positive assertion and an assertNothing* counterpart:

OperationAssertNegative
connect() / connectTo() / connectAll() (per target)assertConnected($a, $b) · assertConnectedTimes($n)assertNotConnected($a, $b) · assertNothingConnected()
invite() / inviteConnection()assertInvited($a, $b)assertNothingInvited()
accept() / acceptConnectionFrom()assertAccepted($a, $b)assertNothingAccepted()
block() / blockConnectionFrom()assertBlocked($a, $b)assertNothingBlocked()
disconnect() / disconnectFrom() / disconnectAll()assertDisconnected($a, $b)assertNothingDisconnected()
reconnect() / restore() / reconnectTo()assertReconnected($a, $b)assertNothingReconnected()
extend()assertExtended($a, $b)assertNothingExtended()
permissions()->grant() / grantAll() / grantThroughConnection()assertGranted($a, $b, ?$permission)assertNothingGranted()
permissions()->revoke() / revokeAll() / revokeThroughConnection()assertRevoked($a, $b, ?$permission)assertNothingRevoked()
permissions()->sync() / clear() / syncConnectionPermissions()assertPermissionsSynced($a, $b, ?$set) · assertPermissionsCleared($a, $b)assertNothingPermissionsSynced()
from()->sync() / syncConnections()assertSynced($connector, ?$set)assertNothingSynced()
prune() / connections:pruneassertPruned()assertNothingPruned()
anythingassertHasPermissionThrough($a, $b, $permission)assertNothingRecorded()

toggle() records the connect() or disconnect() it performs. Actions you resolve and call directly bypass the manager, so they run normally but aren’t recorded. $fake->recorded() returns the raw list of RecordedOperation objects:

$fake = Connections::fake();

Connections::from($user)->sync([$teamA, $teamB]);
Connections::between($user, $teamA)->extend(now()->addYear());
Connections::between($user, $teamA)->permissions()->sync('view', 'edit');
Connections::prune();

$fake->assertSynced($user, [$teamA, $teamB]);                        // the exact desired set
$fake->assertExtended($user, $teamA);
$fake->assertPermissionsSynced($user, $teamA, ['view', 'edit']);
$fake->assertPruned();
$fake->assertNothingRevoked();

$fake->recorded();   // list<RecordedOperation> — the raw log

Factory

The Connection model ships a factory with pending(), blocked() and expired() states — expired() sets expires_at to a day ago. Supply the morph keys of real models:

use RoundlyConsulting\Connections\Models\Connection;

$pair = [
    'connector_type' => $user->getMorphClass(),
    'connector_id' => $user->getKey(),
    'connectable_type' => $team->getMorphClass(),
    'connectable_id' => $team->getKey(),
];

Connection::factory()->pending()->create([...$pair, 'permissions' => ['view']]);   // an open invitation

// Without a state: accepted, no permissions, no expiry.
// Other states: ->blocked() (grants nothing), ->expired() (expires_at a day ago).

Asserting events

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Connections\Events\ConnectionInvited;
use RoundlyConsulting\Connections\Facades\Connections;

it('dispatches an invitation event', function () {
    Event::fake([ConnectionInvited::class]);

    Connections::between($user, $team)->invite();

    Event::assertDispatched(ConnectionInvited::class);
});

Run your suite with the published migration (for example with RefreshDatabase). If a test changes a connection row directly between two permission checks, pass force: true or call Connections::flushCache() — the in-request cache only tracks writes made through the package.

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.