RedberryProducts/mailbox-for-laravel
Open on GitHub →
Mailbox for Laravel
Mailbox for Laravel captures your application's outgoing mail and serves it through a local, self-hosted dashboard — like Mailtrap or Mailhog, but without an external service or a second process to run. It ships with a fluent testing API that, unlike Mail::fake(), asserts against the fully rendered message: real HTML, real recipients, real attachments.
Installation
Require the package via Composer. In most cases you only want this in development:
composer require redberry/mailbox-for-laravel --dev
Run the install command to publish assets, publish the config file, and run migrations:
php artisan mailbox:install
Point your mailer at the mailbox transport in .env:
MAIL_MAILER=mailbox
The dashboard is then available at /mailbox (or whatever path you configure). The package is auto-discovered — no manual provider registration needed.
Requirements: PHP 8.3+, Laravel 10 / 11 / 12 / 13.
Capturing Mail
Everything your app sends through Laravel's Mail facade is intercepted by the mailbox transport and stored before delivery. Visit the dashboard to see captured messages:
- Sorted newest-first with a live-updating list
- HTML, plain-text, and raw RFC 822 views per message
- Attachment preview and download
- Read/unread tracking, single-message delete, and clear-all
- Recipient filtering and search
- A "Send test email" button for smoke tests
Internally, the pipeline is: transport → normalizer → CaptureService → paired message/attachment store. The architectural details are in ARCHITECTURE.md.
Transport decoration (capture + real delivery)
By default, the mailbox transport captures mail without sending it. If you want emails to appear in the dashboard and be delivered for real (useful in staging or for monitoring production mail), set the MAILBOX_DECORATE env variable to any mailer name your app already knows:
MAIL_MAILER=mailbox
MAILBOX_DECORATE=smtp
This tells the service provider to resolve the smtp mailer's Symfony transport and wrap it with MailboxTransport. Every outgoing email is captured locally first, then forwarded to the decorated transport for actual delivery. Any mailer registered in config/mail.php works — smtp, ses, postmark, log, etc.
To go back to capture-only mode, remove MAILBOX_DECORATE (or set it to empty).
Testing your emails
Mail::fake() only tells you a Mailable was queued; it can't tell you whether the rendered email would actually contain what you expect. This package's assertions run against the captured message after Laravel renders it, so you can assert on subject lines, recipients, HTML content, and attachments as the recipient would see them.
Add the InteractsWithMailbox trait to your test. It clears the mailbox before every test, and exposes $this->mailbox() for assertions.
Pest:
use Redberry\MailboxForLaravel\Testing\InteractsWithMailbox;
uses(InteractsWithMailbox::class);
PHPUnit:
class OrderEmailTest extends TestCase
{
use InteractsWithMailbox;
}
Test environment
Two things must be true before any Mailbox::assert* call can pass:
- Mail must go through the
mailboxtransport. Laravel's defaultphpunit.xmlsetsMAIL_MAILER=array, which bypasses the transport entirely, so nothing is captured and every assertion fails with "No messages were captured". - The store must be ready. The default
sqlitedriver expectsstorage/app/mailbox/mailbox.sqlitewith its tables in place. On a fresh checkout or CI runner they do not exist, so the first test errors with "no such table" as soon as the trait tries to clear the store.
The InteractsWithMailbox trait clears whatever store is configured before every test, including the attachments directory on the mailbox filesystem disk, so pointing tests at your development inbox wipes both its messages and its attachments. The simplest setup is the file driver at a dedicated path plus a dedicated attachments path: it creates the directories on first use and needs no migrations. Add these entries inside the existing <php> element of your phpunit.xml:
<env name="MAIL_MAILER" value="mailbox"/>
<env name="MAILBOX_STORE_DRIVER" value="file"/>
<env name="MAILBOX_STORE_FILE_PATH" value="storage/framework/testing/mailbox"/>
<env name="MAILBOX_ATTACHMENTS_PATH" value="testing/attachments"/>
MAILBOX_ATTACHMENTS_PATH is relative to the mailbox disk (storage/app/mailbox by default), and attachment contents are written there regardless of the store driver. The trait clears it as soon as the test application boots, before any Storage::fake('mailbox') call in a test can take effect, so set the path even if you also fake the disk.
If you would rather keep the sqlite or database driver in tests, the tables have to exist before the first test runs. php artisan mailbox:install creates them on the configured mailbox connection, so add it as a step before your test command in CI. It runs in its own process, which has two consequences:
- The connection must be persistent: a file-backed SQLite database or a database server. An in-memory (
:memory:) connection loses its schema when the install command exits, so the tests still see no tables. Use thefiledriver above if your test database is in-memory. - It reads
.env, not the<env>entries inphpunit.xml. If your tests overrideMAILBOX_STORE_DATABASE_CONNECTIONorMAILBOX_STORE_DATABASE_TABLEthere, pass the same values to the install step or it will migrate a different connection:
MAILBOX_STORE_DATABASE_CONNECTION=mailbox_testing php artisan mailbox:install
vendor/bin/pest
RefreshDatabase does not help here: the package runs its migrations itself rather than publishing them into your app.
Collection-level assertions
use Redberry\MailboxForLaravel\Facades\Mailbox;
use Redberry\MailboxForLaravel\DTO\MailboxMessageData;
Mailbox::assertSentCount(2);
Mailbox::assertNothingSent();
Mailbox::assertSentTo('user@example.com');
Mailbox::assertNotSentTo('admin@example.com');
Mailbox::assertSent(fn (MailboxMessageData $m) => $m->subject === 'Welcome');
Mailbox::assertSent(
fn (MailboxMessageData $m) => str_contains($m->subject, 'Newsletter'),
expectedCount: 3,
);
Per-message fluent assertions
Call firstSent() to chain assertions against a single captured message:
Mailbox::firstSent()
->assertHasSubject('Order Confirmation')
->assertFrom('noreply@shop.com')
->assertHasTo('buyer@example.com')
->assertSeeInHtml('Order #12345')
->assertDontSeeInHtml('error')
->assertHasAttachment('invoice.pdf', 'application/pdf')
->assertAttachmentCount(1);
firstSent() also accepts a filter callback:
Mailbox::firstSent(fn (MailboxMessageData $m) => $m->subject === 'Password Reset')
->assertHasTo('user@example.com')
->assertSeeInHtml('Reset your password');
Reference — per-message assertions
| Method | Description |
|---|---|
assertFrom($email, $name?) |
Assert the sender email (and optionally name) |
assertHasTo($email, $name?) |
Assert a "to" recipient exists |
assertHasCc($email, $name?) |
Assert a "cc" recipient exists |
assertHasBcc($email, $name?) |
Assert a "bcc" recipient exists |
assertHasReplyTo($email, $name?) |
Assert a "reply-to" address exists |
assertHasSubject($subject) |
Assert exact subject match |
assertSubjectContains($substring) |
Assert subject contains a substring |
assertSeeInHtml($string) |
Assert HTML body contains string |
assertDontSeeInHtml($string) |
Assert HTML body does not contain string |
assertSeeInText($string) |
Assert text body contains string |
assertDontSeeInText($string) |
Assert text body does not contain string |
assertSeeInOrderInHtml($strings) |
Assert strings appear in order in HTML |
assertSeeInOrderInText($strings) |
Assert strings appear in order in text |
assertHasAttachment($filename, $mimeType?) |
Assert attachment exists |
assertHasNoAttachments() |
Assert no attachments |
assertAttachmentCount($count) |
Assert number of attachments |
assertHasHeader($name, $value?) |
Assert header exists (optionally with value) |
End-to-end example
it('sends welcome email with getting started guide', function () {
$this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret123',
]);
Mailbox::assertSentCount(1);
Mailbox::assertSentTo('john@example.com');
Mailbox::firstSent()
->assertHasSubject('Welcome, John!')
->assertFrom('noreply@myapp.com')
->assertSeeInOrderInHtml(['Welcome', 'Getting Started', 'Support'])
->assertHasAttachment('getting-started.pdf');
});
Configuration
Defaults live in config/mailbox.php and work without modification. Publish the config file only — without touching views, assets, or migrations — if you need to customize:
php artisan vendor:publish --tag=mailbox-config
Add --force to overwrite an existing config/mailbox.php after a package upgrade.
The keys you're most likely to touch:
decorate— a mailer name (e.g.smtp) to forward captured mail to for real delivery. Defaultnull(capture-only). See Transport decoration.path— the URI prefix the dashboard lives at (defaultmailbox).middleware— routes run under thewebgroup by default; add your own guards here (e.g.auth) for staging access control.gate— the Gate ability checked before dashboard access (defaultviewMailbox). See Authorization.store.driver—sqlite(default),database, orfile. See Storage.store.database.connection— the connection the DB driver uses. Defaults to an auto-createdmailboxSQLite file isolated from your app's database.retention— seconds beforemailbox:clear --outdatedprunes a message (default 24 h).retention_schedule— whentrue, the package auto-registers a dailymailbox:clear --outdatedon Laravel's scheduler. Disabled by default (false); enable it to let the package prune automatically, or wire the purge yourself.per_page— dashboard pagination size (default 20, clamped 1–100).attachments.disk— the filesystem disk attachment content is written to (defaultmailboxlocal disk).
Environment variables
| Variable | Default | Description |
|---|---|---|
MAILBOX_ENABLED |
true (non-production) |
Master on/off switch — routes and transport only register when true |
MAILBOX_DECORATE |
null |
Mailer name to decorate — capture + forward for real delivery (e.g. smtp, ses) |
MAILBOX_PATH |
mailbox |
URL prefix for the dashboard |
MAILBOX_GATE |
viewMailbox |
Gate ability checked by the authorize middleware |
MAILBOX_UNAUTHORIZED_REDIRECT |
null |
Redirect target on gate denial (null = 403 response) |
MAILBOX_STORE_DRIVER |
sqlite |
sqlite, database, or file |
MAILBOX_STORE_DATABASE_CONNECTION |
mailbox |
Connection name for the DB driver |
MAILBOX_STORE_DATABASE_TABLE |
mailbox_messages |
Messages table name |
MAILBOX_STORE_FILE_PATH |
storage/app/mailbox |
Path for the file driver |
MAILBOX_RETENTION |
86400 |
Retention period in seconds |
MAILBOX_RETENTION_SCHEDULE |
false |
Auto-register a daily mailbox:clear --outdated on the scheduler |
MAILBOX_PER_PAGE |
20 |
Messages per dashboard page |
MAILBOX_ATTACHMENTS_DISK |
mailbox |
Disk for attachment content |
Storage
SQLite driver (default)
Messages are stored in a dedicated SQLite database at storage/app/mailbox/mailbox.sqlite, fully isolated from your app's main database. This is the zero-config default — no setup required.
Database driver (bring-your-own-connection)
If you'd rather store captured mail in MySQL, Postgres, or another existing connection, switch the driver to database and define the connection in config/database.php:
MAILBOX_STORE_DRIVER=database
'connections' => [
'mailbox' => [
'driver' => 'mysql',
'host' => env('MAILBOX_DB_HOST', '127.0.0.1'),
'database' => env('MAILBOX_DB_DATABASE', 'mailbox'),
'username' => env('MAILBOX_DB_USERNAME', 'root'),
'password' => env('MAILBOX_DB_PASSWORD', ''),
],
],
Or point MAILBOX_STORE_DATABASE_CONNECTION at an existing connection such as mysql. Run php artisan mailbox:install again to create the mailbox_messages and mailbox_attachments tables there. Both sqlite and database use the same Eloquent-backed store internally — the difference is only in how the connection is configured.
File driver
Captures each message to a JSON file under storage/app/mailbox/. Use it when you can't write to a database at all. It's slower for listing and paginates in-memory.
MAILBOX_STORE_DRIVER=file
Attachment disks
Attachment content lives on the mailbox filesystem disk, independent of the message driver. By default the package registers a local disk at storage/app/mailbox/, but — like the database connection — it won't overwrite a disk of the same name you've already defined. To store attachments on S3:
// config/filesystems.php
'disks' => [
'mailbox' => [
'driver' => 's3',
'bucket' => env('MAILBOX_S3_BUCKET'),
'region' => env('MAILBOX_S3_REGION', 'us-east-1'),
],
],
Custom drivers
Implement Contracts\MessageStore for messages, and Contracts\AttachmentStore for their attachments. Register the pair in your service provider and point mailbox.store.driver at your resolver key. See DRIVERS.md for the full driver author guide.
'store' => [
'driver' => 'redis',
'resolvers' => [
'redis' => fn () => new \App\Storage\RedisMessageStore,
],
],
Drivers are always resolved as a pair — if you ship a custom MessageStore, also bind a matching AttachmentStore so attachment reads don't fall through to the database driver.
Authorization
Dashboard access is gated through Laravel's Gate::allows() using the viewMailbox ability. The package defines a default gate that allows access in local environments or whenever mailbox.enabled is true; if you define your own viewMailbox gate, the package will not overwrite it.
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('viewMailbox', fn ($user) => $user?->isAdmin());
}
For staging servers, this gives you authenticated-only access without any extra middleware. You can also point the config's unauthorized_redirect at a login URL if you'd rather redirect than serve a 403.
Captured messages can include passwords, tokens, and personal data, so leave MAILBOX_ENABLED=false in production unless you deliberately want the inbox running there. If you do, define a strict gate and make sure the dashboard sits behind authentication.
Artisan Commands
# Publish assets + config, then run package migrations.
# Flags: --force (overwrite published files), --refresh (drop and rebuild tables),
# --dev (symlink assets for hot reload).
php artisan mailbox:install
# Clear captured mail. With --outdated, only remove messages older than `retention`.
# Auto-pruning is off by default; set MAILBOX_RETENTION_SCHEDULE=true to have the
# --outdated variant run daily on Laravel's scheduler.
php artisan mailbox:clear
php artisan mailbox:clear --outdated
# Upgrade from v1.x to v2.0 — detects stale config, refreshes schema.
# Use --fresh to skip prompts.
php artisan mailbox:upgrade
# Recreate the dev-mode asset symlink (rarely needed directly; --dev on install uses it).
php artisan mailbox:dev-link
Upgrading
See UPGRADE.md for the full v1.x → v2.0.0 migration guide. The quickest path:
composer update redberry/mailbox-for-laravel
php artisan mailbox:upgrade
Breaking changes between major versions are also documented in CHANGELOG.md. Re-publish the config after any upgrade to pick up new keys:
php artisan vendor:publish --tag=mailbox-config --force
When a release changes the storage schema — v2.0.0 switched message ids from auto-increment integers to ULIDs — run php artisan mailbox:install --refresh to drop and recreate the mailbox tables.
Contributing
See CONTRIBUTING.md for local setup, tests, and the coding standards we enforce.
Security Vulnerabilities
If you discover a security vulnerability within this package, please email security@redberry.ge instead of using the issue tracker. All security vulnerabilities will be promptly addressed.
Credits
- Nika Jorjoliani — Creator & maintainer
- Redberry
- All contributors
About Redberry
This package is built and maintained by Redberry, one of the few Official Premier Laravel Partner agencies worldwide. With 250+ Laravel projects shipped across 20+ countries, a 200-person team, and over a decade in the Laravel ecosystem, Redberry has helped startups, SMEs, and publicly traded enterprises in regulated industries build SaaS platforms, custom web applications, APIs, and more. Learn about our Laravel development services.
License
The MIT License (MIT). See LICENSE.md.