Laravel et Symfony
Un client dans le conteneur, un job qui ne peut pas envoyer deux fois, un contrôleur de webhooks, et des tests qui n'atteignent jamais le réseau.
Mise en place
Le package n'a pas d'intégration propre à un framework : ni service provider, ni bundle, ni transport de courrier. Vous enregistrez un client dans le conteneur et appelez l'API depuis un job, un contrôleur ou un service, comme depuis n'importe quel programme PHP. Le client peut être conservé sans risque pendant toute la vie d'un processus : enregistrez-le donc une fois comme service partagé.
<?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, )); }}Dans Laravel, gardez la clé dans .env et lisez-la via config/services.php, pour qu'elle survive à php artisan config:cache. Le singleton est construit la première fois que quelque chose le demande, c'est pourquoi une commande comme php artisan migrate fonctionne sans la clé. À partir de là, tout constructeur ou toute méthode handle qui nomme OpenEmail reçoit le même client.
services: OpenEmail\OpenEmail: arguments: $apiKey: '%env(OPENEMAIL_API_KEY)%' $timeout: 15Dans Symfony, déclarez le client comme service sous le nom de sa classe, et l'autowiring le fournit à chaque constructeur qui demande OpenEmail. Les services sont partagés par défaut : tout le conteneur utilise donc un seul client.
Les exemples utilisent Laravel 12 ou une version ultérieure et Symfony 6.4 ou une version ultérieure. Le client lui-même ne dépend d'aucun framework : il fonctionne donc aussi dans les versions plus anciennes, avec leur propre façon d'enregistrer un service.
Envoyer depuis un job
Envoyez depuis un job en file d'attente plutôt que depuis la requête, pour qu'un envoi lent ou en échec ne bloque jamais une page. Dérivez idempotencyKey: de l'enregistrement que concerne l'envoi. Un job qui s'exécute à nouveau, parce que la file l'a réessayé ou qu'un worker est mort après la réponse de l'API, envoie alors avec la même clé, et l'API rejoue le message déjà envoyé au lieu d'en envoyer un second.
<?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']]); }}Une NetworkException est laissée remonter, pour que la file retente le job après $backoff secondes, et la même clé fait de l'exécution suivante un rejeu si la première a bien atteint l'API. Une ValidationException fait échouer le job immédiatement, puisqu'un message que l'API a refusé tel qu'il est écrit ne peut pas réussir s'il est renvoyé. Au moment où une NetworkException atteint le job, le client a déjà retenté l'envoi lui-même, deux fois par défaut, sous la même clé.
Gardez la clé stable pour l'enregistrement et l'objectif, comme l'est invoice:42:email. Une clé construite à partir d'un horodatage ou de uniqid() est nouvelle à chaque exécution, et un job réessayé enverrait alors deux fois. Réutiliser une clé avec un corps différent, comme une facture qui a changé entre deux exécutions, est refusé avec un 422 idempotency_key_reuse au lieu d'être envoyé.
Un handler Symfony Messenger fonctionne de la même façon. Messenger réessaie de lui-même un message en échec, et lever UnrecoverableMessageHandlingException lui dit de ne pas le faire.
<?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 a sa propre ValidationException. Quand un fichier a besoin des deux, importez-en une sous un autre nom, comme use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.
Recevoir des webhooks
Vérifiez chaque livraison avant d'agir en conséquence. La signature couvre les octets bruts du corps : passez donc $request->getContent() au vérificateur, pas une copie analysée ou réencodée, avec $request->headers, dont il lit l'en-tête de signature.
<?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]);Une livraison ne porte aucun jeton CSRF : une route dans routes/web.php doit donc exclure ce middleware, sinon Laravel refuse le POST avec un 419 avant que votre contrôleur ne s'exécute. Une route dans routes/api.php n'a pas de vérification CSRF au départ.
<?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); }}Confiez l'événement à la file d'attente et répondez immédiatement : une livraison qui ne reçoit pas de réponse dans les 5 secondes compte comme un échec et est renvoyée plus tard. L'id de l'événement est le même à chaque réessai et à chaque rejeu : stockez donc les ids que vous avez traités et ignorez ceux que vous avez déjà vus.
Répondez 400 à une livraison falsifiée ou périmée, et à rien d'autre. Un secret manquant est un échec différent : OpenEmail::verifyWebhookSignature() lève InvalidArgumentException dans ce cas, que les contrôleurs ci-dessus n'interceptent pas. Une application mal configurée répond donc 500, et la livraison est retentée une fois le problème corrigé, au lieu que chaque événement soit rejeté comme falsifié.
Workers de longue durée
Sous PHP-FPM, le conteneur est construit à chaque requête, et le client aussi. Sous Laravel Octane, RoadRunner, FrankenPHP en mode worker ou dans un worker de file d'attente, le singleton vit aussi longtemps que le worker : un client et sa connexion ouverte servent donc chaque requête que traite le worker. Il ne garde aucun état propre à une requête : un apiKey: par appel remplace l'identifiant pour ce seul appel.
Un worker qui fait un fork, comme le fait pcntl_fork, donne au processus enfant sa propre connexion : l'enfant cesse d'utiliser celle dont il a hérité et en ouvre une nouvelle à sa première requête. Un enfant qui se termine ferme pourtant la connexion héritée pour le parent aussi : appelez donc $client->close() avant le fork, et le parent ouvrira une connexion neuve qu'aucun enfant ne partage.
Tests
Placez dans le conteneur, le temps d'un test, un client doté d'un faux client HTTP, et rien ne quitte la machine. Tout ce qui implémente OpenEmail\Http\HttpClient convient, y compris une classe anonyme, et il voit exactement ce qui serait parti.
<?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)); }}Chaque requête enregistrée est une OpenEmail\Http\HttpRequest avec method, url, headers, body et timeout, et son body est le JSON qui aurait été envoyé : json_decode($request->body, true) montre donc le message lui-même. Renvoyez un statut d'erreur avec l'enveloppe d'erreur de l'API pour tester comment votre code gère un refus. Dans Symfony, redéfinissez le service pour l'environnement de test dans config/services_test.yaml, avec un faux client HTTP comme 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