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

# Mail-Transports in Paketen registrieren und testen

> Analysiert den MailManager von Laravel 13 und erläutert die Registrierung von Transports aus Paketen, die Übergabe der Konfiguration, das Vorhalten und erneute Auflösen von Mailern sowie Sendewege, die sich mit Fakes nicht prüfen lassen.

Wenn ein Paket eine eigene Versandmethode für E-Mails bereitstellen soll, registrieren Sie in Laravel **eine Erzeugungsmethode für einen Symfony-Transport**. Mailables oder den Laravel-Mailer müssen Sie dafür nicht neu bauen.

Diese Seite basiert auf der Implementierung von Laravel Framework `v13.39.0`. Die Grundlagen des Versands finden Sie unter [E-Mails versenden](https://kawax.biz/de/mail.md), die Vorbereitung des Providers unter [Paketentwicklung](https://kawax.biz/de/advanced/package-development.md).

## Mailer-Name und Transport-Name trennen

| Element | Beispiel auf dieser Seite | Rolle |
| - | - | - |
| Transport-Name | `acme-memory-mail` | Die mit `extend()` registrierte Erzeugungsmethode |
| Mailer-Name | `receipts` / `alerts` | Der Konfigurationsname in `mail.mailers` und der Bezeichner des erzeugten Mailers |
| Symfony-Transport | `MemoryTransport` | Die Zustellung der Mail. In diesem Beispiel nur die Aufzeichnung im Speicher |
| Laravel-Mailer | `Illuminate\Mail\Mailer` | Erzeugen des Inhalts, Sende-Events, Übergabe an den Transport usw. |

`Mail::mailer('receipts')` liest `mail.mailers.receipts` und ruft die Erzeugungsmethode auf, die zu `transport` gehört. `MailManager::build()` übergibt diesen Transport an den Laravel-Mailer und setzt bei der normalen Auflösung per Name auch globale Werte wie den Absender.

```mermaid theme={null}
flowchart TD
    A["Mail::mailer('receipts')"] --> B{"receipts bereits aufgelöst?"}
    B -->|Ja| C["Vorgehaltenen Mailer zurückgeben"]
    B -->|Nein| D["mail.mailers.receipts laden"]
    D --> E["Erzeugungsmethode für acme-memory-mail<br>Einziges Argument: Konfigurations-Array"]
    E --> F["Symfony-Transport zurückgeben"]
    F --> G["Laravel-Mailer erzeugen und<br>unter dem Namen receipts vorhalten"]
    G --> H["Beim Senden Transport::send() aufrufen"]
    C --> H
```

Eigene Erzeugungsmethoden haben Vorrang vor den eingebauten Transports. Überschreiben Sie daher nicht `smtp` oder `ses`, sondern verwenden Sie einen paketspezifischen Transport-Namen.

## Den Registrierungsweg ohne externen Versand aufbauen

Prüfen Sie zuerst nicht die API des Zustelldienstes, sondern Konfiguration und Auflösungsweg. Legen Sie in `src/Transport/MemoryTransport.php` des Pakets `Acme\Postbox` eine Klasse an, die vom Standard-`ArrayTransport` erbt.

```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()` speichert die Symfony-`SentMessage` in einer internen Collection. Mit `messages()` können Sie sie einsehen, mit `flush()` die Aufzeichnungen dieser Instanz löschen. Das Label dient dazu, zu prüfen, ob die Konfiguration bei der Erzeugungsmethode angekommen ist.

> [!WARNING]
>
> Dieses Beispiel stellt keine Mails nach außen zu. Es dient nur dem Lernen von Registrierung und Tests. Die Aufzeichnungen im Speicher werden weder prozessübergreifend geteilt noch dauerhaft gespeichert und belegen auch nicht, dass die Mail beim Empfänger angekommen ist. Wenn Sie nur im Speicher aufzeichnen möchten, genügt der eingebaute `array`-Transport.

### Beim verzögert geladenen Manager registrieren

Die offizielle Dokumentation ruft `Mail::extend()` in `boot()` auf. In Paketen können Sie zusätzlich [Hooks nach der Service-Auflösung](https://kawax.biz/de/advanced/package-service-resolution.md) verwenden, damit Anwendungen, die keine Mails nutzen, den Manager nicht unnötig erzeugen.

Legen Sie `src/PostboxServiceProvider.php` an.

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

Den Provider laden Sie zum Beispiel über die [automatische Paketerkennung](https://kawax.biz/de/advanced/package-discovery.md).

* Der `MailServiceProvider` von Laravel ist ein verzögerter Provider und registriert `mail.manager` als Singleton. Auch die `Mail`-Facade löst diesen Schlüssel auf.
* `callAfterResolving()` wartet auf die spätere Auflösung, wenn der Manager noch nicht aufgelöst ist, und wendet die Erweiterung auf einen bereits aufgelösten Manager sofort an. **Bereits erzeugte Mailer werden dadurch nicht ersetzt.**
* Machen Sie den eigenen Provider, der die Erweiterung registriert, nicht einfach zu einem `DeferrableProvider`. Es darf kein eigener Transport angefordert werden, bevor der Hook registriert ist.
* Dieses Beispiel registriert den Hook im eigenen `boot()`. Eine Startreihenfolge, in der andere Provider den eigenen Mailer schon vorher nutzen, wird nicht abgedeckt. Planen Sie die Registrierungsreihenfolge und versenden Sie keine Mails in `register()`.

### Die Argumente von extend nicht mit anderen Managern verwechseln

`MailManager::createSymfonyTransport()` ruft eine eigene Erzeugungsmethode mit **nur einem Konfigurations-Array** auf. Anders als bei `Cache::extend()` gibt es nicht zwei Argumente für Anwendung und Konfiguration. Der Rückgabewert ist ein Transport, der `TransportInterface` implementiert, und weder ein Laravel-Mailer noch ein Sendeergebnis.

Außerdem speichert `MailManager::extend()` die Closure nur und bindet sie nicht erneut an den Manager. Das Beispiel verwendet eine statische Closure mit expliziten Argumenten, damit es nicht von der Bedeutung von `$this` abhängt.

Wenn Sie einen Client für eine externe API benötigen, wenden Sie in dieser Erzeugungsmethode die Konfiguration an und übergeben ihn an den Transport. Senden Sie bei der Registrierung nichts, und gestalten Sie den Code so, dass sich Konfiguration und Ziele nicht vermischen, wenn mehrere Mailer denselben Transport-Namen verwenden.

### Mailer in der nutzenden Anwendung hinzufügen

Fügen Sie dem bestehenden `mailers`-Array in `config/mail.php` die folgenden zwei Einträge hinzu. Das Paket sollte weder den Standard des Nutzers noch das gesamte `mailers`-Array überschreiben.

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

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

Soll er zum Standard werden, tragen Sie in `MAIL_MAILER` nicht den Transport-Namen ein, sondern **den Mailer-Namen `receipts`**. Bei expliziter Angabe können Sie ihn nutzen, ohne den bestehenden Standard zu ändern.

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

Dieses `raw()` ist ein Beispiel, um den synchronen Sendeweg ohne View zu prüfen. Auch beim normalen Versand mit Mailables übergibt der Mailer die Nachricht nach dem Erzeugen des Inhalts an denselben Transport.

## Grenzen beim Bau eines echten Zustell-Transports

Für eine Implementierung, die an einen externen Dienst zustellt, können Sie gemäß der offiziellen Dokumentation von `Symfony\Component\Mailer\Transport\AbstractTransport` erben und `doSend(SentMessage $message): void` sowie `__toString(): string` implementieren. Die obige Registrierungsmethode lässt sich unverändert verwenden.

| Zu prüfen | Verantwortungsbereich des Pakets |
| - | - |
| Nachricht und Empfänger | Nicht nur den Text, sondern auch HTML, Anhänge, CC/BCC und die Empfänger des Envelope korrekt an die API des Zustelldienstes übergeben |
| Fehler | API-Fehler nicht als Erfolg verschlucken, sondern an den Aufrufer weitergeben |
| Trennung der Konfiguration | Verbindungsziel und Zugangsdaten pro Mailer auf den Transport anwenden |
| Zeichenkettendarstellung | In `__toString()` keine Zugangsdaten und keinen Mailinhalt aufnehmen |
| Tests | Neben Tests für Registrierung, Konfiguration und erneute Auflösung auch die Umwandlung für die API und die Fehlerbehandlung prüfen |

`Mailer::sendSymfonyMessage()` von Laravel ruft `send()` des Transports auf. Bricht ein `MessageSending`-Listener den Versand ab oder gibt der Transport `null` zurück, liefert auch `send()` von Laravel kein Sendeergebnis. **Werten Sie das bloße Ausbleiben einer Exception nicht als erfolgreiche Zustellung.** Auch eine zurückgegebene `SentMessage` garantiert nicht, dass die Mail im Postfach des Empfängers angekommen ist.

## Konfigurationsänderung und erneute Auflösung des Mailers unterscheiden

`MailManager` hält Mailer und deren Transport pro Mailer-Name vor. Eine Konfigurationsänderung oder eine erneute Registrierung per `extend()` allein aktualisiert bereits erzeugte Objekte nicht.

```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` ist derselbe Mailer wie `$before`. Für `$after`, das nach `purge('receipts')` abgerufen wird, wird der Transport aus der neuen Konfiguration erzeugt.

| Operation | Wirkung |
| - | - |
| `Mail::purge('receipts')` | Entfernt die vom Manager vorgehaltene Referenz auf den angegebenen Mailer |
| `Mail::forgetMailers()` | Entfernt die Referenzen auf alle vom Manager vorgehaltenen Mailer. Registrierte Erweiterungen bleiben erhalten |
| `$transport->flush()` | Löscht die vom MemoryTransport dieses Beispiels vorgehaltenen Nachrichtenaufzeichnungen |

Die geprüfte Implementierung von `purge()` entfernt lediglich die Referenz aus dem internen Array des Managers. Das alte `$before` und Services, denen dieser Mailer bereits injiziert wurde, werden nicht ersetzt. Externe Verbindungen werden dabei auch nicht explizit beendet.

### Übergeordnete Mailer wie failover werden separat vorgehalten

`failover` und `roundrobin` lesen die Konfiguration der untergeordneten Mailer-Namen, **erzeugen die Transports direkt** und binden sie in den übergeordneten Transport ein. Die Instanzen von `Mail::mailer()` der untergeordneten Mailer werden dabei nicht geteilt.

Wenn Sie daher die Konfiguration eines untergeordneten Mailers ändern und `purge('receipts')` aufrufen, wird der interne Transport eines bereits aufgelösten übergeordneten Mailers nicht aktualisiert. Auch der betroffene übergeordnete Mailer muss erneut aufgelöst werden. Selbst `forgetMailers()` kann Referenzen auf übergeordnete Mailer, die der Aufrufer bereits hält, nicht ersetzen.

Nehmen Sie in Produktion die Aktualisierung des Konfigurations-Caches und den Neustart langlebiger Worker in den regulären Deployment-Ablauf auf. Verstehen Sie die erneute Auflösung nicht als gleichzeitige Aktualisierung aller Prozesse, und lesen Sie auch [Lebensdauer von Services](https://kawax.biz/de/advanced/package-service-lifetimes.md).

## Nicht nur Mail::fake, sondern den echten Erzeugungsweg testen

`Mail::fake()` dient dazu, den Versand und das Einreihen von Mailables in die Queue aufzuzeichnen. `MailFake::mailer()` löst keinen echten Mailer auf, sondern zeichnet den Mailer-Namen auf und gibt das Fake selbst zurück. Auch `raw()` führt keinen Versand aus. Assertions zum Versand von Mailables können daher erfolgreich sein, obwohl der eigene Transport gar nicht registriert ist.

Leiten Sie in der [Testumgebung des Pakets](https://kawax.biz/de/advanced/package-testing.md) wie folgt **ohne Fake** über den echten Manager und Transport. Da der Transport in diesem Beispiel nichts nach außen sendet, können Sie den Registrierungsweg ohne Risiko einer echten Zustellung prüfen. Ein Hilfs-Provider wird zuerst geladen, damit auch der Fall eines bereits aufgelösten Managers geprüft wird.

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

Dieser Test prüft die Grenzen von Registrierung, Konfiguration, synchronem Versand und vorgehaltenen Objekten. Bei einem echten Zustell-Transport prüfen Sie Payload und Fehlerantworten zusätzlich mit Mocks oder Fakes des API-Clients. Wenn Sie den Versand über Queues unterstützen, trennen Sie auch die Tests für [Queues](https://kawax.biz/de/queues.md) und stellen Sie sicher, dass der Provider auch auf Worker-Seite geladen wird.

## Herangezogene Primärquellen

* [Offizielle Laravel-Dokumentation: Eigene Mail-Transports und Tests (Stand der Prüfung)](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/mail.md#custom-transports)
* [Offizielle Laravel-Dokumentation: Paketentwicklung](https://github.com/laravel/docs/blob/13.x/packages.md)
* [MailManager: Erzeugung, Vorhalten von Mailern, purge und zusammengesetzte Transports](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailManager.php)
* [MailServiceProvider: Binding des 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: Versand an den Transport und Events](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Mailer.php)
* [ArrayTransport: Aufzeichnung von Nachrichten](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Transport/ArrayTransport.php)
* [Mail-Facade und MailFake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Mail.php)
* [MailFake: Auswahl des Mailers und Aufzeichnung von Mailables](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Testing/Fakes/MailFake.php)
* [Offizielle Laravel-Tests: Prüfung des Versands mit Mailer und ArrayTransport](https://github.com/laravel/framework/blob/v13.39.0/tests/Mail/MailMailerTest.php)


## Related topics

- [E-Mail-Versand](https://kawax.biz/de/mail.md)
- [Weiterführende Themen](https://kawax.biz/de/advanced/index.md)
- [Storage-Treiber in Paketen registrieren und neu auflösen](https://kawax.biz/de/advanced/package-filesystem-drivers.md)
- [Event-Listener in Paketen registrieren und cachen](https://kawax.biz/de/advanced/package-events.md)
- [Paket-Routen registrieren und cachen](https://kawax.biz/de/advanced/package-routes.md)


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