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 — setting a password, confirming the address, 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
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(
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 — 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 is 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 — which you get 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 available. The package
ships no screen of its own: 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.
Do not add a canonical link to it, and do not Disallow the routes in robots.txt: a
disallowed URL is still indexed from an external reference, and the disallow hides the
directive that says not to.
Revoking one
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 — carrying ClaimFailure::Revoked, not 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 is a record of something that happened, not an open door.
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 invalidates every open invitation, immediately. Token hashes are
HMACs keyed with the application key, so after a rotation no outstanding link can be
matched. With a fifteen-minute sign-in link that is invisible. With a seven-day invitation
it is an operational fact worth planning around.
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.