Invitations
A team issues expiring, optionally email-targeted invites. Each carries a role and a random code; accepting it adds the member with that role:
use RoundlyConsulting\Teams\Facades\Teams;
// Default expiry from config, targeted at one email address.
$invite = Teams::for($team)->invites()->create(role: 'admin', email: '[email protected]');
// Every option:
$invite = Teams::for($team)->invites()->create(
role: 'member',
expiresAt: now()->addDays(14), // null → invites.expires_after
email: '[email protected]', // null → an open invite
invitedBy: $currentUser, // stored as a morph
meta: ['note' => 'design team'],
maxUses: 1, // default 1; null = unlimited
);
$invite->code; // random, invites.code_length characters
$invite->expires_at; // Carbon
// The model shortcut takes the same parameters in the same order (role first):
$invite = $team->invite('member', now()->addDays(3), email: '[email protected]');Invites expire after invites.expires_after (7 days) unless you pass expiresAt, and codes are invites.code_length (32) characters. The $team->invite() shortcut runs the same manager call.
Accepting
use RoundlyConsulting\Teams\Facades\Teams;
// 1. By code — the invite is looked up for you
$member = Teams::invites()->accept($code, $user, email: $user->email);
// 2. With a resolved invite
$member = Teams::invites()->accept($invite, $user, email: $user->email);
// 3. On the invite model — the same manager call
$member = $invite->acceptBy($user, $user->email);
Teams::invites()->find($code); // ?InviteAcceptance re-reads the invite under a lock and runs these checks in order, each with a typed exception:
- Expired → InviteExpiredException.
- Exhausted — uses has reached max_uses, or a single-use invite was already consumed → InviteExhaustedException.
- Revoked → InviteNotFoundException.
- An email-targeted invite with a different or missing email → InviteEmailMismatchException. Emails compare case-insensitively and are stored lower-cased, so [email protected] matches [email protected].
- The invite’s team was deleted → InviteNotFoundException.
- Teams::invites()->accept() with an unknown code throws InviteNotFoundException before any check.
$invite->isExpired(); // expires_at has passed
$invite->isExhausted(); // uses reached max_uses (never true when unlimited)On success the member is added with the invite’s role and linked to the invite, uses is incremented, and InviteAccepted fires. A single-use invite is deleted on its first accept; a multi-use invite survives until exhausted.
Accepting is atomic: the seat is claimed with a conditional update in the same transaction as the member add, so a single-use invite admits exactly one person and an N-seat link never admits N + 1, however many tabs or people race for it. Someone who is already an active member gets their membership back unchanged — no seat is consumed and their role is never overwritten, so an owner opening a member link stays owner. An expired or removed membership is revived through the invite, with the invite’s role.
Resending and revoking
Teams::for($team)->invites()->resend($invite); // Invite — rotates the code, extends the expiry from now
Teams::for($team)->invites()->revoke($invite); // bool — soft-delete
Teams::for($team)->invites()->pending(); // Collection<Invite> — the team's unexpired invites
// Model shorthand, same code path:
$invite = $invite->resend();
$invite->revoke();Resending rotates the code and extends the expiry from now, keeping the role, email and meta, and fires InviteResent. Revoking soft-deletes the invite and fires InviteRevoked. Handing the handle another team’s invite throws InviteNotFoundException. Resend from the CLI too:
php artisan teams:invites:resend {code} # prints the new codeQuerying invites
use RoundlyConsulting\Teams\Models\Invite;
Invite::query()->pending()->get(); // not yet expired
Invite::query()->forEmail('[email protected]')->get();
$team->invites()->pending()->get();
$invite->team; // the Team
$invite->invitedBy; // the inviting model (MorphTo), or nullThe accept route and controller
The package registers no routes, so you control the URLs. Invite::getRouteKeyName() returns code, so an {invite} route parameter binds on the invite code. Publish the stubs for a ready-made pair of routes and a controller:
php artisan vendor:publish --tag="teams-stubs"// routes/teams.php — require it from routes/web.php
use App\Http\Controllers\AcceptInviteController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth', 'verified'])->group(function (): void {
Route::get('teams/invites/{invite}', [AcceptInviteController::class, 'show'])
->name('teams.invites.show');
Route::post('teams/invites/{invite}', [AcceptInviteController::class, 'store'])
->name('teams.invites.accept');
});namespace App\Http\Controllers;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Blade;
use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Invite;
class AcceptInviteController extends Controller
{
// GET — shows a confirmation form, never accepts. Swap the template for your own view.
public function show(Invite $invite): Response
{
return response(Blade::render(<<<'BLADE'
<form method="POST" action="{{ route('teams.invites.accept', $invite) }}">
@csrf
<p>Join {{ $invite->team?->name }} as {{ $invite->role }}?</p>
<button type="submit">Accept invite</button>
</form>
BLADE, ['invite' => $invite]));
}
// POST — CSRF-protected by the web group; only a verified email is offered.
public function store(Request $request, Invite $invite): RedirectResponse
{
$user = $request->user();
$email = $user instanceof MustVerifyEmail && $user->hasVerifiedEmail()
? $user->getEmailForVerification()
: null;
$member = Teams::invites()->accept($invite, $user, email: $email);
return redirect()->route('teams.show', $member->team_id);
}
}- GET never changes state: GET teams/invites/{code} only shows a confirmation form, and accepting is a CSRF-protected POST — so a link preview or an <img> on another site can’t make a signed-in user join a team.
- Both routes sit behind ['web', 'auth', 'verified'].
- The controller offers the account’s email to an email-targeted invite only once that address is verified (MustVerifyEmail); otherwise such an invite is refused.
Both files are yours to edit — swap the inline template for your own view. The controller redirects to a teams.show route, which your app defines.
Pruning
teams:invites:prune — or Teams::invites()->prune() — force-deletes invites that expired more than a month ago. Invite is also Prunable with the same window, and php artisan model:prune additionally reaches revoked and consumed (soft-deleted) invites:
php artisan teams:invites:prune # force-delete invites that expired over a month ago (= Teams::invites()->prune())
php artisan model:prune # Invite is Prunable too — includes revoked and consumed onesShow 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.