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
| Operation | Assert | Negative |
|---|---|---|
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() |
| anything | recorded(?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 testShow 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 cryptoBy 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.