Application email QA

Test signup verification email in Laravel

Build a minimal Laravel test for signup verification email: assert generation, SMTP handoff, inbound delivery, and cleanup—without assuming a CI mail API.

Artisan wrench, envelope, checklist clipboard, and receive-only inbox tray

Separate four layers when testing Laravel signup verification email: application generation, SMTP handoff, inbound delivery, and user experience. Unit and feature tests should assert the first layer with Mail::fake() (or notification fakes). Prove SMTP and inbound with an environment you control—Mailpit, HELO catchers, or a manual receive-only mailbox. Do not conflate “notification was queued” with “Gmail showed the code.”

This teardown sits under test email workflows. Mailby’s developer API exists for programmatic inboxes; this article’s demonstrated path uses Laravel fakes plus optional manual Quick Inbox receiving so the pattern stays clear even in offline CI.

Laravel context and boundaries

Laravel commonly sends signup verification via:

  • MustVerifyEmail + notification
  • Custom Mailable with a signed URL or OTP
  • Queued jobs on database/redis queues

Boundaries:

  • You own the application under test (authorized testing only).
  • Mailby does not send mail for your app.
  • Free Quick Inbox retention is short—see data retention—so automate assertions in-process first.
  • Browser waits for delayed mail are covered in Selenium delayed verification mail.

Official docs: Laravel mail testing (rel="nofollow noopener").

Annotated fixture: working vs failing paths

Working path (feature test):

Mail::fake();

$user = User::factory()->unverified()->create([
    'email' => 'dev+signup@example.test',
]);

event(new Registered($user)); // or your register action

Mail::assertSent(VerifyEmail::class, function ($mail) use ($user) {
    return $mail->hasTo($user->email);
});

Assert subject, that a signed URL exists in the rendered HTML, and that the user remains email_verified_at = null until the link is hit.

Failure path we intentionally keep:

  • Test asserts Mail::assertSent while the real register action only Notification::route’s elsewhere → false confidence.
  • Fix: assert the same channel production uses (Mail vs Notification::fake()).

Limitation: fakes never catch DNS/DKIM mistakes. Add one staging smoke with real SMTP.

Mechanism and failure cases

LayerWhat breaksTypical signal
GenerationWrong mailable, missing queue workerFake shows nothing sent
SMTPBad credentials, blocked portLogs / ESP errors
InboundDisposable domain blocked, spamManual inbox empty
UXExpiry, double-submitLink 403; OTP invalid

Cleanup matters: truncate users, invalidate signed URLs, flush Redis queues, delete temporary inboxes.

Test case table

Test caseExpected messageObservable evidenceNegative case
Register happy pathVerifyEmail to userMail::assertSentAssert not sent for existing email
Already verifiedNo new mailMail::assertNothingSentResend still gated
Resend throttleOne mail / windowCount === 1Burst sends 5
Signed URL valid200 + verified flagHTTP test hitTampered signature 403
OTP numeric6 digits in bodyRendered string matchEmpty body
Queue syncJob processedQueue::fake + assertPushedJob never released

Concrete worked example

  1. Feature test with Mail::fake() covers generation (CI green).
  2. Staging .env points SMTP at Mailpit; register once; open Mailpit UI; confirm link.
  3. Optional cross-check: register using a Quick Inbox address to see HTML preview and extracted action link—manual only; label automation via API as a separate developers workflow you opt into.
  4. Teardown: delete staging user; purge inbox.

Counterexample: “CI waits on a public temp mail HTML scrape.” Flaky, retention-short, and ethically messy. Prefer fakes + private catcher.

Alternatives and durable mailboxes

  • Mail::fake / Notification::fake: default for unit/feature.
  • Mailpit / Mailhog: local SMTP catcher.
  • ESP test mode: provider sandboxes.
  • Mailby Developer: API-driven receive when you need shared staging inboxes (developers, account/developer).
  • Production users: durable addresses—never point real customers at disposable QA inboxes.

Short answers

What causes signup verification email issues in Laravel?
Misconfigured mailer, queue not running, wrong fake, or inbound filtering—not always the template.

What should I do first?
Mail::fake() assert on the register action; then one real SMTP smoke.

When is a permanent address safer?
For human UAT accounts that must recover later.

What evidence changes the recommendation?
If fakes pass but staging SMTP fails, debug transport—not the Blade view.

Sources, test date, limitations

  • Patterns validated against Laravel mail testing docs; editorial date 2026-09-24.
  • Laravel mail testing.
  • Product: developers, inbox.
  • Limitation: framework versions differ; adapt namespaces.

Queues, horizon, and the “it works in tinker” trap

Laravel developers often prove a Mailable in tinker with Mail::to()->send(), then wonder why HTTP register does nothing. Check:

  • QUEUE_CONNECTION — if not sync, run a worker in CI (php artisan queue:work --once) or assert Queue::assertPushed.
  • ShouldQueue on the notification — fake the queue, not only Mail.
  • Multiple mailers — Mail::mailer('smtp')->assertSent when using non-default mailers.

A green feature test that never boots the queue worker is a partial truth. Document in the README which suite requires workers.

Signed URL expiry and clock skew

URL::temporarySignedRoute failures look like “email broken” when the link is fine and the server clock is wrong. In CI, freeze time with Carbon (Carbon::setTestNow) around generation and consumption. Negative case: travel beyond expiry and expect 403.

