Saltar para a documentação
PHP

Laravel e Symfony

Um cliente no contentor de serviços, uma tarefa que não pode enviar duas vezes, um controlador de webhooks e testes que nunca chegam à rede.

Configuração inicial

O pacote não tem integração própria com frameworks: nenhum service provider, nenhum bundle e nenhum transporte de correio. Regista um cliente no contentor e chama a API a partir de uma tarefa, de um controlador ou de um serviço, da mesma forma que a partir de qualquer programa PHP. É seguro manter o cliente durante toda a vida de um processo, por isso registe-o uma vez como serviço partilhado.

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,        ));    }}

No Laravel, guarde a chave em .env e leia-a através de config/services.php, para que sobreviva a php artisan config:cache. O singleton é construído na primeira vez que algo o pede, e é por isso que um comando como php artisan migrate corre sem problemas sem a chave. A partir daí, qualquer construtor ou método handle que indique OpenEmail recebe o mesmo cliente.

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

No Symfony, declare o cliente como serviço com o nome da sua classe, e o autowiring entrega-o a todos os construtores que peçam OpenEmail. Os serviços são partilhados por omissão, por isso todo o contentor usa um único cliente.

Os exemplos usam o Laravel 12 ou posterior e o Symfony 6.4 ou posterior. O próprio cliente não depende de nenhum framework, por isso também funciona em versões mais antigas, com a forma própria de cada uma de registar um serviço.

Enviar a partir de uma tarefa

Envie a partir de uma tarefa em fila e não a partir do pedido, para que um envio lento ou falhado nunca atrase uma página. Derive idempotencyKey: do registo a que o envio diz respeito. Assim, uma tarefa que corre de novo, porque a fila a repetiu ou porque um worker morreu depois de a API responder, envia com a mesma chave, e a API reproduz a mensagem que já enviou em vez de enviar uma segunda.

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

Um NetworkException é deixado escapar, por isso a fila volta a tentar a tarefa ao fim de $backoff segundos, e a mesma chave transforma a execução seguinte numa reprodução se a primeira chegou de facto à API. Um ValidationException faz falhar a tarefa de imediato, já que uma mensagem que a API recusou tal como estava escrita não pode ter sucesso se for enviada de novo. Quando um NetworkException chega à tarefa, o cliente já voltou a tentar o envio por si próprio, duas vezes por omissão, com a mesma chave.

Mantenha a chave estável para o registo e para o propósito, como invoice:42:email. Uma chave construída a partir de uma marca temporal ou de uniqid() é nova em cada execução, e uma tarefa repetida enviaria então duas vezes. Reutilizar uma chave com um corpo diferente, como uma fatura que mudou entre duas execuções, é recusado com um 422 idempotency_key_reuse em vez de enviado.

Um handler do Symfony Messenger funciona da mesma forma. O Messenger repete por si uma mensagem falhada, e lançar UnrecoverableMessageHandlingException diz-lhe que não o faça.

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

O Laravel tem um ValidationException próprio. Quando um ficheiro precisa dos dois, importe um com outro nome, como use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.

Receber webhooks

Verifique cada entrega antes de agir com base nela. A assinatura cobre os bytes em bruto do corpo, por isso passe $request->getContent() ao verificador, e não uma cópia analisada ou recodificada, juntamente com $request->headers, de onde ele lê o cabeçalho da assinatura.

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]);

Uma entrega não traz nenhum token CSRF, por isso uma rota em routes/web.php tem de deixar esse middleware de fora, ou o Laravel recusa o POST com um 419 antes de o seu controlador correr. Uma rota em routes/api.php não tem verificação CSRF à partida.

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

Passe o evento à fila e responda de imediato: uma entrega que não recebe resposta em 5 segundos conta como falhada e é enviada de novo mais tarde. O id do evento é o mesmo em cada repetição e reenvio, por isso guarde os ids que já tratou e ignore os que já viu.

Responda 400 a uma entrega falsificada ou expirada e a mais nada. A falta do segredo é uma falha diferente: OpenEmail::verifyWebhookSignature() lança InvalidArgumentException nesse caso, que os controladores acima não apanham, por isso uma aplicação mal configurada responde 500 e a entrega é tentada de novo depois de a corrigir, em vez de todos os eventos serem rejeitados como falsificados.

Workers de longa duração

Com PHP-FPM, o contentor é construído em cada pedido, e o cliente também. Com Laravel Octane, RoadRunner, FrankenPHP em modo worker ou um worker de filas, o singleton vive tanto quanto o worker, por isso um cliente e a sua ligação aberta servem todos os pedidos que o worker trata. Não guarda nenhum estado que pertença a um único pedido: um apiKey: por chamada substitui a credencial apenas para essa chamada.

Um worker que faz fork, como pcntl_fork faz, dá ao processo filho uma ligação própria: o filho deixa de usar a que herdou e abre uma nova no primeiro pedido. Ainda assim, um filho que termina fecha a ligação herdada também para o pai, por isso chame $client->close() antes do fork, e o pai abre uma ligação nova que nenhum filho partilha.

Testes

Ponha no contentor, durante um teste, um cliente com um cliente HTTP falso e nada sai da máquina. Qualquer coisa que implemente OpenEmail\Http\HttpClient serve, incluindo uma classe anónima, e vê exatamente o que teria saído.

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 pedido registado é um OpenEmail\Http\HttpRequest com method, url, headers, body e timeout, e o seu body é o JSON que teria sido enviado, por isso json_decode($request->body, true) mostra a própria mensagem. Devolva um estado de erro com o envelope de erro da API para testar como o seu código trata uma recusa. No Symfony, defina de novo o serviço para o ambiente de teste em config/services_test.yaml, com um cliente HTTP falso como 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