Laravel ships with email verification but it's designed for session-based apps with a frontend. Since this is a stateless API using token auth, verification is implemented as a code-based system instead of signed URLs.
Verification is modelled around channels rather than a single global switch. Every channel (email, phone) has its own mode and its own grace period, and each verified channel stamps its own column on the user (email_verified_at, phone_verified_at). There is no single account-verified flag. Whether a user is "verified enough" to pass the middleware is derived from the channels that are actually required.
This is what makes mixed setups possible without rearchitecting. Email-only, phone-only, or both. The tradeoff is that "is this user verified" is no longer one boolean, so the profile response carries two channel lists for the client to branch on. verification_required is the channels blocking access right now (grace expired), which is what you interrupt the user with. verification_pending is every channel still awaiting verification regardless of grace, which is what you surface as a softer banner. A channel in pending but not required is sitting in its grace window. Each list has a convenience boolean alongside it (is_verification_required, is_verification_pending) so the client can gate on presence without inspecting the arrays.
A required channel only gates a user who actually has that identifier. Verification proves ownership of an identifier, it does not capture one, so a mode flip on its own never retroactively locks anyone out. It only says "if this identifier exists, it must be verified." Capturing the identifier is always the separate step.
Registration handles the capture side automatically. The register rules are dynamic, email is always required and phone becomes required the moment its channel is enabled, so a new signup on a phone-required setup is forced to provide a number and gets gated on it. That means you can flip a channel on or off on the fly without a migration or a code change.
The difference between the two channels shows up for users who already exist. Phone is not something most of them have, so enabling phone is effectively inert for the existing base. The presence check gives a phone-less user a bypass rather than a lockout. Email is the opposite. Every user already has one, so enabling email required gates the whole base at once unless they were already verified (which is the case if the channel was previously on auto, since that stamps the timestamp at registration). Coming from disabled, email required bites everyone immediately.
Pulling existing phone-less users into a newly required phone channel is deliberately out of scope. It would be a phone-required interrupt or a profile-completion step that collects and verifies a number, and you can add it later without touching any of the plumbing here.
Each channel gets its own mode env var (VERIFICATION_EMAIL_MODE, VERIFICATION_PHONE_MODE), resolved to a VerificationMode enum in the config so consuming code compares against enum cases rather than raw strings. A channel is active whenever its mode is not disabled, so there's no separate list of enabled channels to keep in sync.
required.Each channel has its own optional grace period in minutes (VERIFICATION_EMAIL_GRACE_PERIOD, VERIFICATION_PHONE_GRACE_PERIOD) that gives new users temporary access before they verify. This avoids locking users out immediately if a code is slow to arrive. Set to null (default) for no grace period. The window is in minutes because these are typically hours or days, unlike the code timings which stay in seconds.
The per-channel split matters most when both channels are required. Hitting a user with two prompts the moment they register is a bad experience, so the usual pattern is to verify one channel up front with no grace period (typically email) and give the other a grace period so it kicks in later. Two grace periods spread apart works too. How strictly each channel is confirmed is left to the app.
One current limitation worth knowing. The grace window is measured from account creation, so a phone added long after signup would already be past its grace period the moment it's captured. That's fine for signup-time identifiers and only becomes relevant once a later phone-capture flow exists.
Because more than one channel can be pending, /verify and /verify/resend both take a channel param alongside the code. The code is matched only against records for that channel, attempts are counted per channel, and resend throttling is per channel. Verifying a channel stamps only that channel's column. The welcome notification is unrelated to any of this, it fires once on registration regardless of verification state.
Email is wired end to end. Phone is scaffolded (the enum, the phone and phone_verified_at columns, the channel column on codes, the per-channel verify flow, and dynamic registration capture all exist) but two pieces are intentionally left open. There is no SMS delivery yet, the notification has no toVonage() method, and there is no flow to collect a number from users who registered before the channel was enabled. New signups are handled, existing phone-less users bypass until you add that interrupt.
Channel-specific values are keyed by channel in config/verification.php, the rest are global. All overridable via env vars:
mode.email / mode.phone - disabled / auto / requiredgrace_period.email / grace_period.phone - minutes of access before enforcement kicks in (default null)code_length - digits in the code (default 6)code_expiry - seconds before code expires (default 900)resend_throttle - seconds between resend requests (default 60)max_attempts - failed tries before code is invalidated (default 5)