DEV Community

Cover image for Mail::alwaysTo() Guards One Mailer: The Staging Leak It Leaves Open
Ivan Mykhavko
Ivan Mykhavko

Posted on AI-assisted

Mail::alwaysTo() Guards One Mailer: The Staging Leak It Leaves Open

A few weeks ago I saved a link about keeping test emails away from real users. The plan was simple: add Mail::alwaysTo() to our staging setup and stop worrying. The Laravel docs list it for local development, and Laravel News plus at least five other blog posts I found carry it over to staging. None of them mention a limit.

Before shipping it, I opened the framework source to see what that call actually does. Turns out, it guards only one of your mailers.

The Safety Net Everyone Copies

Here is the usual version, adapted from the docs and wrapped in an environment check:

use Illuminate\Support\Facades\Mail;

public function boot(): void
{
    if (! $this->app->isProduction()) {
        Mail::alwaysTo('[email protected]');
    }
}
Enter fullscreen mode Exit fullscreen mode

Every message sent through it gets its to replaced, and cc and bcc are removed. Looks complete.

The Problem

Now add a second mailer. Say transactional mail goes through the default one and invoices through another:

// config/mail.php
'default' => env('MAIL_MAILER', 'smtp'),

'mailers' => [
    'smtp' => [/* ... */],
    'billing' => ['transport' => 'postmark'],
],
Enter fullscreen mode Exit fullscreen mode

I tested this in a sandbox with Laravel 12.43, both mailers on the array transport, and Mail::alwaysTo('[email protected]') in place:

Mail::to('[email protected]')->send(new InvoiceMail());
// sent to: [email protected]

Mail::mailer('billing')->to('[email protected]')->send(new InvoiceMail());
// sent to: [email protected]
Enter fullscreen mode Exit fullscreen mode

Notifications have the same hole. MailMessage lets you pick a mailer too:

public function toMail(object $notifiable): MailMessage
{
    return (new MailMessage)
        ->mailer('billing')
        ->line('Your invoice is ready.');
}
// sent to: the real customer
Enter fullscreen mode Exit fullscreen mode

Even with scrubbed staging data, real addresses slip through: admins, seeded clients, imported leads. Each one gets the invoice.

Pointing MAIL_MAILER at Mailpit or log on staging doesn't close this either. It swaps the default mailer only, so if staging has the real Postmark key, Mail::mailer('billing') still uses it.

Why It Happens

Mail::alwaysTo() is not a global setting. The facade points to MailManager, and MailManager forwards unknown calls to the default mailer only:

// Illuminate\Mail\MailManager
public function __call($method, $parameters)
{
    return $this->mailer()->$method(...$parameters);
}
Enter fullscreen mode Exit fullscreen mode

So alwaysTo is stored on one Mailer instance. When you ask for Mail::mailer('billing'), the manager builds a separate instance in resolve(), and that instance gets its global addresses from config:

foreach (['from', 'reply_to', 'to', 'return_path'] as $type) {
    $this->setGlobalAddress($mailer, $config, $type);
}
Enter fullscreen mode Exit fullscreen mode

setGlobalAddress() reads mail.mailers.billing.to first, then falls back to mail.to. Your alwaysTo call is never part of that.

This isn't new. Issue #44024 described the same thing in 2022 and was closed as a support question. The logic is the same in Laravel 12 and on the 13.x branch.

There is a second way it fails, under Octane. Service providers boot once, but Octane forgets the resolved mailers between requests, so the redirect set in boot() is lost even in a single-mailer app. I didn't run Octane for this one. A Laravel maintainer confirmed it in discussion #53727.

The Fix

Put the redirect where every configured mailer reads it: the config. config/mail.php has no to key by default, but MailManager picks one up if you add it:

// config/mail.php
'to' => [
    'address' => env('MAIL_TO_ADDRESS'),
    'name' => env('MAIL_TO_NAME', 'Staging Inbox'),
],
Enter fullscreen mode Exit fullscreen mode
# .env on staging only
MAIL_TO_ADDRESS=[email protected]
Enter fullscreen mode Exit fullscreen mode

In the same sandbox, both mailers now sent to [email protected]. The address is applied each time a mailer is built, so it also survives Mail::forgetMailers(), which is what Octane does between requests. I checked that part with a plain call, not a running Octane server.

When MAIL_TO_ADDRESS is not set, nothing changes, so production is unaffected without any if in a provider. Leave the key out of the production .env entirely. An empty MAIL_TO_ADDRESS= is still a value, and every send then fails with RfcComplianceException.

One trade-off: this fails open. Forget the variable on a new staging box and mail goes out for real. I'd add a guard for that:

public function boot(): void
{
    if ($this->app->environment('staging') && blank(config('mail.to.address'))) {
        throw new RuntimeException('MAIL_TO_ADDRESS must be set on staging.');
    }
}
Enter fullscreen mode Exit fullscreen mode

