Skip to main content

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 url verbatim. It points at the inert, signed confirmation page (a GET that 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 UserNotInGuardException if it does not match. With no $guard the default guard is used; pass an allowed guard (the default plus any under guards) or get an UnknownGuardException.
  • issueCode supersedes the previous code for the same user and guard, so only the most recently issued code can be claimed. (issueLink does not invalidate earlier links.)
  • The channel must be enabled. With enabled = false the API throws MagicLinkDisabledException rather 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 SignInEligibility before 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 as ClaimFailure::Ineligible. A user resolved through UserLookup has 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 carries url or code in the queue's payload, and Laravel's default queue is a table in your database, failed_jobs included. Implement Illuminate\Contracts\Queue\ShouldBeEncrypted on it, as the bundled notification does, so a leaked database holds no working link.

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);
}
});
}
$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.