v13.39.0. For basic sending, see Mail. For preparing a provider, see Package development.
Separate the mailer name from the transport name
Mail::mailer('receipts') reads mail.mailers.receipts and calls the creator that matches its transport. MailManager::build() passes that Transport to a Laravel Mailer, and normal named resolution also applies settings such as the global sender.
Custom creators take precedence over built-in transports. Do not override smtp or ses; use a package-specific transport name.
Build the registration path without external delivery
Start by verifying configuration and resolution rather than the delivery API. Insrc/Transport/MemoryTransport.php of the Acme\Postbox package, create a class that extends the standard ArrayTransport.
ArrayTransport::send() stores Symfony’s SentMessage in an internal Collection. You can inspect it with messages() and clear that instance’s records with flush(). The label exists to confirm that the configuration reached the creator.
Register on the lazily loaded manager
The official documentation callsMail::extend() in boot(). In a package, you can combine this with a post-resolution hook so that applications that do not use mail do not create the manager unnecessarily.
Create src/PostboxServiceProvider.php.
- Laravel’s
MailServiceProvideris a deferred provider that registersmail.manageras a singleton. TheMailfacade also resolves this key. callAfterResolving()waits for a later resolution if the manager has not been resolved yet, and applies the extension immediately to an already resolved manager. It does not replace mailers that have already been created.- Avoid simply making your own provider that registers the extension a
DeferrableProvider. Make sure the custom transport is not requested before the hook is registered. - This example registers the hook in its own
boot(). It does not guarantee a boot order in which another provider uses the custom mailer earlier. Design the registration order, and do not send mail insideregister().
Do not confuse the extend arguments with other managers
MailManager::createSymfonyTransport() calls a custom creator with only a single configuration array. It does not pass two arguments, the application and the configuration, as Cache::extend() does. The return value is a Transport that implements TransportInterface, not a Laravel Mailer or a send result.
Also, MailManager::extend() only stores the closure and does not rebind it to the manager. The example uses static closures and explicit arguments so that it does not depend on how $this is interpreted.
If you need a client for an external API, apply the configuration in this creator and pass the client to the Transport. Do not send anything at registration time, and design it so that configuration and destinations are not mixed when multiple mailers use the same transport name.
Add mailers to the consuming application
Add the following two entries to the existingmailers array in config/mail.php. Do not override the consumer’s default or the entire mailers array from the package.
MAIL_MAILER to the mailer name receipts, not the transport name. If you select it explicitly, you can use it without changing the existing default.
raw() call is an example for verifying the synchronous sending path without preparing a view. Normal sending with mailables also hands the message to the same Transport after the Mailer renders the content.
Boundaries when building a real delivery transport
For an implementation that delivers to an external service, you can follow the official documentation, extendSymfony\Component\Mailer\Transport\AbstractTransport, and implement doSend(SentMessage $message): void and __toString(): string. The registration method above works as is.
Laravel’s
Mailer::sendSymfonyMessage() calls the Transport’s send(). If a MessageSending listener cancels sending, or the Transport returns null, Laravel’s send() also returns no send result. Do not treat the absence of an exception alone as proof of successful delivery. Even if a SentMessage is returned, it does not guarantee that the message reached the recipient’s mailbox.
Distinguish configuration changes from mailer re-resolution
MailManager retains each Mailer and its Transport by mailer name. Changing the configuration or re-registering with extend() does not update objects that have already been created.
$same is the same Mailer as $before. For $after, retrieved after purge('receipts'), a Transport is created from the new configuration.
The
purge() implementation examined here only removes the reference from the manager’s internal array. The old $before and any services that already had that Mailer injected are not replaced. It also does not explicitly close external connections.
Parent mailers such as failover are retained separately
failover and roundrobin read the configuration of their child mailer names, create the Transports directly, and embed them in the parent Transport. They do not share the child Mail::mailer() instances.
So even if you change a child’s configuration and call purge('receipts'), the internal Transport of an already resolved parent mailer is not updated. You must also re-resolve the parent mailer you want to update. Even forgetMailers() cannot replace references to parent mailers that consumers already hold.
In production, include refreshing the configuration cache and restarting long-running workers in your normal deployment process. Do not think of re-resolution as a bulk update across all processes; also see Service lifetimes.
Test the real creation path, not just Mail::fake
Mail::fake() is for recording mailables that are sent or queued. MailFake::mailer() does not resolve a real Mailer; it records the mailer name and returns the fake itself. raw() does not send anything either. Mailable sending assertions can pass even if your custom transport is not registered.
In your package test environment, run through the real manager and Transport without fakes, as shown below. Because this example’s Transport does not send externally, you can verify the registration path without risking real delivery. A helper provider is loaded first so the test also covers the case where the manager has already been resolved.
Primary sources consulted
- Laravel official documentation: Custom mail transports and testing (at time of review)
- Laravel official documentation: Package development
- MailManager: creation, mailer retention, purge, and composite transports
- MailServiceProvider: MailManager bindings
- ServiceProvider: callAfterResolving
- Mailer: sending to the Transport and events
- ArrayTransport: recording messages
- Mail facade and MailFake
- MailFake: mailer selection and mailable recording
- Laravel official tests: verifying sending with Mailer and ArrayTransport