Skip to main content

Invitations

A magic link signs in somebody who already exists. An invitation does the opposite: it puts an account into service for an address that may have no account at all. That means setting a password, confirming the address, or making somebody a member with roles decided in advance by whoever invited them.

The two look almost identical from the outside, which is exactly why reaching for the sign-in flow is the natural mistake. UserLookup would find a not-yet-member and MagicLinkAuthenticator would sign them in before they had joined, which is the very thing the invitation was supposed to establish.

Where the line runs​

The package ownsYour application owns
issuing the token and its signed URLthe acceptance screen
superseding an earlier invitationsetting the password
refusing an unknown, expired, accepted or revoked onecreating the account or the membership
spending it exactly oncegranting the roles
signing the new user in afterwardsdeciding whether to sign them in at all

Nothing on the right can be guessed by a package. It would have to know your user model, your password policy and your membership rules, and at that point it stops being an authentication building block. So the right-hand column reaches you through one interface, and nothing else.

Turning it on​

// config/email-magic-link.php
'invitations' => [
'enabled' => true,
'handler' => App\Auth\AcceptInvitation::class,
'view' => 'auth.accept-invitation',
],

Both handler and view are required, and the package refuses to boot without them rather than failing at the moment an invited person clicks their link. That is the worst possible time to discover a configuration mistake and the hardest place to see it.

Issuing one​

use EmailMagicLink\Contracts\InvitationIssuer;

$invitation = app(InvitationIssuer::class)->invite(
context: ['roles' => ['editor'], 'team_id' => 42],
invitedBy: auth()->user()->email,
);

$invitation->url; // deliver this, verbatim
$invitation->expiresAt; // Carbon\CarbonInterface
$invitation->expiresInMinutes; // e.g. 10080

No mail is sent, so deliver the URL however you like. There is deliberately no plaintext property on the result, unlike a one-time code. An invitation token is only ever useful inside its URL, and not exposing it twice means there's no second copy for a log line or an exception dump to pick up.

If a queued mailable or job delivers it, implement Illuminate\Contracts\Queue\ShouldBeEncrypted on that class. The URL is a working credential, and Laravel's default queue is a table in your database, failed_jobs included.

context is stored verbatim and handed back on acceptance. The package never interprets it.

Deciding what acceptance means​

use App\Models\User;
use EmailMagicLink\Contracts\InvitationHandler;
use EmailMagicLink\Support\AcceptedInvitation;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;

final class AcceptInvitation implements InvitationHandler
{
public function accept(AcceptedInvitation $invitation, Request $request): ?Authenticatable
{
$data = $request->validate(['password' => ['required', 'confirmed', Password::defaults()]]);

$user = User::create([
'email' => $invitation->email,
'password' => Hash::make($data['password']),
]);

// Check the context against current state -- see the caveat below.
$user->assignRoles($invitation->context['roles'] ?? []);

return $user;
}
}

Return an authenticatable and the package signs that user in through the same path a magic link uses. That includes the two-factor handoff if you use Fortify, and you get it precisely because your handler does not call Auth::login() itself. That sign-in lasts as long as the session: it never sets the remember cookie, also with remember.enabled on.

Return a user of the invitation's guard. A session keeps only the user's identifier and looks it up again through that guard's provider on every request. If you invite onto more than one guard and those guards use different models, create the model that $invitation->guard signs in with. The example above creates a User whatever the guard. On a guard backed by an Admin model, the invited person would otherwise become the admin with the same id. The package checks this before it signs anybody in. A user the guard's provider does not resolve to fails with an InvitationsMisconfiguredException, and the acceptance rolls back, so the invitation stays open until the handler is fixed.

Return null and the invitation is accepted without a session. That is what you want when acceptance still has to be approved by somebody else.

Your acceptance screen​

The GET route renders your view and passes it everything it needs:

<form method="POST" action="{{ $action }}">
@csrf
<p>You were invited as {{ $email }}.</p>
<input type="password" name="password" required>
<input type="password" name="password_confirmation" required>
<button type="submit">Accept</button>
</form>

$action, $email, $context, $scope, $expiresAt and $token are all available, $scope null for an invitation issued without one. Render $action exactly as you were given it: it is the signed URL this page was reached at, the acceptance POST checks that signature again, and a form that rebuilds its target from the route name loses it and gets refused. The package ships no screen of its own, because one carrying a password field would put credential handling inside a package that deliberately handles none.

The response already carries X-Robots-Tag: noindex, nofollow, as every response on the package's routes does. The page shows an email address at a URL that carries the token, so don't add a canonical link to it, and don't Disallow the routes in robots.txt either: a disallowed URL is still indexed from an external reference, and the disallow hides the directive that says not to.

Invitations per tenant​

Where several tenants invite on their own, give each invitation the tenant as its scope:

