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

# パッケージのメールトランスポート登録とテスト

> Laravel 13のMailManagerを読み解き、パッケージからのトランスポート登録、設定の受け渡し、Mailerの保持と再解決、fakeでは確認できない送信経路を解説します。

パッケージから独自のメール送信方法を提供するには、**SymfonyのTransportを生成する処理**をLaravelに登録します。MailableやLaravelのMailerを作り直す必要はありません。

このページはLaravel Framework `v13.39.0` の実装を確認しています。基本の送信方法は[メール送信](https://kawax.biz/jp/mail.md)、プロバイダーの準備は[パッケージ開発](https://kawax.biz/jp/advanced/package-development.md)を参照してください。

## Mailer名とTransport名を分ける

| 要素 | このページの例 | 役割 |
| - | - | - |
| Transport名 | `acme-memory-mail` | `extend()` に登録する生成方法 |
| Mailer名 | `receipts` / `alerts` | `mail.mailers` の設定名と、生成済みMailerの識別子 |
| Symfony Transport | `MemoryTransport` | メールの配送処理。この例ではメモリ内への記録のみ |
| Laravel Mailer | `Illuminate\Mail\Mailer` | 本文の生成、送信イベント、Transportへの受け渡しなど |

`Mail::mailer('receipts')` は `mail.mailers.receipts` を読み、`transport` に対応する生成処理を呼びます。`MailManager::build()` がそのTransportをLaravelのMailerに渡し、通常の名前付き解決ではグローバルな送信者なども設定します。

```mermaid theme={null}
flowchart TD
    A["Mail::mailer('receipts')"] --> B{"receiptsは解決済み？"}
    B -->|はい| C["保持済みのMailerを返す"]
    B -->|いいえ| D["mail.mailers.receiptsを読み込む"]
    D --> E["acme-memory-mailの生成処理<br>引数は設定配列のみ"]
    E --> F["Symfony Transportを返す"]
    F --> G["LaravelのMailerを生成し<br>receiptsの名前で保持"]
    G --> H["送信時にTransport::send()を呼ぶ"]
    C --> H
```

カスタム生成処理は組み込みTransportより先に選ばれます。`smtp` や `ses` を上書きせず、パッケージ固有のTransport名を使います。

## 外部送信なしで登録経路を作る

まず配送先のAPIではなく、設定と解決経路を確認します。`Acme\Postbox` パッケージの `src/Transport/MemoryTransport.php` に、標準の `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()` はSymfonyの `SentMessage` を内部のCollectionに保存します。`messages()` で確認でき、`flush()` でそのインスタンスの記録を消せます。ラベルは設定が生成処理へ届いたことを確認するためのものです。

> [!WARNING]
>
> この例はメールを外部へ配送しません。登録とテストの学習用です。インメモリの記録はプロセスをまたいで共有・永続化されず、受信者への到達確認にもなりません。メモリへの記録だけが目的なら、組み込みの `array` Transportで十分です。

### 遅延読み込みされるManagerに登録する

公式ドキュメントでは `boot()` で `Mail::extend()` を呼びます。パッケージでは、メールを使わないアプリケーションでManagerを余分に生成しないよう、[サービス解決後フック](https://kawax.biz/jp/advanced/package-service-resolution.md)を組み合わせられます。

`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');
            });
        });
    }
}
```

プロバイダーは[パッケージの自動検出](https://kawax.biz/jp/advanced/package-discovery.md)などで読み込みます。

* Laravelの `MailServiceProvider` は遅延プロバイダーで、`mail.manager` をsingletonとして登録します。`Mail` ファサードもこのキーを解決します。
* `callAfterResolving()` は未解決なら後の解決を待ち、解決済みのManagerにはその場で拡張を適用します。**生成済みのMailerまで差し替える処理ではありません。**
* 拡張を登録する自分のプロバイダーまで、単に `DeferrableProvider` にすることは避けます。フックの登録前に独自Transportが要求されないようにします。
* この例は自分の `boot()` でフックを登録します。他のプロバイダーがそれより前に独自Mailerを使う起動順は保証しません。登録順を設計し、`register()` 内でのメール送信は行わないでください。

### extendの引数を他のManagerと混同しない

`MailManager::createSymfonyTransport()` は、カスタム生成処理を**設定配列1つだけ**で呼びます。`Cache::extend()` のようなアプリケーションと設定の2引数ではありません。戻り値は `TransportInterface` を実装したTransportであり、LaravelのMailerや送信結果ではありません。

また、`MailManager::extend()` はクロージャを保持するだけで、Managerへの再バインドは行いません。例ではstaticクロージャと明示的な引数を使い、`$this` の解釈に依存しないようにしています。

外部API用のクライアントが必要なら、この生成処理で設定を適用してTransportへ渡します。登録時点で送信を行わず、同じTransport名の複数Mailerがある場合に設定や宛先を混在させない設計にします。

### 利用アプリケーションにMailerを追加する

`config/mail.php` の既存の `mailers` 配列へ、次の2つを追加します。パッケージ側から利用者のデフォルトや `mailers` 全体を上書きしません。

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

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

デフォルトにしたい場合に `MAIL_MAILER` へ指定するのは、Transport名ではなく **`receipts` というMailer名**です。個別指定なら、既存のデフォルトを変更せずに使えます。

```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();
```

この `raw()` はビューを用意せず同期の送信経路を確認するための例です。Mailableを使う通常の送信でも、Mailerが本文などを生成した後に同じTransportへ渡します。

## 実際の配送Transportを作るときの境界

外部サービスへ配送する実装では、公式ドキュメントに沿って `Symfony\Component\Mailer\Transport\AbstractTransport` を継承し、`doSend(SentMessage $message): void` と `__toString(): string` を実装できます。上の登録方法はそのまま使えます。

| 確認すること | パッケージが担当する範囲 |
| - | - |
| メッセージと宛先 | 本文だけでなくHTML、添付、CC・BCC、Envelopeの宛先を配送先APIへ正しく渡す |
| エラー | APIの失敗を成功として飲み込まず、呼び出し側へ伝える |
| 設定の分離 | Mailerごとの接続先・認証情報をTransportに適用する |
| 文字列表現 | `__toString()` に認証情報やメール本文を含めない |
| テスト | 登録・設定・再解決のテストに加え、APIへの変換とエラー処理を検証する |

Laravelの `Mailer::sendSymfonyMessage()` はTransportの `send()` を呼びます。`MessageSending` リスナーが送信を中止した場合やTransportが `null` を返した場合、Laravelの `send()` も送信結果を返しません。**例外がなかったことだけを配送成功の判定にしないでください。** `SentMessage` が返っても、受信者のメールボックスへ到達したという保証ではありません。

## 設定変更とMailerの再解決を区別する

`MailManager` は、MailerとそのTransportをMailer名ごとに保持します。設定変更や `extend()` の再登録だけでは、生成済みのオブジェクトは更新されません。

```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` は `$before` と同じMailerです。`purge('receipts')` の後に取得する `$after` では、新しい設定からTransportが生成されます。

| 操作 | 対象 |
| - | - |
| `Mail::purge('receipts')` | Managerが保持する指定Mailerの参照を取り除く |
| `Mail::forgetMailers()` | Managerが保持する全Mailerの参照を取り除く。拡張の登録は残る |
| `$transport->flush()` | この例のMemoryTransportが保持するメッセージ記録を消す |

確認した `purge()` の実装は、Manager内部の配列から参照を取り除くだけです。古い `$before` や、そのMailerを注入済みのサービスは差し替わりません。外部接続を明示的に終了する処理でもありません。

### failoverなどの親Mailerも別に保持される

`failover` と `roundrobin` は、子のMailer名の設定を読み、**Transportを直接生成して**親のTransportへ組み込みます。子の `Mail::mailer()` インスタンスを共有する仕組みではありません。

そのため、子の設定を変更して `purge('receipts')` を呼んでも、解決済みの親Mailerの内部Transportは更新されません。更新対象の親Mailerも再解決する必要があります。`forgetMailers()` でも、すでに利用側が保持する親Mailerの参照まで置き換えることはできません。

本番では設定キャッシュの更新と長寿命ワーカーの再起動を通常のデプロイ手順へ組み込みます。再解決を全プロセスの一括更新と考えず、[サービスのライフサイクル](https://kawax.biz/jp/advanced/package-service-lifetimes.md)も確認してください。

## Mail::fakeだけでなく実際の生成経路をテストする

`Mail::fake()` はMailableの送信・キュー登録を記録するためのものです。`MailFake::mailer()` は実際のMailerを解決せず、Mailer名を記録してfake自身を返します。`raw()` も送信処理を行いません。独自Transportの登録がなくても、Mailableの送信アサートが成功する場合があります。

[パッケージのテスト環境](https://kawax.biz/jp/advanced/package-testing.md)で、次のように**fakeを使わず**実際のManagerとTransportを通します。この例のTransportは外部送信しないため、実配送の危険なく登録経路を確認できます。補助プロバイダーを先に読み込み、Managerが解決済みの場合も検証します。

```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'));
    }
}
```

このテストは登録・設定・同期送信・保持済みオブジェクトの境界を確認するものです。実際の配送用Transportでは、別途APIクライアントのモックやfakeでペイロードと失敗応答を検証します。キュー送信を提供する場合は、[キュー](https://kawax.biz/jp/queues.md)のテストも分け、ワーカー側でもプロバイダーが読み込まれることを確認してください。

## 参照した一次情報

* [Laravel公式ドキュメント: メールのカスタムTransportとテスト（確認時点）](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/mail.md#custom-transports)
* [Laravel公式ドキュメント: パッケージ開発](https://github.com/laravel/docs/blob/13.x/packages.md)
* [MailManager: 生成処理、Mailerの保持、purgeと複合Transport](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailManager.php)
* [MailServiceProvider: MailManagerのバインディング](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: Transportへの送信とイベント](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Mailer.php)
* [ArrayTransport: メッセージの記録](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Transport/ArrayTransport.php)
* [MailファサードとMailFake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Mail.php)
* [MailFake: Mailer選択とMailableの記録](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Testing/Fakes/MailFake.php)
* [Laravel公式テスト: MailerとArrayTransportによる送信の検証](https://github.com/laravel/framework/blob/v13.39.0/tests/Mail/MailMailerTest.php)


## Related topics

- [メール送信](https://kawax.biz/jp/mail.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [パッケージのルート登録とキャッシュ](https://kawax.biz/jp/advanced/package-routes.md)
- [パッケージのArtisanコマンド登録と依存解決](https://kawax.biz/jp/advanced/package-commands.md)
- [パッケージのミドルウェア登録と適用範囲](https://kawax.biz/jp/advanced/package-middleware.md)


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