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.

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/redisqueues
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::assertSentwhile the real register action onlyNotification::route’s elsewhere → false confidence. - Fix: assert the same channel production uses (
MailvsNotification::fake()).
Limitation: fakes never catch DNS/DKIM mistakes. Add one staging smoke with real SMTP.
Mechanism and failure cases
| Layer | What breaks | Typical signal |
|---|---|---|
| Generation | Wrong mailable, missing queue worker | Fake shows nothing sent |
| SMTP | Bad credentials, blocked port | Logs / ESP errors |
| Inbound | Disposable domain blocked, spam | Manual inbox empty |
| UX | Expiry, double-submit | Link 403; OTP invalid |
Cleanup matters: truncate users, invalidate signed URLs, flush Redis queues, delete temporary inboxes.
Test case table
| Test case | Expected message | Observable evidence | Negative case |
|---|---|---|---|
| Register happy path | VerifyEmail to user | Mail::assertSent | Assert not sent for existing email |
| Already verified | No new mail | Mail::assertNothingSent | Resend still gated |
| Resend throttle | One mail / window | Count === 1 | Burst sends 5 |
| Signed URL valid | 200 + verified flag | HTTP test hit | Tampered signature 403 |
| OTP numeric | 6 digits in body | Rendered string match | Empty body |
| Queue sync | Job processed | Queue::fake + assertPushed | Job never released |
Concrete worked example
- Feature test with
Mail::fake()covers generation (CI green). - Staging
.envpoints SMTP at Mailpit; register once; open Mailpit UI; confirm link. - 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.
- 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 notsync, run a worker in CI (php artisan queue:work --once) or assertQueue::assertPushed.ShouldQueueon the notification — fake the queue, not only Mail.- Multiple mailers —
Mail::mailer('smtp')->assertSentwhen 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
| Job | What it proves | Duration |
|---|---|---|
| Feature + Mail::fake | Generation & auth rules | Seconds |
| Integration + Mailpit | SMTP handoff | ~1 min |
| Nightly staging smoke | Real ESP | Minutes |
| Manual Quick Inbox | Visual HTML | Human |
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'. RefreshDatabaseor 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
| Injection | Expect |
|---|---|
| Invalid SMTP env in staging smoke | Error logged; user sees safe message |
| Queue down | Job pending; worker recovery sends later |
| Rate limit exceeded | 429; no extra mails |
| User already verified | No 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
| Env | MAIL_MAILER | Purpose |
|---|---|---|
| phpunit | array / fake | Deterministic asserts |
| local | smtp → Mailpit | Visual + header checks |
| staging | real ESP sandbox | Deliverability |
| production | real ESP | Customers 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.
