Verification
The package generates a verification token, stores only its hash plus an expiry, and fires an event with the plaintext so your app delivers it over its own channel — mail, SMS or anything else. The package never sends anything. The flow lives on Contacts::verification(); the model’s requestVerification() and confirmVerification() are shorthand for the same calls:
use RoundlyConsulting\Contacts\Facades\Contacts;
// Generate a token and dispatch ContactVerificationRequested($contact, $plainToken).
$token = Contacts::verification()->request($contact); // or $contact->requestVerification()
// Later, confirm with the token the user supplied.
Contacts::verification()->confirm($contact, $token); // or $contact->confirmVerification($token)
// On success: verified_at is set, token fields cleared, ContactVerified fires.Delivering the token
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Contacts\Events\ContactVerificationRequested;
Event::listen(function (ContactVerificationRequested $event): void {
// $event->contact, $event->plainToken — send your own mail/SMS here.
});The plaintext is marked #[SensitiveParameter], so it never appears in stack traces. Requesting again replaces the previous token, and verification_token is hidden when the model is serialized.
Confirming
Confirmation throws InvalidVerificationToken (wrong, absent or voided token) or VerificationExpired (past the TTL). Both extend ContactException. VerificationAttemptsExceeded is a subclass of InvalidVerificationToken, so catch it first when you want a separate message. Confirming an already-verified contact returns it unchanged:
use RoundlyConsulting\Contacts\Exceptions\InvalidVerificationToken;
use RoundlyConsulting\Contacts\Exceptions\VerificationAttemptsExceeded;
use RoundlyConsulting\Contacts\Exceptions\VerificationExpired;
use RoundlyConsulting\Contacts\Facades\Contacts;
try {
Contacts::verification()->confirm($contact, $request->string('code')->toString());
} catch (VerificationAttemptsExceeded) {
// the last attempt is spent and the code is void — catch it before its parent
return back()->withErrors(['code' => 'Too many wrong codes. Request a new one.']);
} catch (InvalidVerificationToken) {
return back()->withErrors(['code' => 'That code is not valid.']);
} catch (VerificationExpired) {
return back()->withErrors(['code' => 'That code has expired. Request a new one.']);
}Attempt limit
Each token survives verification.max_attempts (default 5) wrong guesses. The guess that spends the last one voids the token and throws VerificationAttemptsExceeded — ask for a new token then. The count lives in the database, so parallel requests share one budget, and an expired token costs no attempt. A new request() starts a fresh budget, so rate-limit how often your app lets a user request a token — Laravel’s RateLimiter works well.
A verification belongs to one value
Changing a contact’s value or kind — through update(), sync() or a direct $contact->update([...]) — clears verified_at and voids any pending token, so a token sent to the old address can never confirm the new one. A write that sets verified_at itself, such as an import, is kept:
use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Contacts\Facades\Contacts;
$contact->isVerified(); // true
$updated = Contacts::update($contact, new ContactData(ContactType::Email, '[email protected]'));
$updated->isVerified(); // false — and any pending token is void
$contact->update(['value' => '[email protected]']); // a direct write drops it tooCode or token
With style code (the default) you get a numeric code of code_length digits (1–72), drawn digit by digit so leading zeros are possible. With style token you get token_length random bytes (1–36), hex-encoded — bcrypt reads only the first 72 characters, so a longer setting throws instead of being checked in part:
CONTACTS_VERIFICATION_STYLE=token # 'code' (digits) or 'token' (hex)
CONTACTS_VERIFICATION_TTL=15 # minutes
CONTACTS_VERIFICATION_TOKEN_LENGTH=32 # bytes -> a 64-character hex string (1-36)
CONTACTS_VERIFICATION_MAX_ATTEMPTS=5 # wrong guesses before the token is voidedMarking verified directly
When ownership is already proven elsewhere — an OAuth email, an admin check — skip the token flow:
Contacts::verification()->markVerified($contact); // now (idempotent)
Contacts::verification()->markVerified($contact, now()->subDay()); // ...or at a given moment
$contact->isVerified(); // true
Contact::query()->verified()->get(); // verified contacts
Contact::query()->verified(false)->get(); // unverified contacts
Contact::query()->pendingVerification()->get(); // unverified, token issuedThe factory ships a pendingVerification() state to match the pendingVerification() scope — see Testing.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.