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 owns | Your application owns |
|---|---|
| issuing the token and its signed URL | the acceptance screen |
| superseding an earlier invitation | setting the password |
| refusing an unknown, expired, accepted or revoked one | creating the account or the membership |
| spending it exactly once | granting the roles |
| signing the new user in afterwards | deciding 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(
'newcomer@example.com',
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.
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.
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, $expiresAt and $token are all available. 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.
Revoking one
$revoked = app(InvitationIssuer::class)->revoke('newcomer@example.com');
Withdraws every open invitation for the address (on the default guard, or the one you pass)
and returns how many it withdrew. 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.
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 and guard, 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.
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.