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

Teams::fake() swaps in RoundlyConsulting\Teams\Testing\TeamsFake — a recording subclass of TeamsManager — behind the facade and the TeamsManager container binding, and returns it. Operations still run against your test database; the fake records each one so you can assert on what your code asked for:

use RoundlyConsulting\Teams\DataTransferObjects\CreateTeamData;
use RoundlyConsulting\Teams\Facades\Teams;

it('creates a team and adds an admin', function () {
    $fake = Teams::fake();   // TeamsFake — also swapped in for an injected TeamsManager
    $owner = User::factory()->create();
    $alice = User::factory()->create();
    $bob = User::factory()->create();

    $team = Teams::create(new CreateTeamData(name: 'Acme', owner: $owner));
    Teams::for($team)->members()->add($alice, 'admin');
    $team->addMember($bob, 'member');   // a model method — recorded too

    $fake->assertTeamCreated('Acme');
    Teams::assertMemberAdded($team, $alice, 'admin');   // the facade forwards to the fake
    Teams::assertMemberAdded($team, $bob);
    Teams::assertNothingRemoved();

    expect($alice)->toBeMemberOf($team);   // the operation really ran
});

Every state change funnels through one manager method, so the fake sees calls made through the facade, an injected TeamsManager, every handle, the model methods ($team->addMember(), $invite->revoke(), $membership->removeFromTeam()), the Artisan commands and the approvals listener. Call the assertions on the returned fake or through the facade.

  • Only the operation you called is recorded, not what it does internally — accepting an invite records the accept, not the member add inside it.
  • A refused call (another team’s invite, a non-member) throws before it runs and is not recorded.
  • Registering global roles and permissions is boot-time configuration and is not recorded; Teams::members()->expiring() is a read and isn’t either.
  • Calling an action directly bypasses the manager, so the fake doesn’t see it.

Every assertion

OperationAssertNegative
create()assertTeamCreated(?name)assertNothingCreated()
transferOwnershipTo()assertOwnershipTransferred($team, ?to)assertNothingTransferred()
members()->add()assertMemberAdded($team, $member, ?role), assertMemberNotAdded($team, $member)assertNothingAdded()
members()->remove()assertMemberRemoved($team, $member)assertNothingRemoved()
members()->changeRole()assertRoleChanged($team, $member, ?role)assertNoRoleChanged()
invites()->create()assertInviteCreated($team, ?email)assertNothingInvited()
invites()->resend()assertInviteResent($invite)assertNothingResent()
invites()->revoke()assertInviteRevoked($invite)assertNothingRevoked()
invites()->accept()assertInviteAccepted(?invite, ?by)assertNothingAccepted()
joinRequests()->open()assertJoinRequested($team, ?requester)assertNothingRequested()
joinRequests()->approve()assertJoinRequestApproved($request, ?by)assertNothingApproved()
joinRequests()->deny()assertJoinRequestDenied($request, ?by)assertNothingDenied()
roles()->define()assertRoleDefined($team, ?key)assertNothingDefined()
members()->notifyExpiring()assertExpiringMembersNotified()assertNothingNotified()
invites()->prune(), members()->prune(), joinRequests()->expire()assertInvitesPruned(), assertMembersPruned(), assertJoinRequestsExpired()assertNothingPruned()
anythingrecorded(?TeamOperation)assertNothingRecorded()
use RoundlyConsulting\Teams\Enums\TeamOperation;
use RoundlyConsulting\Teams\Facades\Teams;

// Teams
Teams::assertTeamCreated('Acme');                    // name optional
Teams::assertOwnershipTransferred($team, to: $alice);
Teams::assertNothingCreated();
Teams::assertNothingTransferred();

// Members
Teams::assertMemberAdded($team, $alice, 'admin');    // role optional
Teams::assertMemberNotAdded($team, $carol);
Teams::assertMemberRemoved($team, $bob);
Teams::assertRoleChanged($team, $alice, 'editor');   // role optional
Teams::assertExpiringMembersNotified();
Teams::assertNothingAdded();
Teams::assertNothingRemoved();
Teams::assertNoRoleChanged();
Teams::assertNothingNotified();

// Invites
Teams::assertInviteCreated($team, '[email protected]'); // email optional
Teams::assertInviteResent($invite);
Teams::assertInviteRevoked($invite);
Teams::assertInviteAccepted($invite, by: $user);     // both optional
Teams::assertNothingInvited();
Teams::assertNothingResent();
Teams::assertNothingRevoked();
Teams::assertNothingAccepted();

// Join requests and per-team roles
Teams::assertJoinRequested($team, $user);            // requester optional
Teams::assertJoinRequestApproved($request, by: $admin);
Teams::assertJoinRequestDenied($request, by: $admin);
Teams::assertRoleDefined($team, 'editor');           // key optional
Teams::assertNothingRequested();
Teams::assertNothingApproved();
Teams::assertNothingDenied();
Teams::assertNothingDefined();

// Housekeeping
Teams::assertInvitesPruned();
Teams::assertMembersPruned();
Teams::assertJoinRequestsExpired();
Teams::assertNothingPruned();

// Anything
Teams::recorded();                                   // list<RecordedTeamOperation>
Teams::recorded(TeamOperation::AddMember);           // only one operation
Teams::assertNothingRecorded();

Inspecting recorded operations

recorded() returns RecordedTeamOperation objects — the operation, the call’s context and its result — with helpers to read the context (keys include team, member, role, invite, email, requester, request, by and key):

use RoundlyConsulting\Teams\Enums\TeamOperation;

$op = Teams::recorded(TeamOperation::AddMember)[0];

$op->operation;                  // TeamOperation::AddMember
$op->result;                     // the Member the call returned
$op->model('member');            // ?Model from the call's context
$op->involves('team', $team);    // bool
$op->string('role');             // ?string — 'admin'

Pest matchers

Register three expectation matchers from tests/Pest.php — call TeamExpectations::register() or require the file published by teams-stubs:

// tests/Pest.php
use RoundlyConsulting\Teams\Testing\TeamExpectations;

TeamExpectations::register();

// or, after publishing teams-stubs:
require __DIR__.'/Teams.php';
expect($user)
    ->toBeMemberOf($team)
    ->withRole($team, 'admin')
    ->toHaveTeamPermission($team, 'posts.publish');

Factories

Every model has a factory, with states for the lifecycle cases:

use RoundlyConsulting\Teams\Models\Invite;
use RoundlyConsulting\Teams\Models\JoinRequest;
use RoundlyConsulting\Teams\Models\Member;
use RoundlyConsulting\Teams\Models\Team;

Team::factory()->create();
Member::factory()->expired()->create();
Member::factory()->expiringAt(now()->addDays(5))->create();
Invite::factory()->maxUses(5)->forEmail('[email protected]')->create();
Invite::factory()->expired()->create();
JoinRequest::factory()->expiringWithin(3)->create();
JoinRequest::factory()->expired()->create();

Member and join-request factories fill the morph columns with placeholder values — pass member_type and member_id (or requester_type and requester_id) when you need a real model.

The package’s own tests

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.