Self-hosted, Laravel-native email testing. One package ships both sides:
- Receiver — stores ingested email in an isolated database (default SQLite; any Laravel-supported driver works), serves a Vue 3 inbox UI at
/mailulator. - Driver — a Symfony Mailer transport registered as
mailulator. SetMAIL_MAILER=mailulatorand outbound mail flows to the receiver — over HTTP for standalone deployments, in-process for in-app ones.
- PHP
^8.3 - Laravel
^11 || ^12 || ^13
composer require webteractive/mailulator
php artisan mailulator:installmailulator:install publishes:
app/Providers/MailulatorServiceProvider.php— customize the auth gate here.config/mailulator.php— receiver + driver config.
It then runs migrations against the isolated mailulator connection and creates a protected Default inbox (it cannot be renamed or deleted; at least one inbox must always exist).
The install prints the Default inbox's bearer token. In-app deployments don't need it for delivery, but save it anyway — any sender app you later point at this receiver will need it, and it isn't shown again.
Publish compiled UI assets:
php artisan vendor:publish --tag=mailulator-assetsRe-run this tag after each composer update.
Two shapes, distinguished by where the inbox UI lives.
The app sending the mail is also the app you read it from. Install Mailulator there, set MAIL_MAILER=mailulator, and the captured mail shows up at /mailulator on the same host. One app, one inbox, no plumbing.
# .env
MAIL_MAILER=mailulatorNo URL, no token. The transport detects in-app mode (driver enabled + receiver enabled + no MAILULATOR_URL) and writes directly to the Default inbox via StoreIncomingEmail, bypassing HTTP entirely. Open /mailulator on the same app to read the captured mail.
Best for: local development, staging, demo environments — any app that just wants to "swallow" its own outbound mail and read it back in place.
A single Laravel app exists just to host inboxes and the UI. Any number of other apps point at it via MAILULATOR_URL and route to the right inbox via MAILULATOR_TOKEN. One UI, many senders.
On the standalone receiver — install the package as receiver-only:
# .env
MAILULATOR_RECEIVER_ENABLED=true
MAILULATOR_DRIVER_ENABLED=falseCreate one inbox per sender app from the UI (or reuse the seeded Default). Each inbox has its own bearer token; that token is the only thing that ties a sender to its inbox.
On each sender app — install the package as driver-only and point it at the receiver with the inbox's token:
# .env
MAIL_MAILER=mailulator
MAILULATOR_URL=https://mailulator.your-domain.test
MAILULATOR_TOKEN=<token for this app's inbox>
MAILULATOR_RECEIVER_ENABLED=false
MAILULATOR_DRIVER_ENABLED=trueTo onboard another sender app, repeat the sender-side .env with a different inbox token. The receiver doesn't care how many apps point at it. Open /mailulator on the receiver to read mail across all inboxes.
The SPA is gated. Edit app/Providers/MailulatorServiceProvider.php:
protected function gate(): void
{
Gate::define('viewMailulator', fn ($user) =>
in_array(optional($user)->email, [
'you@example.com',
])
);
}Default: local environment only. Non-local without a customized gate → 403.
Optional hooks in the same provider:
public function boot(): void
{
parent::boot();
Mailulator::canViewInbox(fn ($user, $inboxId) =>
$user->inboxes()->where('inboxes.id', $inboxId)->exists()
);
Mailulator::manage(fn ($user) => $user->is_admin ?? false);
}| Variable | Default | Purpose |
|---|---|---|
MAIL_MAILER |
— | Set to mailulator to route outbound email through this driver. |
MAILULATOR_URL |
— | Base URL of the receiver. |
MAILULATOR_TOKEN |
— | Per-inbox bearer token printed by mailulator:install or the admin UI. |
MAILULATOR_TIMEOUT |
5 |
HTTP timeout (seconds) for ingest calls. |
MAILULATOR_ON_FAILURE |
log |
log (warn + return), silent (return), or throw (raise TransportException). |
MAILULATOR_DRIVER_ENABLED |
true |
Set false to install as receiver-only. |
A receiver outage will not break the sender app's request unless MAILULATOR_ON_FAILURE=throw.
| Variable | Default | Purpose |
|---|---|---|
MAILULATOR_RECEIVER_ENABLED |
true |
Turn receiver off to install as driver-only. |
MAILULATOR_DB_CONNECTION |
mailulator |
Connection name to use. Set to any connection defined in your host app's config/database.php (e.g. mysql) to share that DB; leave as mailulator for an isolated, package-managed connection. |
MAILULATOR_DB_DRIVER |
sqlite |
Driver for the auto-managed connection — only used when MAILULATOR_DB_CONNECTION=mailulator and the host hasn't pre-defined it. |
MAILULATOR_SQLITE_PATH |
database_path('mailulator.sqlite') |
SQLite file, auto-touched. |
MAILULATOR_DB_HOST / _PORT / _DATABASE / _USERNAME / _PASSWORD / _CHARSET |
— | Credentials for the auto-managed connection (non-SQLite drivers). |
MAILULATOR_ATTACHMENTS_DISK |
local |
Filesystem disk for attachment bytes. |
MAILULATOR_RATE_LIMIT |
600 |
Ingest requests/min per inbox. |
MAILULATOR_RETENTION_DAYS |
30 |
Default retention for newly created inboxes. Per-inbox override available; null keeps forever. |
MAILULATOR_UI_PATH |
mailulator |
SPA path prefix. |
MAILULATOR_UI_DOMAIN |
— | Optional subdomain (e.g. mail.your-staging.com). |
MAILULATOR_REALTIME_ENABLED |
true |
Master switch for realtime updates. |
MAILULATOR_REALTIME |
polling |
polling or broadcast. |
MAILULATOR_POLL_INTERVAL |
3 |
Polling interval (seconds). |
MAILULATOR_BROADCASTER |
reverb |
reverb or pusher when MAILULATOR_REALTIME=broadcast. |
POST /api/emails — bearer-token authenticated, rate-limited per inbox.
Accepts JSON (base64 attachments) or multipart/form-data (UploadedFile attachments). Returns 201 { "id": <email_id> }.
Three states, controlled by two env vars:
MAILULATOR_REALTIME_ENABLED |
MAILULATOR_REALTIME |
Behavior |
|---|---|---|
true (default) |
polling (default) |
UI polls every MAILULATOR_POLL_INTERVAL seconds. Zero extra deps. |
true |
broadcast |
Echo subscribes to mailulator.inbox.{id} private channels. |
false |
— | Static UI. No polling, no broadcast. |
To enable broadcasting, install Reverb / Pusher in the host app, then:
MAILULATOR_REALTIME=broadcast
MAILULATOR_BROADCASTER=reverb
MAILULATOR_ECHO_KEY=...
MAILULATOR_ECHO_CLUSTER=... # Pusher only
MAILULATOR_ECHO_HOST=... # Reverb only
MAILULATOR_ECHO_PORT=...
MAILULATOR_ECHO_SCHEME=https
EmailReceived dispatches to mailulator.inbox.{id} on every ingest. Channel authorization routes through Mailulator::canViewInbox. If MAILULATOR_REALTIME=broadcast is set without a configured MAILULATOR_ECHO_KEY, the client logs a warning and falls back to polling.
- Each inbox has a name, optional retention period, optional color (UI accent), and a hashed bearer token.
- The seeded
Defaultinbox is protected — it cannot be renamed or deleted. - The last remaining inbox cannot be deleted regardless of name.
- Regenerating a key invalidates the previous token immediately at the ingest boundary.
Set retention_days per inbox; the daily PruneEmails job deletes older emails and cleans their attachment files. null = keep forever.
The job is auto-scheduled. Ensure your host app runs schedule:run via cron or schedule:work.
composer update webteractive/mailulator
php artisan vendor:publish --tag=mailulator-assets --force
php artisan migrate --database=mailulatorMIT.