> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Registering and testing mail transports in packages

> Examine Laravel 13's MailManager to understand how to register a transport from a package, how configuration is passed, how mailers are retained and re-resolved, and which delivery paths fakes cannot verify.

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](https://kawax.biz/en/mail.md). For preparing a provider, see [Package development](https://kawax.biz/en/advanced/package-development.md).

## Separate the mailer name from the transport name

| Element | Example on this page | Role |
| - | - | - |
| Transport name | `acme-memory-mail` | The creation method registered with `extend()` |
| Mailer name | `receipts` / `alerts` | The configuration name in `mail.mailers`, and the identifier of the created Mailer |
| Symfony Transport | `MemoryTransport` | Delivers the message. In this example, it only records it in memory |
| Laravel Mailer | `Illuminate\Mail\Mailer` | Renders content, dispatches sending events, hands the message to the Transport, and more |

`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.

```mermaid theme={null}
flowchart TD
    A["Mail::mailer('receipts')"] --> B{"Is receipts already resolved?"}
    B -->|Yes| C["Return the retained Mailer"]
    B -->|No| D["Load mail.mailers.receipts"]
    D --> E["Creator for acme-memory-mail<br>receives only the config array"]
    E --> F["Return a Symfony Transport"]
    F --> G["Create a Laravel Mailer and<br>retain it under the name receipts"]
    G --> H["Call Transport::send() when sending"]
    C --> H
```

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`.

```php theme={null}
<?php

namespace Acme\Postbox\Transport;

use Illuminate\Mail\Transport\ArrayTransport;

class MemoryTransport extends ArrayTransport
{
    public function __construct(
        private readonly string $label,
    ) {
        parent::__construct();
    }

    public function __toString(): string
    {
        return 'acme-memory-mail:'.$this->label;
    }
}
```

`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.

> [!WARNING]
>
> 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](https://kawax.biz/en/advanced/package-service-resolution.md) so that applications that do not use mail do not create the manager unnecessarily.

Create `src/PostboxServiceProvider.php`.

```php theme={null}
<?php

namespace Acme\Postbox;

use Acme\Postbox\Transport\MemoryTransport;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Mailer\Transport\TransportInterface;

class PostboxServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->callAfterResolving('mail.manager', static function (
            MailManager $manager,
        ): void {
            $manager->extend('acme-memory-mail', static function (
                array $config,
            ): TransportInterface {
                return new MemoryTransport($config['label'] ?? 'mail');
            });
        });
    }
}
```

Load the provider through mechanisms such as [package discovery](https://kawax.biz/en/advanced/package-discovery.md).

* 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.

```php theme={null}
'receipts' => [
    'transport' => 'acme-memory-mail',
    'label' => 'receipts-v1',
],

'alerts' => [
    'transport' => 'acme-memory-mail',
    'label' => 'alerts',
],
```

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.

```php theme={null}
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;

Mail::mailer('receipts')->raw('Receipt body', static function (Message $message): void {
    $message->from('noreply@example.com')
        ->to('reader@example.com')
        ->subject('Receipt');
});

$transport = Mail::mailer('receipts')->getSymfonyTransport();

$recorded = $transport->messages()->sole();
```

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.

| What to check | What the package is responsible for |
| - | - |
| Message and recipients | Pass not only the body but also HTML, attachments, CC and BCC, and Envelope recipients to the delivery API correctly |
| Errors | Do not swallow API failures as success; propagate them to the caller |
| Configuration isolation | Apply each mailer's endpoint and credentials to its Transport |
| String representation | Do not include credentials or message bodies in `__toString()` |
| Testing | In addition to tests for registration, configuration, and re-resolution, verify the conversion to the API and error handling |

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.

```php theme={null}
use Illuminate\Support\Facades\Mail;

$before = Mail::mailer('receipts');

config(['mail.mailers.receipts.label' => 'receipts-v2']);

$same = Mail::mailer('receipts');

Mail::purge('receipts');

$after = Mail::mailer('receipts');
```

`$same` is the same Mailer as `$before`. For `$after`, retrieved after `purge('receipts')`, a Transport is created from the new configuration.

| Operation | Target |
| - | - |
| `Mail::purge('receipts')` | Removes the manager's reference to the specified mailer |
| `Mail::forgetMailers()` | Removes the manager's references to all mailers. Registered extensions remain |
| `$transport->flush()` | Clears the message records held by this example's MemoryTransport |

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](https://kawax.biz/en/advanced/package-service-lifetimes.md).

## 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](https://kawax.biz/en/advanced/package-testing.md), 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.

```php theme={null}
<?php

namespace Acme\Postbox\Tests;

