---
title: "Invitations — Teams for Laravel | Roundly"
description: "Issue expiring, email-targeted invites, accept them by code, resend or revoke them, and wire the publishable accept route."
url: https://roundly-consulting.com/open-source/docs/teams-for-laravel/invitations
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Teams for Laravel](https://roundly-consulting.com/open-source/docs/teams-for-laravel.md)

# 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:

```php
use RoundlyConsulting\Teams\Facades\Teams;

// Default expiry from config, targeted at one email address.
$invite = Teams::for($team)->invites()->create(role: 'admin', email: 'jane@example.com');

// Every option:
$invite = Teams::for($team)->invites()->create(
    role: 'member',
    expiresAt: now()->addDays(14),    // null → invites.expires_after
    email: 'jane@example.com',        // 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: 'jane@example.com');
```

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

```php
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);   // ?Invite
```

Acceptance 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 Jane@Acme.test matches jane@acme.test.
- The invite’s team was deleted → InviteNotFoundException.
- Teams::invites()->accept() with an unknown code throws InviteNotFoundException before any check.

```php
$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

```php
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:

```bash
php artisan teams:invites:resend {code}   # prints the new code
```

## Querying invites

```php
use RoundlyConsulting\Teams\Models\Invite;

Invite::query()->pending()->get();                     // not yet expired
Invite::query()->forEmail('jane@example.com')->get();
$team->invites()->pending()->get();

$invite->team;        // the Team
$invite->invitedBy;   // the inviting model (MorphTo), or null
```

## The 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:

```bash
php artisan vendor:publish --tag="teams-stubs"
```

```php
// 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');
});
```

```php
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:

```bash
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 ones
```

## 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/teams-for-laravel.md)
