Skip to main content
To provide your own mail delivery method from a package, register a method that creates a Symfony Transport with Laravel. You do not need to rebuild mailables or Laravel’s Mailer. This page is based on the implementation of Laravel Framework 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. In src/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.
This example does not deliver mail externally. It is for learning registration and testing. The in-memory records are not shared across processes or persisted, and they do not confirm delivery to recipients. If you only need to record messages in memory, the built-in array transport is enough.

Register on the lazily loaded manager

The official documentation calls Mail::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.
Load the provider through mechanisms such as package discovery.
  • Laravel’s MailServiceProvider is a deferred provider that registers mail.manager as a singleton. The Mail facade 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 inside register().

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 existing mailers array in config/mail.php. Do not override the consumer’s default or the entire mailers array from the package.
To make it the default, set 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.
This 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, extend Symfony\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.
This test verifies the boundaries of registration, configuration, synchronous sending, and retained objects. For a real delivery Transport, separately verify payloads and failure responses with a mocked or fake API client. If you support queued sending, keep the queue tests separate as well, and confirm that the provider is also loaded on the worker side.

Primary sources consulted

Last modified on October 30, 2026