use Acme\Postbox\PostboxServiceProvider;
use Acme\Postbox\Transport\MemoryTransport;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Orchestra\Testbench\TestCase;
use Symfony\Component\Mime\Email;

class MailTransportTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [EarlyMailManagerProvider::class, PostboxServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('mail.default', 'receipts');
        $app['config']->set('mail.from', [
            'address' => 'noreply@example.com',
            'name' => 'Postbox',
        ]);
        $app['config']->set('mail.mailers.receipts', [
            'transport' => 'acme-memory-mail',
            'label' => 'receipts-v1',
        ]);
        $app['config']->set('mail.mailers.alerts', [
            'transport' => 'acme-memory-mail',
            'label' => 'alerts',
        ]);
    }

    public function test_registers_on_an_already_resolved_manager(): void
    {
        $this->assertSame(
            $this->app->make('postbox.test.early-manager'),
            $this->app->make('mail.manager'),
        );

        $transport = Mail::mailer('receipts')->getSymfonyTransport();

        $this->assertInstanceOf(MemoryTransport::class, $transport);
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $transport);
    }

    public function test_message_reaches_the_transport(): void
    {
        $mailer = Mail::mailer('receipts');

        $sent = $mailer->raw('Receipt body', static function (Message $message): void {
            $message->to('reader@example.com')->subject('Receipt');
        });

        $transport = $mailer->getSymfonyTransport();

        $this->assertNotNull($sent);
        $this->assertCount(1, $transport->messages());

        $recorded = $transport->messages()->sole();
        $email = $recorded->getOriginalMessage();

        $this->assertInstanceOf(Email::class, $email);
        $this->assertSame('Receipt', $email->getSubject());
        $this->assertSame('Receipt body', $email->getTextBody());
        $this->assertSame('noreply@example.com', $email->getFrom()[0]->getAddress());
        $this->assertSame(
            'reader@example.com',
            $recorded->getEnvelope()->getRecipients()[0]->getAddress(),
        );
    }

    public function test_mailer_names_have_separate_transports(): void
    {
        $receipts = Mail::mailer('receipts');
        $alerts = Mail::mailer('alerts');

        $this->assertNotSame($receipts->getSymfonyTransport(), $alerts->getSymfonyTransport());
        $this->assertSame('acme-memory-mail:alerts', (string) $alerts->getSymfonyTransport());

        $receipts->raw('Receipt body', static function (Message $message): void {
            $message->to('reader@example.com')->subject('Receipt');
        });

        $this->assertCount(1, $receipts->getSymfonyTransport()->messages());
        $this->assertCount(0, $alerts->getSymfonyTransport()->messages());
    }

    public function test_config_change_requires_purging_the_mailer(): void
    {
        $before = Mail::mailer('receipts');

        config(['mail.mailers.receipts.label' => 'receipts-v2']);

        $this->assertSame($before, Mail::mailer('receipts'));
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $before->getSymfonyTransport());

        Mail::purge('receipts');

        $after = Mail::mailer('receipts');

        $this->assertNotSame($before, $after);
        $this->assertSame('acme-memory-mail:receipts-v2', (string) $after->getSymfonyTransport());
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $before->getSymfonyTransport());
    }
}

class EarlyMailManagerProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app->instance('postbox.test.early-manager', $this->app->make('mail.manager'));
    }
}
```

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](https://kawax.biz/en/queues.md) tests separate as well, and confirm that the provider is also loaded on the worker side.

## Primary sources consulted

* [Laravel official documentation: Custom mail transports and testing (at time of review)](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/mail.md#custom-transports)
* [Laravel official documentation: Package development](https://github.com/laravel/docs/blob/13.x/packages.md)
* [MailManager: creation, mailer retention, purge, and composite transports](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailManager.php)
* [MailServiceProvider: MailManager bindings](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailServiceProvider.php)
* [ServiceProvider: callAfterResolving](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Mailer: sending to the Transport and events](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Mailer.php)
* [ArrayTransport: recording messages](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Transport/ArrayTransport.php)
* [Mail facade and MailFake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Mail.php)
* [MailFake: mailer selection and mailable recording](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Testing/Fakes/MailFake.php)
* [Laravel official tests: verifying sending with Mailer and ArrayTransport](https://github.com/laravel/framework/blob/v13.39.0/tests/Mail/MailMailerTest.php)


## Related topics

- [Mail](https://kawax.biz/en/mail.md)
- [Advanced Topics](https://kawax.biz/en/advanced/index.md)
- [Registering and caching package event listeners](https://kawax.biz/en/advanced/package-events.md)
- [Registering package Artisan commands and resolving dependencies](https://kawax.biz/en/advanced/package-commands.md)
- [Registering and re-resolving package Storage drivers](https://kawax.biz/en/advanced/package-filesystem-drivers.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.