$issued = app(InvitationIssuer::class)->invite(
context: ['role' => 'agent'],
scope: 'workspace-7',
);

An invitation supersedes only the open invitations of its own scope, so a tenant re-inviting an address replaces its own invitation and leaves the one another tenant sent. The scope comes back on acceptance as $invitation->scope, beside the context, and the acceptance screen gets it as $scope. An invitation without a scope supersedes the unscoped ones only, so an application that never names a scope behaves as it did before.

Revoking one​

$revoked = app(InvitationIssuer::class)->revoke('[email protected]');
$revoked = app(InvitationIssuer::class)->revoke('[email protected]', scope: 'workspace-7');

Withdraws every open invitation for the address (on the default guard, or the one you pass), in the scope you name or, without one, in every scope, and returns how many it withdrew. A tenant names its own scope; the wide form is for an operator closing an address altogether. An accepted invitation is left alone. From then on the link refuses like any other dead one, and your application hears about the attempt through InvitationRejected. It carries ClaimFailure::Revoked rather than AlreadyConsumed, so a click on a withdrawn link can be told apart from a re-click on a spent one.

Revoking is not narrowed to the guards in guards. It closes something and opens nothing, so it works on a guard you have just removed from that list, too.

Closing a guard closes its invitations. Remove a guard from guards and every open invitation on it stops working, the way sign-in links do: the acceptance screen is never shown, and posting the form withdraws the invitation instead of accepting it. Your application hears about it through InvitationRejected with ClaimFailure::Revoked.

What the flow guarantees​

Only the hash is stored. A database dump is not a working way in.

Re-inviting kills the old link. Issuing again supersedes any earlier unaccepted invitation for the same address, guard and scope, so there are never two live links for one person. An already accepted invitation is left alone: it's a record of something that happened, not an open door.

That holds when two requests arrive at once, and it costs one thing: issuing takes a short lock on the cache store. Without it each request would supersede whatever it happened to see and both links would survive. The array, file, database, Redis, Memcached and DynamoDB stores can all do the locking this needs, and the resend guard already required it, so it isn't a new dependency.

Two stores cannot, and they fail differently. The apc store is not a lock provider at all, so the package says so and stops. The null store is the trap: it is a lock provider, and the lock it hands out succeeds every single time. A check for the lock interface therefore passes it straight through. The package rejects that lock explicitly and names the setting to change, because a lock that never says no is not a lock — and here it would mean two live invitations for one address, with nothing in any log to say so. Issuing a sign-in code takes the same lock per user. Sign-in links take none, because several live links for one person are allowed by design.

Every refusal is identical. Unknown, expired, already accepted, revoked, tampered signature: same status, same body, byte for byte. Anything more specific would answer "was this ever a real link". The reason reaches your application through the InvitationRejected event and goes nowhere else.

The refusal comes before the password field. The GET verifies the signature and checks the invitation before rendering your view, so a dead invitation never reaches a screen that asks for anything. That is a guarantee rather than advice because the package owns that route.

Following the link never spends it. Only the POST does. An email security scanner that opens every link it is sent cannot burn an invitation before its recipient sees it.

Three things worth knowing before you ship​

Your handler runs inside the transaction that spends the token. If it throws — a password that fails validation, a unique constraint losing a race — the acceptance rolls back with it and the link still works. That is deliberate: spending first and creating after would turn every handler failure into a burnt invitation and a support request. Keep the work in there short for the same reason.

A context payload can be a week old. It describes what was decided when the invitation was issued, not what is true now. A role may have been renamed, a team deleted, a plan changed. Validate it against current state before acting on it; the package hands it back untouched precisely because it cannot know which parts still make sense.

Rotating APP_KEY no longer invalidates open invitations, as long as you retire the old key rather than dropping it. Token hashes are HMACs keyed with the application key, and since 0.25.0 a lookup tries every key in app.previous_keys as well. Put the outgoing key there and outstanding links keep working; leave it out and they all stop at once.

That second half is the one to plan around. With a fifteen-minute sign-in link it is invisible either way. With a seven-day invitation, a rotation that drops the old key ends every one of them, and each dead link comes back as the same refusal an unknown token gets. Conversely, if you are rotating because a key leaked, dropping it is the point — see rotating the application key.

Cleaning up​

email-magic-link:purge clears expired invitations along with expired tokens: one command, one schedule entry. See token cleanup. It does so whenever the invitations table exists, including after you switch invitations off again, so the retention below keeps holding for the rows that feature left behind.

Accepted and revoked rows survive for invitations.retain_accepted_days (30 by default) so you keep an audit trail. They carry the invited address in the clear, which makes that window a data-retention decision rather than a technical one. Set it to 0 to delete them as soon as they settle. EMAIL_MAGIC_LINK_INVITATION_RETAIN_ACCEPTED_DAYS sets it from the environment.