Ir a la documentación
PHP

Laravel y Symfony

Un cliente en el contenedor, un trabajo que no puede enviar dos veces, un controlador de webhooks y pruebas que nunca llegan a la red.

Configuración inicial

El paquete no tiene integración propia con ningún framework: ni service provider, ni bundle, ni transporte de correo. Registras un cliente en el contenedor y llamas a la API desde un trabajo, un controlador o un servicio, igual que desde cualquier programa PHP. El cliente se puede conservar sin riesgo durante toda la vida de un proceso, así que regístralo una vez como servicio compartido.

config/services.php
<?php return [    'openemail' => [        'key' => env('OPENEMAIL_API_KEY'),        'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'),    ],];
app/Providers/AppServiceProvider.php
<?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,        ));    }}

En Laravel, guarda la clave en .env y léela a través de config/services.php, para que sobreviva a php artisan config:cache. El singleton se construye la primera vez que algo lo pide, y por eso un comando como php artisan migrate funciona bien sin la clave. A partir de entonces, cualquier constructor o método handle que nombre OpenEmail recibe el mismo cliente.

config/services.yaml
services:    OpenEmail\OpenEmail:        arguments:            $apiKey: '%env(OPENEMAIL_API_KEY)%'            $timeout: 15

En Symfony, declara el cliente como un servicio con el nombre de su clase, y el autowiring lo entrega a cada constructor que pida OpenEmail. Los servicios son compartidos por defecto, así que todo el contenedor usa un solo cliente.

Los ejemplos usan Laravel 12 o posterior y Symfony 6.4 o posterior. El cliente en sí no depende de ningún framework, así que también funciona en versiones anteriores, con su propia forma de registrar un servicio.

Enviar desde un trabajo

Envía desde un trabajo en cola y no desde la solicitud, para que un envío lento o fallido nunca retrase una página. Deduce idempotencyKey: del registro al que se refiere el envío. Así, un trabajo que se vuelve a ejecutar, porque la cola lo reintentó o porque un worker murió después de que la API respondiera, envía con la misma clave, y la API reproduce el mensaje que ya envió en lugar de enviar uno segundo.

app/Jobs/SendInvoiceEmail.php
<?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']]);    }}

Una NetworkException se deja escapar, así que la cola vuelve a intentar el trabajo después de $backoff segundos, y la misma clave convierte la siguiente ejecución en una reproducción si la primera sí llegó a la API. Una ValidationException hace fallar el trabajo de inmediato, ya que un mensaje que la API rechazó tal como estaba escrito no puede funcionar si se vuelve a enviar. Cuando una NetworkException llega al trabajo, el cliente ya ha reintentado el envío por su cuenta, dos veces por defecto, con la misma clave.

Mantén la clave estable para el registro y el propósito, como invoice:42:email. Una clave construida a partir de una marca de tiempo o de uniqid() es nueva en cada ejecución, y un trabajo reintentado enviaría entonces dos veces. Reutilizar una clave con un cuerpo distinto, como una factura que cambió entre dos ejecuciones, se rechaza con un 422 idempotency_key_reuse en lugar de enviarse.

Un handler de Symfony Messenger funciona igual. Messenger reintenta por su cuenta un mensaje fallido, y lanzar UnrecoverableMessageHandlingException le indica que no lo haga.

src/MessageHandler/SendWelcomeEmailHandler.php
<?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 tiene su propia ValidationException. Cuando un archivo necesita las dos, importa una con otro nombre, como use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.

Recibir webhooks

Verifica cada entrega antes de actuar en consecuencia. La firma cubre los bytes en bruto del cuerpo, así que pasa $request->getContent() al verificador, no una copia analizada o recodificada, junto con $request->headers, de donde lee la cabecera de la firma.

app/Http/Controllers/OpenEmailWebhookController.php
<?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();    }}
routes/web.php
<?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]);

Una entrega no lleva token CSRF, así que una ruta en routes/web.php tiene que dejar fuera ese middleware, o Laravel rechaza el POST con un 419 antes de que se ejecute tu controlador. Una ruta en routes/api.php no tiene comprobación CSRF de entrada.

src/Controller/OpenEmailWebhookController.php
<?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);    }}

Pasa el evento a la cola y responde de inmediato: una entrega que no recibe respuesta en 5 segundos cuenta como fallida y se vuelve a enviar más tarde. El id del evento es el mismo en cada reintento y reproducción, así que guarda los ids que ya has procesado y omite los que ya hayas visto.

Responde 400 a una entrega falsificada o caducada, y a nada más. La falta del secreto es otro tipo de fallo: OpenEmail::verifyWebhookSignature() lanza InvalidArgumentException en ese caso, que los controladores de arriba no capturan, así que una app mal configurada responde 500 y la entrega se vuelve a intentar una vez que lo corriges, en lugar de rechazarse todos los eventos como falsificados.

Workers de larga duración

Con PHP-FPM el contenedor se construye en cada solicitud, y el cliente también. Con Laravel Octane, RoadRunner, FrankenPHP en modo worker o un worker de colas, el singleton vive tanto como el worker, así que un cliente y su conexión abierta atienden cada solicitud que gestiona el worker. No guarda ningún estado que pertenezca a una solicitud: un apiKey: por llamada sustituye la credencial solo para esa llamada.

Un worker que hace fork, como hace pcntl_fork, da al proceso hijo una conexión propia: el hijo deja de usar la que heredó y abre una nueva en su primera solicitud. Aun así, un hijo que termina cierra la conexión heredada también para el padre, así que llama a $client->close() antes del fork y el padre abrirá una conexión nueva que ningún hijo comparte.

Pruebas

Pon en el contenedor, mientras dure una prueba, un cliente con un cliente HTTP falso y nada sale de la máquina. Sirve cualquier cosa que implemente OpenEmail\Http\HttpClient, incluida una clase anónima, y ve exactamente lo que habría salido.

tests/Feature/SendInvoiceEmailTest.php
<?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));    }}

Cada solicitud registrada es un OpenEmail\Http\HttpRequest con method, url, headers, body y timeout, y su body es el JSON que se habría enviado, así que json_decode($request->body, true) muestra el propio mensaje. Devuelve un status de error con el sobre de error de la API para probar cómo gestiona tu código un rechazo. En Symfony, vuelve a definir el servicio para el entorno de pruebas en config/services_test.yaml, con un cliente HTTP falso como su argumento $httpClient.

tests/FakeOpenEmailHttpClient.php
<?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"}');    }}
config/services_test.yaml
services:    App\Tests\FakeOpenEmailHttpClient: ~     OpenEmail\OpenEmail:        arguments:            $apiKey: 'oe_test_fake'            $httpClient: '@App\Tests\FakeOpenEmailHttpClient'            $maxRetries: 0            $disableUpdateNotice: true