HTML vs text parts in assertions

Many verification mails are multipart. Asserting only assertSeeInHtml misses text/plain clients. Render both:

$mailable = new VerifyEmail($user);
$html = $mailable->render();
// or build ->assertSee in Mail::assertSent closure using $mail->assertSeeInHtml / assertSeeInText where available

If you extract OTPs with regex, run the same regex on both parts to avoid “HTML-only code” bugs.

How this differs from the test-email-workflows hub

The hub surveys strategies across stacks. This page is a Laravel-specific teardown with fake vs SMTP layers, a test-case table, and explicit cleanup. Selenium waits belong in the sibling automation article, not here.

CI matrix recommendation

JobWhat it provesDuration
Feature + Mail::fakeGeneration & auth rulesSeconds
Integration + MailpitSMTP handoff~1 min
Nightly staging smokeReal ESPMinutes
Manual Quick InboxVisual HTMLHuman

Keep the first job mandatory on every PR. Promote failures upward only when lower layers pass.

Cleanup recipes that prevent flaky re-runs

  • Use unique emails per test: Str::uuid().'@example.test'.
  • RefreshDatabase or transactional tests.
  • Purge Redis if verification throttles live in cache.
  • Delete Mailpit messages via API between cases.
  • If you used Mailby Developer inboxes, delete via API so addresses do not pile up (developers).

Security note for test data

Never paste production user emails into fixtures. Never commit ESP API keys. Use .env.testing with local catchers. Temporary public inboxes in CI risk leaking OTPs for apps that accidentally point at staging with real users—keep allowlists tight.

Localization and timezone footguns

Verification copy in lang/en may not match lang/es assertions. Parameterize expected subject strings. For OTP expiry text, assert presence of the code rather than full sentences when locales vary by user preference mid-test.

Notification versus Mailable dual stacks

Laravel apps sometimes send VerifyEmail notification on web register and a custom Mailable on API register. Duplicate tests per entrypoint. A single Mail::fake on the web path will not catch API regressions.

Observability hooks for staging

Log a correlation ID in the verification mail footer in non-production. When Selenium or humans report “no mail,” grep logs for that ID across queue and SMTP adapter logs. Faster than debating whether the test email was typoed.

Full minimal feature test sketch

Beyond the earlier snippet, assert state transitions:

public function test_registration_sends_verification_and_blocks_unverified_dashboard(): void
{
    Mail::fake();
    $response = $this->post('/register', [/* valid payload */]);
    $response->assertRedirect();
    $user = User::where('email', 'new@example.test')->firstOrFail();
    $this->assertNull($user->email_verified_at);
    Mail::assertSent(VerifyEmail::class, 1);
    $this->actingAs($user)->get('/dashboard')->assertRedirect('/email/verify');
}

Add a sibling test that hits the signed URL and asserts email_verified_at not null and dashboard 200. Keep both tests free of sleep().

Failure injection catalog

InjectionExpect
Invalid SMTP env in staging smokeError logged; user sees safe message
Queue downJob pending; worker recovery sends later
Rate limit exceeded429; no extra mails
User already verifiedNo mail; friendly status

Automate what you can at the HTTP layer; reserve Mailpit for transport.

Documentation debt

Link this article from your README#email-testing. Future hires should not invent scrapers. Point to developers if the team adopts Mailby API inboxes for shared staging, and to Selenium waits for browser layers.

Environment matrix for mailers

EnvMAIL_MAILERPurpose
phpunitarray / fakeDeterministic asserts
localsmtp → MailpitVisual + header checks
stagingreal ESP sandboxDeliverability
productionreal ESPCustomers only

Never point phpunit at production ESP credentials. Never point staging at customer lists. Keep .env.example documenting each row so new developers do not “temporarily” use the prod key.

Idempotent resend endpoints

Test that hitting “resend verification” twice within the throttle window does not create five jobs. Assert mail count and HTTP status. Product abuse and flaky Selenium waits both shrink when resend is disciplined.

Pest versus PHPUnit style

Pest users write the same fakes with different syntax; the layer model does not change. Prefer project-consistent style. What matters is asserting the production channel, cleaning unique emails, and keeping SMTP smoke outside the default PR job. If your team mixes Pest feature tests with PHPUnit unit tests, document which job runs mail fakes so coverage does not silently drop.

Storing raw MIME for debugging

On staging failures, persist the .eml from Mailpit to an artifact bucket with a 7-day lifecycle. Diff headers when ESP configs drift. Do not store production MIME with PII in open Slack channels. Temporary Mailby receives for manual QA should be deleted after the .eml export if you need longer analysis on your own disk.

One-paragraph definition of done

Signup verification email testing is done when fakes assert generation on every PR, one staging SMTP smoke ran this week, unique addresses prevent collisions, queues are drained or asserted, and no temporary inbox is left holding customer-shaped data after the run.

Conclusion

Assert generation in CI; prove SMTP and inbound in staging; clean up users and queues. Use Quick Inbox for manual receiving checks when useful, and the Developer API when you intentionally automate receive—without pretending a fake assertion delivered mail to the internet.

Try it on Mailby

Open a receive-only disposable inbox when a short-lived address fits the job — session-bound, with timed purge.