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

DI, actions & DTOs

The facade is the recommended default, not the only way in. The package offers three equivalent entry points that run the same code:

  • The Teams facade — the shortest; use it in everyday application code.
  • The manager, RoundlyConsulting\Teams\TeamsManager — the facade root and a container singleton. Inject it through the constructor for the same API with an explicit dependency and no static calls.
  • The actions in RoundlyConsulting\Teams\Actions — final readonly, single-purpose classes with execute(), for composing the raw use case into your own actions, jobs or commands.

Injecting the manager

use RoundlyConsulting\Teams\Models\Invite;
use RoundlyConsulting\Teams\Models\Team;
use RoundlyConsulting\Teams\TeamsManager;

final class InviteController
{
    public function __construct(private TeamsManager $teams) {}

    public function store(Team $team): Invite
    {
        return $this->teams->for($team)->invites()->create(role: 'member');
    }
}

Teams::fake() swaps the TeamsManager container binding too, so a constructor-injected manager receives the fake in tests.

Calling an action

use RoundlyConsulting\Teams\Actions\AddMemberAction;
use RoundlyConsulting\Teams\Actions\CreateInviteAction;
use RoundlyConsulting\Teams\DataTransferObjects\AddMemberData;
use RoundlyConsulting\Teams\DataTransferObjects\CreateInviteData;

// The raw actions — the behaviour the manager and facade both run.
$invite = app(CreateInviteAction::class)->execute($team, new CreateInviteData(
    role: 'admin',
    email: '[email protected]',
    invitedBy: $currentUser,
));

app(AddMemberAction::class)->execute($team, new AddMemberData(
    member: $user,
    role: 'admin',
    meta: ['invited_via' => 'import'],
    expiresAt: now()->addYear(),
));

The manager resolves every action from the container, so a binding you register for an action class applies to the facade too. Calling an action directly skips the team-scope checks the handles perform and isn’t seen by Teams::fake() — prefer the manager in application code. All 17 actions are host-facing; none is internal.

Facade method → action

Facade methodActionexecute(…)Dispatches
Teams::create()CreateTeamActionCreateTeamData $data— (adds the owner via AddMemberAction)
for($t)->members()->add()AddMemberActionTeam $team, AddMemberData $dataTeamMemberAdded / TeamMemberRoleChanged
for($t)->members()->remove()RemoveMemberActionTeam $team, Model $memberTeamMemberDeleted
for($t)->members()->changeRole()ChangeMemberRoleActionTeam $team, Model $member, string $roleTeamMemberRoleChanged
for($t)->transferOwnershipTo()TransferOwnershipActionTeam $team, Model $newOwnerTeamOwnershipTransferred
for($t)->invites()->create()CreateInviteActionTeam $team, CreateInviteData $dataInviteCreated
Teams::invites()->accept()AcceptInviteActionInvite $invite, AcceptInviteData $dataInviteAccepted
for($t)->invites()->resend()ResendInviteActionInvite $inviteInviteResent
for($t)->invites()->revoke()RevokeInviteActionInvite $inviteInviteRevoked
Teams::invites()->prune()PruneInvitesAction——
for($t)->roles()->define()DefineTeamRoleActionDefineTeamRoleData $data—
for($t)->joinRequests()->open()RequestToJoinActionRequestToJoinData $dataJoinRequestCreated (+ JoinRequestApproved)
for($t)->joinRequests()->approve()ApproveJoinRequestActionJoinRequest $request, RespondToJoinRequestData $dataJoinRequestApproved
for($t)->joinRequests()->deny()DenyJoinRequestActionJoinRequest $request, RespondToJoinRequestData $dataJoinRequestDenied
Teams::joinRequests()->expire()ExpireJoinRequestsAction—JoinRequestExpired (per row)
Teams::members()->prune()PruneExpiredMembersAction—MembershipExpired (per row)
Teams::members()->expiring() / notifyExpiring()DispatchExpiringMembershipsAction?int $withinDays = null, bool $notify = trueMembershipExpiringSoon (per row, when $notify)

DTOs

The DTOs in RoundlyConsulting\Teams\DataTransferObjects are final readonly classes. The facade and manager build them from named arguments, so you only need them for direct action calls:

new CreateTeamData(
    string $name,
    bool $isPublic = false,
    ?Model $owner = null,
    array $meta = [],
);

new AddMemberData(
    Model $member,
    string $role,
    array $meta = [],
    ?CarbonInterface $expiresAt = null,
    ?int $acceptedInviteId = null,   // set internally on invite acceptance
);

new CreateInviteData(
    string $role,
    ?CarbonInterface $expiresAt = null,   // null → invites.expires_after
    ?string $email = null,                // null → an open invite
    ?Model $invitedBy = null,
    array $meta = [],
    ?int $maxUses = 1,                    // null → unlimited seats
);

new AcceptInviteData(
    Model $member,
    ?string $email = null,   // must match an email-targeted invite
);

new DefineTeamRoleData(
    int $teamId,
    string $key,
    string $name,
    array $permissions = [],   // list<string|Permission>
    string $description = '',
);

new RequestToJoinData(
    Team $team,
    Model $requester,
    ?string $requestedRole = null,
    ?string $message = null,
    array $meta = [],
    ?CarbonInterface $expiresAt = null,       // null → never lapses
    array $approvers = [],                    // non-empty → route through approvals-for-laravel
    ?ApprovalRule $approvalRule = null,       // null → teams.approvals.rule
    ?int $approvalQuorum = null,              // null → teams.approvals.quorum
);

new RespondToJoinRequestData(
    Model $responder,
    ?string $role = null,   // overrides the requested/default role on approval
);

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.