Documentation menu

Laravel

Configure Laravel's mail driver to send development email to MailDev, use Mailable previews alongside it, and assert on delivered mail in Dusk and Pest tests.

Laravel reads its mail configuration from the environment. Add this to .env:

MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="app@example.com"
MAIL_FROM_NAME="${APP_NAME}"

Then clear the config cache, or Laravel will keep using the old values:

php artisan config:clear

Use 127.0.0.1 rather than localhost. On some systems localhost resolves to IPv6 ::1 first, and if MailDev is bound to IPv4 only you get a connection refused that looks like MailDev is down when it is not.

Older Laravel versions use MAIL_DRIVER instead of MAIL_MAILER. If the setting appears to have no effect, check which one config/mail.php reads.

Send a test message

Tinker is the quickest path:

php artisan tinker
Mail::raw('It works!', function ($message) {
    $message->to('user@test.com')->subject('Hello from MailDev');
});

Mailables

class OrderShipped extends Mailable
{
    public function __construct(public Order $order) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Your order has shipped');
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'mail.orders.shipped',
            text: 'mail.orders.shipped-text',
        );
    }

    public function attachments(): array
    {
        return [
            Attachment::fromPath(storage_path('invoices/' . $this->order->id . '.pdf'))
                ->as('invoice.pdf')
                ->withMime('application/pdf'),
        ];
    }
}
Mail::to($order->user)->send(new OrderShipped($order));

MailDev shows the rendered markdown-mail HTML, the text alternative, and the PDF as a downloadable attachment — so you can confirm the invoice your job generated is a valid file, not just that the code path ran.

Previews versus MailDev

Laravel can render a Mailable straight from a route, which is a fast loop while you are working on markup:

Route::get('/mail/preview', fn () => new OrderShipped(Order::first()));

That renders a template. MailDev shows the message that was actually delivered — final headers, both MIME parts, encoded attachments — and lets you click the links in it. Use both.

Queued mail

Mail::queue() and queued Mailables only reach MailDev once a worker processes the job. Either run one:

php artisan queue:work

or use the sync driver in development:

QUEUE_CONNECTION=sync

This is the single most common reason mail "disappears" in a Laravel app during development.

Sail and Docker

Laravel Sail ships with Mailpit by default. To use MailDev instead, add it as a service in docker-compose.yml:

    maildev:
        image: 'maildev/maildev'
        ports:
            - '1080:1080'
        networks:
            - sail

and point the app at it by service name, since 127.0.0.1 inside the app container is the app container:

MAIL_HOST=maildev
MAIL_PORT=1025

See the Docker guide for the general rule.

Testing

For unit tests, Mail::fake() and Mail::assertSent() are the right tool — no external process, and assertions on the Mailable itself.

MailDev belongs in browser and end-to-end tests, where mail leaves a real server process. Read the REST API:

function clearInbox(): void
{
    Http::delete('http://localhost:1080/api/email/all');
}

function waitForEmail(string $subject, int $timeoutMs = 10000): array
{
    $deadline = microtime(true) + $timeoutMs / 1000;

    while (microtime(true) < $deadline) {
        $items = Http::get('http://localhost:1080/api/email/summary', [
            'search' => $subject,
        ])->json('items');

        if (! empty($items)) {
            return Http::get('http://localhost:1080/api/email/' . $items[0]['id'])->json();
        }

        usleep(200_000);
    }

    throw new RuntimeException("No email matching '{$subject}' arrived");
}

A Pest test using them:

it('emails a verification link on registration', function () {
    clearInbox();

    $this->post('/register', [
        'email' => 'new-user@test.com',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);

    $email = waitForEmail('Verify');

    expect($email['subject'])->toContain('Verify')
        ->and($email['html'])->toContain('/verify-email/');
});

More patterns in testing email in CI.

Symfony Mailer directly

Laravel's mail layer sits on Symfony Mailer, so the underlying DSN form works anywhere Symfony Mailer is used — including a plain Symfony app:

MAILER_DSN=smtp://127.0.0.1:1025

Troubleshooting

Connection could not be established with host "127.0.0.1:1025" — MailDev is not running, or you are inside a container. Check both.

Changes to .env have no effect. Run php artisan config:clear. A cached config file overrides the environment.

Expected response code 250 but got an empty response — usually MAIL_ENCRYPTION is set to tls against a plaintext listener. Set it to null, or configure TLS on MailDev.

Nothing arrives from a job or notification. No queue worker is running. Set QUEUE_CONNECTION=sync or start queue:work.

Last updated 2026-08-25Edit this pageView as markdown