Heads up: two exceptions. A mailer with its own to key in mail.mailers wins over the global one, even 'to' => null. And a mailer built on the fly with Mail::build([...]) skips global addresses completely.

A Test That Keeps It Fixed

The per-mailer to is the one that bites later. Someone adds a to to one mailer months later, never having read this config. So I'd rather have a test that checks every mailer against the real config. First, give the test environment the address:

<!-- phpunit.xml -->
<env name="MAIL_TO_ADDRESS" value="[email protected]"/>
Enter fullscreen mode Exit fullscreen mode
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

final class MailRedirectTest extends TestCase
{
    public function test_every_mailer_sends_to_the_staging_inbox(): void
    {
        // Arrange
        $mailers = array_keys(config('mail.mailers'));

        foreach ($mailers as $name) {
            config(["mail.mailers.{$name}.transport" => 'array']);
        }

        Mail::forgetMailers();

        foreach ($mailers as $name) {
            // Act
            Mail::mailer($name)->raw('ping', fn ($message) => $message->to('[email protected]'));

            // Assert
            $sent = Mail::mailer($name)->getSymfonyTransport()->messages()->last()->getOriginalMessage();

            $this->assertSame('[email protected]', $sent->getTo()[0]->getAddress(), "Mailer [{$name}] escaped the redirect");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It swaps every transport to array, so nothing is actually sent. Mail::forgetMailers() matters here: a mailer resolved before the test keeps its old transport, and with a log default the assertion crashes on LogTransport, which has no messages().

With the nine mailers from the default config (smtp, ses, postmark, resend, sendmail, log, array, failover, roundrobin) it passes. Give billing its own to and it fails with Mailer [billing] escaped the redirect. Delete the to block from config/mail.php and it fails on smtp. Don't use Mail::fake() here: the fake replaces the manager, so it can't tell you anything about redirects.

Final Thoughts

Mail::alwaysTo() is fine on your laptop, with one mailer and no Octane. For staging, where real addresses live in the database, I'd use mail.to in config, a boot guard for the missing variable, and a test that walks every mailer.


Author's Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Laravel, after the happy path.

Top comments (7)

Collapse
 
kamrankhandev profile image
Kamran Khan •

One more case that quietly escapes: queued mailables. The alwaysTo() redirect lives on the default Mailer instance in the current process, but a queued mailable is serialized and sent by a separate worker's MailManager. Set the redirect at runtime (a console command, Tinker, a test) and it never reaches the worker. Provider-booted redirects do apply in the worker, but die with Mail::forgetMailers() under Octane. The config-based approach survives all of it because it's applied at build time — which is honestly the strongest argument for it over alwaysTo().

Collapse
 
tegos profile image
Ivan Mykhavko •

Good catch. Runtime alwaysTo() never reaches queue worker, so config wins on staging.

Collapse
 
kamrankhandev profile image
Kamran Khan •

Exactly — and the mechanism is worth spelling out, because it's the same trap in both directions. The redirect lives on the in-memory mailer instance, and a queued mailable gets serialized and handed to a worker that boots its own fresh mailer from config. Runtime state never crosses the process boundary, so anything set in a provider's boot() is simply invisible to it.

That's also why config wins here: the worker rebuilds every mailer from config each time, so a to address in config/mail.php travels with the job for free. Runtime redirects like alwaysTo() can't make that trip.

Thread Thread
 
tegos profile image
Ivan Mykhavko •

Yeah, agree. And laravel team probably should be more careful with such things in the docs.

Thread Thread
 
kamrankhandev profile image
Kamran Khan •

I hadn't checked the docs recently, so thanks for the screenshot. It does cover alwaysTo() now, which is an improvement. The gap I still see is that the section reads like the whole story while queued mailables silently break it, and that is exactly why I wrote the post. A one-line warning about the queue path there would save someone a staging incident.

Collapse
 
officialmailkr profile image
오피셜메일 •

Mail::alwaysTo()가 기본 mailer 인스턴스에만 적용되고 Mail::mailer("billing")는 빠진다는 재현이 명확했습니다. 특히 Mail::fake()로는 실제 리다이렉트를 검증할 수 없어서 모든 설정된 mailer를 array 전송으로 시험한다는 부분이 유용하네요. 글에서 예외로 짚은 Mail::build() 같은 동적 생성 경로도 별도 테스트 목록에 넣고, 스테이징에는 실서비스 발송 자격 증명 자체가 없는지 확인하면 방어선을 하나 더 둘 수 있겠습니다.

Collapse
 
tegos profile image
Ivan Mykhavko •

Thanks! Agreed. Mail::build() skips the config loop, so I'd grep for it in CI. And no live credentials on staging is the stronger guard.