Laravel und Symfony
Ein Client im Container, ein Job, der nicht zweimal senden kann, ein Webhook-Controller und Tests, die nie das Netzwerk erreichen.
Einrichtung
Das Paket bringt keine eigene Framework-Integration mit: keinen Service Provider, kein Bundle und keinen Mail-Transport. Sie registrieren einen Client im Container und rufen die API aus einem Job, einem Controller oder einem Service auf, genauso wie aus jedem PHP-Programm. Der Client kann gefahrlos für die Lebensdauer eines Prozesses behalten werden, registrieren Sie ihn also einmal als gemeinsamen Service.
<?php return [ 'openemail' => [ 'key' => env('OPENEMAIL_API_KEY'), 'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'), ],];<?php namespace App\Providers; use Illuminate\Support\ServiceProvider;use OpenEmail\OpenEmail; final class AppServiceProvider extends ServiceProvider{ public function register(): void { $this->app->singleton(OpenEmail::class, fn(): OpenEmail => new OpenEmail( apiKey: (string) config('services.openemail.key'), timeout: 15, )); }}Bewahren Sie in Laravel den Schlüssel in .env auf und lesen Sie ihn über config/services.php, damit er php artisan config:cache übersteht. Das Singleton wird gebaut, wenn zum ersten Mal etwas danach fragt, deshalb läuft ein Befehl wie php artisan migrate auch ohne den Schlüssel. Von da an erhält jeder Konstruktor und jede handle-Methode, die OpenEmail nennt, denselben Client.
services: OpenEmail\OpenEmail: arguments: $apiKey: '%env(OPENEMAIL_API_KEY)%' $timeout: 15Deklarieren Sie in Symfony den Client als Service unter seinem Klassennamen, und Autowiring übergibt ihn jedem Konstruktor, der nach OpenEmail fragt. Services sind standardmäßig gemeinsam genutzt, der ganze Container verwendet also einen Client.
Die Beispiele setzen Laravel 12 oder neuer und Symfony 6.4 oder neuer voraus. Der Client selbst hängt von keinem Framework ab und funktioniert daher auch in älteren Versionen, mit deren eigener Art, einen Service zu registrieren.
Aus einem Job senden
Senden Sie aus einem Job in der Queue statt aus der Anfrage, damit ein langsamer oder fehlschlagender Versand nie eine Seite aufhält. Leiten Sie idempotencyKey: aus dem Datensatz ab, um den es beim Versand geht. Ein Job, der erneut läuft, weil die Queue ihn wiederholt hat oder ein Worker nach der Antwort der API abgestürzt ist, sendet dann mit demselben Key, und die API spielt die bereits gesendete Nachricht erneut ab, statt eine zweite zu senden.
<?php namespace App\Jobs; use App\Models\Invoice;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Support\Facades\Storage;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail; final class SendInvoiceEmail implements ShouldQueue{ use Queueable; public int $tries = 5; public int $backoff = 30; public function __construct(public Invoice $invoice) {} public function handle(OpenEmail $openemail): void { try { $sent = $openemail->emails->send([ 'from' => 'Acme Billing <[email protected]>', 'to' => $this->invoice->customer_email, 'subject' => 'Invoice ' . $this->invoice->number, 'html' => '<p>Your invoice ' . $this->invoice->number . ' is attached.</p>', 'attachments' => [[ 'filename' => $this->invoice->number . '.pdf', 'content' => OpenEmail::toBase64((string) Storage::get($this->invoice->pdf_path)), 'contentType' => 'application/pdf', ]], ], idempotencyKey: 'invoice:' . $this->invoice->id . ':email'); } catch (ValidationException $error) { $this->fail($error); return; } $this->invoice->update(['email_id' => $sent['id'], 'email_status' => $sent['status']]); }}Eine NetworkException lässt man durchlaufen, damit die Queue den Job nach $backoff Sekunden erneut versucht, und derselbe Key macht den nächsten Lauf zu einem erneuten Abspielen, falls der erste die API doch erreicht hat. Eine ValidationException lässt den Job sofort fehlschlagen, denn eine Nachricht, die die API in dieser Form abgelehnt hat, kann bei erneutem Senden nicht gelingen. Wenn eine NetworkException den Job erreicht, hat der Client den Versand bereits selbst erneut versucht, standardmäßig zweimal, unter demselben Key.
Halten Sie den Key für den Datensatz und den Zweck stabil, so wie invoice:42:email. Ein Key aus einem Zeitstempel oder aus uniqid() ist bei jedem Lauf neu, und ein wiederholter Job würde dann zweimal senden. Ein Key, der mit einem anderen Body wiederverwendet wird, etwa bei einer Rechnung, die sich zwischen zwei Läufen geändert hat, wird mit einem 422 idempotency_key_reuse abgelehnt statt gesendet.
Ein Symfony-Messenger-Handler funktioniert genauso. Messenger wiederholt eine fehlgeschlagene Nachricht von sich aus, und das Werfen einer UnrecoverableMessageHandlingException weist ihn an, es nicht zu tun.
<?php namespace App\MessageHandler; use App\Message\SendWelcomeEmail;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail;use Symfony\Component\Messenger\Attribute\AsMessageHandler;use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; #[AsMessageHandler]final class SendWelcomeEmailHandler{ public function __construct(private readonly OpenEmail $openemail) {} public function __invoke(SendWelcomeEmail $message): void { try { $this->openemail->emails->send([ 'from' => 'Acme <[email protected]>', 'to' => $message->email, 'subject' => 'Welcome to Acme', 'text' => 'Glad you are here.', ], idempotencyKey: 'welcome:' . $message->userId); } catch (ValidationException $error) { throw new UnrecoverableMessageHandlingException($error->getMessage(), 0, $error); } }}Laravel hat eine eigene ValidationException. Braucht eine Datei beide, importieren Sie eine unter einem anderen Namen, etwa use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.
Webhooks empfangen
Verifizieren Sie jede Zustellung, bevor Sie darauf reagieren. Die Signatur deckt die rohen Bytes des Bodys ab. Übergeben Sie der Prüfung daher $request->getContent(), keine geparste oder neu kodierte Kopie, zusammen mit $request->headers, woraus sie den Signatur-Header liest.
<?php namespace App\Http\Controllers; use App\Jobs\HandleOpenEmailEvent;use Illuminate\Http\Request;use Illuminate\Http\Response;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; final class OpenEmailWebhookController{ public function __invoke(Request $request): Response { try { $event = OpenEmail::verifyWebhookSignature( $request->getContent(), $request->headers, (string) config('services.openemail.webhook_secret'), ); } catch (WebhookSignatureException) { return response('bad signature', 400); } HandleOpenEmailEvent::dispatch($event); return response()->noContent(); }}<?php use App\Http\Controllers\OpenEmailWebhookController;use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;use Illuminate\Support\Facades\Route; Route::post('/webhooks/openemail', OpenEmailWebhookController::class) ->withoutMiddleware([ValidateCsrfToken::class]);Eine Zustellung trägt kein CSRF-Token, eine Route in routes/web.php muss diese Middleware daher auslassen, sonst lehnt Laravel den POST mit einem 419 ab, bevor Ihr Controller läuft. Eine Route in routes/api.php hat gar keine CSRF-Prüfung.
<?php namespace App\Controller; use App\Message\OpenEmailEvent;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\AsController;use Symfony\Component\Messenger\MessageBusInterface;use Symfony\Component\Routing\Attribute\Route; #[AsController]final class OpenEmailWebhookController{ public function __construct( private readonly MessageBusInterface $bus, #[Autowire(env: 'OPENEMAIL_WEBHOOK_SECRET')] private readonly string $webhookSecret, ) {} #[Route('/webhooks/openemail', methods: ['POST'])] public function __invoke(Request $request): Response { try { $event = OpenEmail::verifyWebhookSignature($request->getContent(), $request->headers, $this->webhookSecret); } catch (WebhookSignatureException) { return new Response('bad signature', Response::HTTP_BAD_REQUEST); } $this->bus->dispatch(new OpenEmailEvent($event)); return new Response(null, Response::HTTP_NO_CONTENT); }}Übergeben Sie das Event an die Queue und antworten Sie sofort: Eine Zustellung, die innerhalb von 5 Sekunden keine Antwort bekommt, gilt als fehlgeschlagen und wird später erneut gesendet. Die id des Events ist bei jeder Wiederholung und jedem erneuten Abspielen dieselbe. Speichern Sie also die bearbeiteten ids und überspringen Sie eine, die Sie schon gesehen haben.
Antworten Sie nur auf eine gefälschte oder veraltete Zustellung mit 400. Ein fehlendes Secret ist ein anderer Fehlschlag: OpenEmail::verifyWebhookSignature() wirft dafür eine InvalidArgumentException, die die Controller oben nicht abfangen. Eine falsch konfigurierte App antwortet also mit 500, und die Zustellung wird erneut versucht, sobald Sie das behoben haben, statt dass jedes Event als gefälscht abgewiesen wird.
Langlebige Worker
Unter PHP-FPM wird der Container für jede Anfrage gebaut, und der Client ebenso. Unter Laravel Octane, RoadRunner, FrankenPHP im Worker-Modus oder in einem Queue-Worker lebt das Singleton so lange wie der Worker, ein Client und seine offene Verbindung bedienen also jede Anfrage, die der Worker bearbeitet. Er hält keinen Zustand, der zu einer einzelnen Anfrage gehört: Ein apiKey: pro Aufruf ersetzt die Zugangsdaten nur für diesen Aufruf.
Ein Worker, der forkt, wie es pcntl_fork tut, gibt dem Kindprozess eine eigene Verbindung: Der Kindprozess hört auf, die geerbte zu verwenden, und öffnet bei seiner ersten Anfrage eine neue. Ein Kindprozess, der endet, schließt die geerbte Verbindung trotzdem auch für den Elternprozess. Rufen Sie daher vor dem Fork $client->close() auf, dann öffnet der Elternprozess eine frische Verbindung, die er mit keinem Kindprozess teilt.
Testen
Legen Sie für die Dauer eines Tests einen Client mit einem Fake-HTTP-Client in den Container, dann verlässt nichts den Rechner. Alles, was OpenEmail\Http\HttpClient implementiert, funktioniert, auch eine anonyme Klasse, und es sieht genau, was hinausgegangen wäre.
<?php namespace Tests\Feature; use App\Jobs\SendInvoiceEmail;use App\Models\Invoice;use Illuminate\Foundation\Testing\RefreshDatabase;use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail;use Tests\TestCase; final class SendInvoiceEmailTest extends TestCase{ use RefreshDatabase; public function testARetriedJobSendsUnderOneKey(): void { $api = new class implements HttpClient { public array $requests = []; public function send(HttpRequest $request): HttpResponse { $this->requests[] = $request; return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}'); } }; $this->app->instance(OpenEmail::class, new OpenEmail( apiKey: 'oe_test_fake', httpClient: $api, maxRetries: 0, disableUpdateNotice: true, )); $invoice = Invoice::factory()->create(); SendInvoiceEmail::dispatchSync($invoice); SendInvoiceEmail::dispatchSync($invoice); $keys = array_map(fn(HttpRequest $request): ?string => $request->header('Idempotency-Key'), $api->requests); $this->assertSame(array_fill(0, 2, 'invoice:' . $invoice->id . ':email'), $keys); $this->assertSame('/emails', parse_url($api->requests[0]->url, PHP_URL_PATH)); }}Jede aufgezeichnete Anfrage ist ein OpenEmail\Http\HttpRequest mit method, url, headers, body und timeout, und ihr body ist das JSON, das gesendet worden wäre. json_decode($request->body, true) zeigt also die Nachricht selbst. Geben Sie einen Fehlerstatus mit dem Fehlerumschlag der API zurück, um zu testen, wie Ihr Code mit einer Ablehnung umgeht. Definieren Sie in Symfony den Service für die Testumgebung in config/services_test.yaml neu, mit einem Fake-HTTP-Client als Argument $httpClient.
<?php namespace App\Tests; use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse; final class FakeOpenEmailHttpClient implements HttpClient{ public array $requests = []; public function send(HttpRequest $request): HttpResponse { $this->requests[] = $request; return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}'); }}services: App\Tests\FakeOpenEmailHttpClient: ~ OpenEmail\OpenEmail: arguments: $apiKey: 'oe_test_fake' $httpClient: '@App\Tests\FakeOpenEmailHttpClient' $maxRetries: 0 $disableUpdateNotice: true