Chapter 16 - Mail and localization
Chapter 15 opened Part VII (Observing and Communicating) by controlling how far an event's response chain runs and by reaching directly into the log pipeline. Chapter 16 closes it by turning to two adjacent but distinct layers of the same concern: how an email actually leaves the application outside the Mailable-based flow the documentation covers, and how a piece of translated text is resolved once the standard locale/fallback mechanism is not enough.
The two halves of this chapter share no code. The Mail block, covered here and in the two
sections that follow it, sends alerts to the fulfillment team about a problem order - Order has
no email column in this codebase, so the recipient is deliberately internal, never the customer.
The Localization block later in the chapter revisits Order from a different angle: the status
label shown when a customer looks one up.
Mail::raw(), Mail::plain(), and Mail::html()
Case type: three undocumented methods on Illuminate\Mail\Mailer (and the Mail facade),
sitting beside the documented Mailable-based flow (make:mail, a class with a build() method,
Mail::to(...)->send(new SomeMailable)) that mail.md covers in full.
Alias flag: not
aliases - each one skips a different part of that pipeline rather than just calling send()
under a shorter name: raw() skips the view layer entirely, plain() renders a view but keeps
only its text part, html() accepts HTML directly with no view render step involved at all.
Audience: application developers, no shift toward package authors.
Stability: core mail component, no minor-version churn found while verifying against v13.22.0.
Minimal snippet
Mail::raw('The nightly export finished with 3 warnings.', function ($message) {
$message->to('ops@example.com')->subject('Export warnings');
});
Documented way vs. discovered way
mail.md's own canonical example for sending mail is a Mailable class:
Mail::to($request->user())->send(new OrderShipped($order));
That class needs a build() method, at minimum a view to render, and its own file - reasonable
overhead for a message that will be reused, tested on its own, or grows past a one-off. For a
short, one-off internal alert, all three of that ceremony's pieces can be skipped, each in a
different way:
// No class, no view: the text is the entire message body.
Mail::raw($text, $callback);
// No class: a view still exists, but only its text part is ever sent.
Mail::plain($view, $data, $callback);
// No class, no view: the HTML is already in hand as a string.
Mail::html($html, $callback);
One pitfall applies to all three: Mail::fake() does not observe them the way it observes a
Mailable. Illuminate\Support\Testing\Fakes\MailFake::raw() is an explicit no-op - nothing is
sent and nothing is recorded, so a test asserting on it with Mail::assertSent() would pass
vacuously, or rather never see anything to assert at all. plain() and html() are not
overridden by MailFake in any form, so calling them while Mail::fake() is active falls
straight through to the real mailer and genuinely sends. None of the three can be tested through
the fake; testing them means exercising the real mailer, which is exactly what the real scenario
below does.
Real scenario: alerting the fulfillment team about a problem order
App\Support\Fulfillment\OrderIssueReporter uses all three, one method per method:
class OrderIssueReporter
{
public function reportUrgent(Order $order, string $note): void
{
Mail::raw(
"Order {$order->tracking_code}: {$note}",
function (Message $message) use ($order) {
$message->to(config('services.fulfillment.alert_address'))
->subject("Urgent order issue: {$order->tracking_code}");
}
);
}
public function reportFromTemplate(Order $order, string $note): void
{
Mail::plain(
'emails.fulfillment.issue-plain',
['order' => $order, 'note' => $note],
function (Message $message) use ($order) {
$message->to(config('services.fulfillment.alert_address'))
->subject("Order issue report: {$order->tracking_code}");
}
);
}
public function reportRichAlert(Order $order, string $note): void
{
$html = view('emails.fulfillment.issue-rich', [
'order' => $order,
'note' => $note,
])->render();
Mail::html($html, function (Message $message) use ($order) {
$message->to(config('services.fulfillment.alert_address'))
->subject("Order issue alert: {$order->tracking_code}");
});
}
}
reportUrgent() needs nothing beyond the note itself - there is no view to author for a one-line
ping. reportFromTemplate() reuses a small Blade view, emails.fulfillment.issue-plain, but
plain() forces it into a text-only message regardless of what the view contains, so the view
never needs to worry about HTML at all. reportRichAlert() goes the other way: the view,
emails.fulfillment.issue-rich, is rendered ahead of time with view(...)->render(), and the
resulting HTML string is handed to html() directly - html() never touches the view layer
itself, only the string it is given.
All three are addressed to config('services.fulfillment.alert_address'), never to the customer:
Order has no email column, and this alert is about an order, not to whoever placed it. Testing
any of them means reading from the real mail transport instead of Mail::fake(), per the pitfall
above - this codebase already configures an array transport for tests
(MAIL_MAILER=array in phpunit.xml), which collects real, unsent messages in memory:
$messages = Mail::getSymfonyTransport()->messages();
expect($messages)->toHaveCount(1);
$email = $messages->first()->getOriginalMessage();
expect($email->getTo()[0]->getAddress())->toBe('ops@example.test');
Mail::sendNow()
Case type: an undocumented method on Illuminate\Mail\Mailer (and the Mail/PendingMail
facade chain), sitting beside the documented send(), queue(), and later() that mail.md
covers for a Mailable implementing Illuminate\Contracts\Queue\ShouldQueue.
Alias flag: not
an alias - on a Mailable that does not implement ShouldQueue, send() already sends
immediately, so sendNow() adds nothing there; its entire value is forcing an immediate send on
a Mailable that otherwise would queue.
Audience: application developers, no shift toward queue-worker or package-author concerns.
Stability: core mail component, no minor-version churn found while verifying against v13.22.0.
Minimal snippet
// $delayed implements ShouldQueue.
Mail::to($address)->send($delayed); // queued, delivered whenever a worker picks it up
Mail::to($address)->sendNow($delayed); // sent immediately, queue skipped entirely
Documented way vs. discovered way
mail.md's documented pair for controlling when a queued Mailable is delivered is queue() and
later():
Mail::to($request->user())->queue(new OrderShipped($order));
Mail::to($request->user())->later(now()->addMinutes(10), new OrderShipped($order));
Both only move delivery later: queue() pushes it onto the queue right away, later() schedules
it for a future moment. Neither one goes in the opposite direction - forcing a Mailable that
already implements ShouldQueue to send immediately instead of waiting for a worker.
Mail::sendNow() is that missing direction: it calls the exact same build/render pipeline as
send(), but dispatches through the real transport synchronously regardless of ShouldQueue.
Real scenario: escalating a shipping delay when it cannot wait for a worker
App\Mail\OrderShippingDelayed is the first Mailable in this codebase, and it implements
ShouldQueue:
class OrderShippingDelayed extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Order $order,
public string $reason,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Shipping delay: {$this->order->tracking_code}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.fulfillment.shipping-delayed',
with: [
'order' => $this->order,
'reason' => $this->reason,
],
);
}
public function attachments(): array
{
return [];
}
}
OrderIssueReporter::escalateShippingDelay() sends it one way or the other depending on how
urgent the delay is:
public function escalateShippingDelay(Order $order, string $reason, bool $urgent = false): void
{
$recipient = Mail::to(config('services.fulfillment.alert_address'));
if ($urgent) {
$recipient->sendNow(new OrderShippingDelayed($order, $reason));
return;
}
$recipient->send(new OrderShippingDelayed($order, $reason));
}
A routine delay goes through send() and waits for a queue worker like any other ShouldQueue
Mailable. A delay serious enough to need the fulfillment team's attention right now goes through
sendNow() instead, bypassing the queue entirely regardless of the Mailable's own
ShouldQueue contract. Mail::fake() makes the difference directly observable: the same
Mailable class ends up in different collections depending on which method sent it.
Mail::assertQueued(OrderShippingDelayed::class, fn (OrderShippingDelayed $mail) => /* ... */);
Mail::assertNotSent(OrderShippingDelayed::class); // not urgent: queued, not sent
Mail::assertSent(OrderShippingDelayed::class, fn (OrderShippingDelayed $mail) => /* ... */);
Mail::assertNotQueued(OrderShippingDelayed::class); // urgent: sent, never queued
Mail::alwaysFrom(), Mail::alwaysReplyTo(), and Mail::alwaysReturnPath()
Case type: an undocumented runtime trio on Illuminate\Mail\Mailer (and the Mail facade),
beside the documented, config-time-only 'from' key in config/mail.php that mail.md covers.
The trio stops at three on purpose: Mail::alwaysTo(), the fourth method of the same family on
the same class, is documented in mail.md as the way to route every message to a single address,
and is not part of this entry.
Alias flag: not aliases - the config key can only ever be set once, at deploy time, the same for every environment reading that file; these three add a runtime, environment-conditional override on top of it, and reply-to/return-path have no config-time equivalent at all, not just a more limited one.
Audience: application developers, no shift toward package authors.
Stability: core mail component, no minor-version churn found while verifying against v13.22.0.
Minimal snippet
Mail::alwaysFrom('capture@example.test');
Mail::alwaysReplyTo('capture@example.test');
Mail::alwaysReturnPath('capture@example.test');
// Every message built by this mailer afterward uses that address for all three,
// regardless of what the Mailable/raw()/plain()/html() call itself specifies.
Documented way vs. discovered way
mail.php's documented 'from' key sets the sender once, for every environment that reads the
same file:
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
There is no equivalent key at all for reply-to or return-path, and the from-address itself cannot
change based on anything the application decides at runtime - only on which .env file happened
to be loaded. Mail::alwaysFrom(), Mail::alwaysReplyTo(), and Mail::alwaysReturnPath() cover
both gaps: callable from application code, conditionally, whenever a real decision (not just a
fixed environment file) determines the sender.
One pitfall applies to all three, the same shape as the one raw()/plain()/html() already
ran into: Mail::fake() does not observe them either. Illuminate\Support\Testing\Fakes\MailFake does not define any of the three, so
calling them while Mail::fake() is active falls through to the real MailManager and genuinely
mutates the real Mailer instance's stored addresses - but MailFake's own send()/sendMail()
never consult that state when recording a message, so the override has no observable effect on
anything Mail::assertSent()/assertQueued() can see. Testing the actual effect means reading
from the real transport, exactly as this chapter's first entry already does.
Real scenario: capturing every mail sent from staging
App\Providers\AppServiceProvider::boot() applies all three, guarded by the environment:
if ($this->app->environment('staging')) {
Mail::alwaysFrom(config('services.staging_capture.address'));
Mail::alwaysReplyTo(config('services.staging_capture.address'));
Mail::alwaysReturnPath(config('services.staging_capture.address'));
}
This is decided once at boot, not inside OrderIssueReporter or any future mail sender: staging
is a fixed, known environment for the whole life of the process, so there is nothing to
re-evaluate on every message. The guard is deliberately conditional, though - it must never run
unconditionally, or a misconfigured deploy would silently divert every email, including real
customer-facing mail from other parts of the application, into the staging capture address
outside staging as well. Leaving this guard active (or its environment check broken) is exactly
the kind of mistake that would only surface once a customer reports an email that never arrived.
Testing a boot-time guard like this needs one detail that is easy to miss: boot() already runs
once, during test setup, before any test body executes - a later $this->app['env'] = 'staging'
mutation cannot reach back and retrigger a check that already ran. Re-invoking the guard after the
mutation is the same technique this book's own companion code already uses for an
identically-shaped guard in StockPruneMovementsCommand::prohibit() - but here it has to be the
guard alone, not the whole of boot(): boot() also registers this provider's Event::listen()
calls, and Illuminate\Events\Dispatcher::listen() appends to an array rather than overwriting,
so calling boot() a second time would silently double-register every one of them for the rest
of the test. AppServiceProvider::applyStagingMailCapture() isolates just the guard for exactly
this reason:
$this->app['env'] = 'staging';
(new AppServiceProvider($this->app))->applyStagingMailCapture();
(new OrderIssueReporter)->reportUrgent($order, 'Package damaged in transit.');
$email = Mail::getSymfonyTransport()->messages()->first()->getOriginalMessage();
$stagingAddress = config('services.staging_capture.address');
expect($email->getFrom()[0]->getAddress())->toBe($stagingAddress)
->and($email->getReplyTo()[0]->getAddress())->toBe($stagingAddress)
->and($email->getReturnPath()->getAddress())->toBe($stagingAddress);
Outside staging, the same call leaves every address at whatever the message would otherwise use.
With Mail::fake() active and staging simulated the same way, Mail::assertQueued() still
passes on the Mailable's own content and recipient - there is simply no assertion that could ever
expose the overridden from/reply-to/return-path in the first place.
This closes the chapter's Mail block. The Localization block that follows shares no code with it:
it turns from how an email leaves the application to how a piece of translated text is resolved,
revisiting Order from a different angle - the status label shown when a customer looks one up.
Lang::handleMissingKeysUsing()
Case type: an undocumented method on Illuminate\Translation\Translator (and the Lang
facade), alongside the documented fallback-locale mechanism it observes rather than replaces -
localization.md covers publishing and organizing translation files, and how a fallback locale
fills gaps in the requested one, but nothing about being notified when even the fallback comes up
empty.
Alias flag: not an alias - nothing else in the documented API reacts to a missing key at the point of lookup.
Audience: application developers, no shift toward package authors.
Stability: core framework, no minor-version churn found while verifying against v13.22.0.
Minimal snippet
Lang::handleMissingKeysUsing(function ($key, $replace, $locale, $fallback) {
// record $key/$locale somewhere, e.g. for later review
return null; // keep the original key as the resolved value
});
Lang::get('greetings.missing', [], 'fr', false); // 'greetings.missing' - untranslated, now recorded
Documented way vs. discovered way
localization.md documents the fallback locale as the answer to a missing translation: if
config('app.fallback_locale') has the key, that locale's text is used instead, silently. There
is no documented way to learn that this happened - a translator finds out only by noticing raw
keys in the interface, or not at all if the fallback happens to cover the gap. There is nothing
comparable to being told, at the moment of lookup, exactly which key and which locale just missed.
Real scenario: detecting drift between locale files
lang/en/orders.php and lang/es/orders.php hold the same status labels, except one: en has
picked up a refunded status the Spanish file has not caught up with yet.
return [
'status' => [
'pending' => 'Pending',
'shipped' => 'Shipped',
'delayed' => 'Delayed',
'refunded' => 'Refunded',
],
];
return [
'status' => [
'pending' => 'Pendiente',
'shipped' => 'Enviado',
'delayed' => 'Retrasado',
],
];
App\Support\Localization\MissingTranslationCollector just remembers every miss it is told about:
class MissingTranslationCollector
{
protected array $misses = [];
public function record(string $key, ?string $locale): void
{
$this->misses[] = [$key, $locale];
}
public function all(): array
{
return $this->misses;
}
}
It is bound as a plain singleton, deliberately, not scoped() the way this chapter's other
context holder, PreferredLocaleContext, is further down: the whole point is to accumulate misses
across the entire process, not just one request. That choice carries the same cost every other
process-lifetime singleton in this book does - under a long-running worker such as Octane,
$misses grows for as long as the worker lives, since nothing here ever drains or bounds it. A
real deployment would read all() and reset the collector periodically (a scheduled command, for
instance), rather than let it accumulate unchecked for the worker's entire lifetime.
AppServiceProvider::boot() wires every miss to it:
Lang::handleMissingKeysUsing(function (string $key, array $replace, ?string $locale, bool $fallback) {
$this->app->make(MissingTranslationCollector::class)->record($key, $locale);
return null;
});
OrderController::lookup() is where a miss can actually happen. It answers two different kinds
of caller, and the one that names a locale outright needs a deliberate detail to make a miss
possible at all:
$key = "orders.status.{$order->status}";
$requested = $request->string('locale')->toString();
return response()->json([
'uuid' => $order->uuid,
'status' => $order->status,
'status_label' => $requested !== ''
? Lang::get($key, [], $requested, false)
: Lang::get($key),
]);
That last argument, false, is not optional on the branch that carries it. Translator::get()
only adds
config('app.fallback_locale') to the locales it checks when this argument is true - its
default, and the only value __()/trans() ever use, since neither helper exposes it at all.
With the default, a lookup for refunded in es would silently succeed via the English fallback
('en' genuinely has the key) and Lang::handleMissingKeysUsing() would never fire - the drift
would stay invisible. Passing false restricts the check to es alone: a genuine miss there
reaches the callback, and status_label comes back as the literal key,
orders.status.refunded, exactly Laravel's ordinary behavior for a key that resolves nowhere at
all.
A caller that names no locale takes the other branch, keeps the ordinary fallback chain, and gets its language decided somewhere else entirely. That branch is what the next section is about.
Lang::determineLocalesUsing()
Case type: an undocumented method on Illuminate\Translation\Translator (and the Lang
facade), beside the documented App::setLocale() that localization.md presents as the way to
change language for a single HTTP request at runtime.
Alias flag: not an alias -
App::setLocale() replaces the single locale the translator treats as current, for everything
that happens afterward in the same process; this replaces the entire ordered list of candidate
locales, recomputed on every individual lookup, and never touches the process's own locale at
all.
Audience: application developers, no shift toward package authors.
Stability: core framework, no minor-version churn found while verifying against v13.22.0.
Minimal snippet
Lang::determineLocalesUsing(fn (array $locales) => ['pt_BR', ...$locales]);
__('orders.status.shipped'); // pt_BR is checked first, then the chain that was already there
Documented way vs. discovered way
localization.md documents App::setLocale(), and it is genuinely a per-request mechanism: call
it from middleware and the rest of that request resolves in the chosen language. What it cannot
do is vary below that granularity. It assigns Translator::$locale, so it holds until something
assigns it again, and everything the request touches afterward - a queued job serialized mid
request, a notification rendered at the end of the controller, a second record belonging to a
different customer - is resolved in the locale the last caller happened to set. Restoring the
previous value afterward is the caller's own responsibility, and forgetting to is a silent bug
rather than an error.
Lang::determineLocalesUsing() works one level down. Translator::get() decides which locales
to consult with $locales = $fallback ? $this->localeArray($locale) : [$locale];, and
localeArray() builds [$locale ?: $this->locale, $this->fallback], then passes that array
through the registered callback before walking it. The callback therefore sees the whole chain,
for one lookup, and can prepend to it, reorder it, or replace it outright. Nothing persists: the
next lookup rebuilds the array from scratch and calls the callback again. localeArray() also
applies array_unique()/array_values() to whatever comes back, so prepending a locale that was
already in the chain is harmless and needs no manual deduplication.
Real scenario: resolving a status label in the customer's own language
An order carries the language its customer asked to be contacted in, in a nullable
preferred_locale column. A tiny holder makes that value reachable from outside the controller:
class PreferredLocaleContext
{
protected ?string $locale = null;
public function set(?string $locale): void
{
$this->locale = $locale;
}
public function current(): ?string
{
return $this->locale;
}
}
How it is bound matters more than what it contains:
$this->app->scoped(PreferredLocaleContext::class);
scoped(), not singleton() and not bind(). The callback registered in
AppServiceProvider::boot() and the controller must reach the same instance, so a plain bind()
would hand them one each and current() would always be null. A singleton() would work but
would carry one request's preference into the next under a long-running server such as Octane,
the same hazard Chapter 15 raised for log context. scoped() is a singleton that
forgetScopedInstances() clears at every request and job boundary, which is exactly the lifetime
this needs.
The callback itself stays a plain array reshaping, since it runs on every lookup that consults the chain:
Lang::determineLocalesUsing(function (array $locales) {
$preferred = $this->app->make(PreferredLocaleContext::class)->current();
return $preferred === null ? $locales : [$preferred, ...$locales];
});
It prepends rather than truncates, on purpose. An order with preferred_locale of es whose
status is refunded - the label lang/es/orders.php has not caught up with - still resolves to
Refunded, because the English fallback is still sitting behind es in the chain. Only the
order in which locales are tried has changed.
OrderController::lookup() supplies the value and otherwise stays out of the way:
$context->set($order->preferred_locale);
One detail is worth stating plainly, because it decides where this entry can and cannot be used.
The callback lives inside localeArray(), and Translator::get() only calls localeArray()
when its $fallback argument is true. The strict lookup of the previous section passes
false, so Lang::determineLocalesUsing() never fires on it - the two entries in this block
cannot both act on the same call, by construction rather than by choice. That is why
lookup() branches: an explicitly requested locale is audited against that locale alone, and a
request that names none has its language decided by the order it is about.
Summary
| Entry | Documented alternative | When to prefer it |
|---|---|---|
Mail::raw() | A Mailable class plus Mail::to(...)->send(...) | The message is one throwaway line of text and a whole class, view, and test would exist only to carry it |
Mail::plain() | A Mailable whose content() declares a text view | A view already holds the copy, but nothing else about the message justifies a class of its own |
Mail::html() | A Mailable whose content() declares an html view | The HTML is already assembled in hand, by a renderer or an upstream service, and there is no view to point at |
Mail::sendNow() | Mail::to(...)->send(...) on a Mailable that does not implement ShouldQueue | The same Mailable must queue on its ordinary path and bypass the queue on an urgent one, without a second class |
Mail::alwaysFrom() / alwaysReplyTo() / alwaysReturnPath() | The 'from' key in config/mail.php | The sender must depend on something decided at runtime, or reply-to and return-path need an override the config file has no key for at all |
Lang::handleMissingKeysUsing() | The fallback locale filling the gap silently | Somebody has to learn that a key was missing, not just that the interface still rendered |
Lang::determineLocalesUsing() | App::setLocale() | The language must be decided per lookup, from data, without leaving the process switched over for everything that follows |
Each documented alternative is not wrong, only narrower. A dedicated Mailable is worth its
ceremony the moment the message will be reused, tested on its own, or grows past a single
one-off: raw(), plain(), and html() earn their place only while none of that is true.
send() already sends immediately whenever the Mailable does not implement ShouldQueue, which
leaves sendNow() with nothing to add unless the same class genuinely needs both paths. The
static 'from' key alone is fine wherever the sender never has to change based on a runtime
decision, only on which environment file was loaded. handleMissingKeysUsing() has nothing to
observe in an application that ships translations in one locale, where drift between files cannot
occur. And App::setLocale()'s whole-process switch is precise enough whenever a request really
does belong to a single language from beginning to end. Each entry in this chapter earns its
place only once one of those narrower conditions stops holding.
Part VII - Observing and Communicating ends here, complete across Chapters 15-16: from observing the application's own events and logs to communicating outward, whether by mail sent without the Mailable pipeline or by text resolved outside the standard locale chain. Part VIII - Application Infrastructure opens next with Chapter 17, "Configuration and cookies at runtime", moving from what the application says to the outside world to the configuration and cookie state it carries while saying it.