Minting links and codes yourself
Sometimes you want to deliver the link or code over a channel the bundled email flow does not cover — an SMS, a chat message, an existing transactional email, or a queued job. The Mint API issues a credential and hands it back without sending anything.
The facade
use EmailMagicLink\Facades\EmailMagicLink;
// A single-use magic link for the default guard.
$link = EmailMagicLink::issueLink($user);
$link->url; // signed, single-use confirmation URL — deliver this verbatim
$link->expiresAt; // Carbon\CarbonInterface
$link->expiresInMinutes; // e.g. 15 — handy for your own copy
// A one-time code instead.
$code = EmailMagicLink::issueCode($user);
$code->code; // the code to deliver
$code->expiresAt;
$code->expiresInMinutes;
expiresAt is typed as Carbon\CarbonInterface, which is what lets the same value work
whether or not your application calls Date::use(CarbonImmutable::class). You get back
whatever your app's date factory produces: the mutable Illuminate\Support\Carbon by
default, a CarbonImmutable if you have switched. Type your own parameters against
CarbonInterface too, and either setting fits.
The contract
Prefer dependency injection? Depend on the EmailMagicLink\Contracts\MagicLinkIssuer
contract; the facade is a thin wrapper over it and takes exactly the same arguments,
including the optional maxUses, passphrase and baseUrl shown below.
use EmailMagicLink\Contracts\MagicLinkIssuer;
public function __construct(private MagicLinkIssuer $issuer) {}
The full signatures, including the optional per-link parameters, are in the contract reference.
The rules the API enforces
The minted credential is hashed at rest, single-use, and consumed through the exact same flow as an emailed one — only nothing is sent. A few rules the API enforces or expects:
- Deliver
urlverbatim. It points at the inert, signed confirmation page (aGETthat changes nothing); the token is spent only when the user submits it. Never send or prefetch the consume endpoint. - Pass a user that belongs to the guard. Issuing re-resolves the user through the guard's
own provider — the same provider the consume step uses — and throws
UserNotInGuardExceptionif it does not match. With no$guardthe default guard is used; pass an allowed guard (the default plus any underguards) or get anUnknownGuardException. issueCodesupersedes the previous code for the same user and guard, so only the most recently issued code can be claimed. (issueLinkdoes not invalidate earlier links.)- The channel must be enabled. With
enabled = falsethe API throwsMagicLinkDisabledExceptionrather than minting a credential that could never be consumed. - Look a user up by email first by injecting
EmailMagicLink\Contracts\UserLookup— that is the supported email-to-user path; the Mint API deliberately takes an already-resolved user. - Eligibility is decided when the credential is redeemed. The API does not ask
SignInEligibilitybefore it mints; it takes the user you resolved. The redemption always asks, so a credential minted for an account that is refused by then is consumed and refused, reported asClaimFailure::Ineligible. A user resolved throughUserLookuphas been asked already: a refused account resolves to nothing. - Encrypt a delivery you queue. A mailable you queue,
Mail::to(...)->queue(...), or a job of your own carriesurlorcodein the queue's payload, and Laravel's default queue is a table in your database,failed_jobsincluded. ImplementIlluminate\Contracts\Queue\ShouldBeEncryptedon it, as the bundled notification does, so a leaked database holds no working link.
Revoking open links and codes
A credential belongs to the account it was issued for, not to the address it went to. When an account's address changes, a link already sent to the old address would still sign in until it expires. Revoke what the person holds when that happens:
$revoked = EmailMagicLink::revokeFor($user); // the default guard
$revoked = EmailMagicLink::revokeFor($user, 'admin'); // another guard
It spends every open link and code of the user on that guard, the remaining uses of a
multi-use link included, and returns how many were open. A revoked link then fails exactly like
one that was already used. Tokens of other users and other guards are left alone, and the call
works on a guard you no longer list in guards and while the channel is off.
To do it wherever the address changes, revoke from the model:
// app/Models/User.php
protected static function booted(): void
{
static::updated(function (User $user): void {
if ($user->wasChanged('email')) {
EmailMagicLink::revokeFor($user);
}
});
}
Building a link for another host
$link = $issuer->issueLink($user, baseUrl: 'https://tenant-a.example.com');
Handy for multi-domain or multi-tenant applications. The signature is computed over that final host, so the link verifies only when visited there.
Without a base URL the host comes from the request that asked for the link, which is the
framework's default for every URL it builds. Read the host in the emailed link
before you deploy: an application that answers any Host header should either restrict the
hosts it trusts or force its origin.
A base URL that carries no scheme — tenant-a.example.com or //tenant-a.example.com — is
completed with the scheme the application itself is served over, so the link in the email is
always absolute. A base URL that states its own scheme is used exactly as given, including
one that differs from the application's.
Beyond a single-use login link
- Multi-use links — hand out a link that may be redeemed a bounded number of times.
- Passphrase-gated links — require a shared secret on the confirmation page.
- Authorizing a gated resource without login — use the same token to authorize one specific resource without creating a session.