Перейти к документации
PHP

Laravel и Symfony

Один клиент в контейнере, задание, которое не может отправить дважды, контроллер вебхуков и тесты, которые никогда не обращаются к сети.

Настройка

У пакета нет собственной интеграции с фреймворками: ни сервис-провайдера, ни бандла, ни почтового транспорта. Вы регистрируете один клиент в контейнере и вызываете API из задания, контроллера или сервиса так же, как из любой программы на PHP. Клиент можно безопасно держать всё время жизни процесса, поэтому зарегистрируйте его один раз как общий сервис.

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

В Laravel храните ключ в .env и читайте его через config/services.php, чтобы он пережил php artisan config:cache. Синглтон создаётся, когда что-то впервые его запрашивает, поэтому команда вроде php artisan migrate спокойно работает без ключа. С этого момента любой конструктор или метод handle, в котором указан OpenEmail, получает один и тот же клиент.

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

В Symfony объявите клиент сервисом под именем его класса, и автосвязывание передаст его каждому конструктору, который запрашивает OpenEmail. Сервисы по умолчанию общие, поэтому весь контейнер использует один клиент.

В примерах используются Laravel 12 или новее и Symfony 6.4 или новее. Сам клиент не зависит ни от одного фреймворка, поэтому работает и в более старых версиях, с их собственным способом регистрации сервиса.

Отправка из задания

Отправляйте из задания в очереди, а не из запроса, чтобы медленная или неудачная отправка никогда не задерживала страницу. Выводите idempotencyKey: из записи, к которой относится отправка. Тогда задание, которое запустилось снова, потому что очередь его повторила или воркер упал после ответа API, отправит с тем же ключом, и API воспроизведёт уже отправленное сообщение, а не отправит второе.

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

NetworkException выпускается наружу, поэтому очередь повторяет задание через $backoff секунд, а тот же ключ превращает следующий запуск в воспроизведение, если первый всё же дошёл до API. ValidationException сразу проваливает задание, поскольку сообщение, которое API отклонил в его текущем виде, не сможет пройти при повторной отправке. К тому моменту, когда NetworkException доходит до задания, клиент уже сам повторил отправку, по умолчанию дважды, с тем же ключом.

Держите ключ стабильным для записи и цели, как invoice:42:email. Ключ, построенный из метки времени или из uniqid(), будет новым при каждом запуске, и тогда повторённое задание отправит дважды. Повторное использование ключа с другим телом, например со счётом, который изменился между двумя запусками, отклоняется с 422 idempotency_key_reuse, а не отправляется.

Обработчик Symfony Messenger работает так же. Messenger сам повторяет неудавшееся сообщение, а выброс UnrecoverableMessageHandlingException говорит ему этого не делать.

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 есть собственный ValidationException. Если в файле нужны оба, импортируйте один под другим именем, например use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.

Приём вебхуков

Проверяйте каждую доставку, прежде чем действовать по ней. Подпись покрывает исходные байты тела, поэтому передайте верификатору $request->getContent(), а не разобранную или перекодированную копию, вместе с $request->headers, из которых он читает заголовок подписи.

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

Доставка не несёт CSRF-токена, поэтому маршрут в routes/web.php должен исключить этот middleware, иначе Laravel отклонит POST с кодом 419 ещё до запуска вашего контроллера. У маршрута в routes/api.php проверки CSRF нет изначально.

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

Передайте событие в очередь и ответьте сразу: доставка, не получившая ответа в течение 5 секунд, считается неудачной и позже отправляется снова. id события одинаков при каждом его повторе и воспроизведении, поэтому храните идентификаторы обработанных событий и пропускайте уже виденные.

Отвечайте 400 на поддельную или устаревшую доставку и ни на что другое. Отсутствующий секрет является другим сбоем: для него OpenEmail::verifyWebhookSignature() выбрасывает InvalidArgumentException, который контроллеры выше не перехватывают, поэтому неправильно настроенное приложение отвечает 500, и доставка будет повторена, когда вы это исправите, а не каждое событие будет отвергнуто как поддельное.

Долгоживущие воркеры

Под PHP-FPM контейнер создаётся для каждого запроса, как и клиент. Под Laravel Octane, RoadRunner, FrankenPHP в режиме воркера или в обработчике очереди синглтон живёт столько же, сколько воркер, поэтому один клиент и его открытое соединение обслуживают все запросы, которые обрабатывает воркер. Он не хранит состояния, принадлежащего одному запросу: apiKey: для отдельного вызова заменяет учётные данные только для этого вызова.

Воркер, который делает форк, как это делает pcntl_fork, даёт дочернему процессу собственное соединение: дочерний процесс перестаёт использовать унаследованное и открывает новое при первом запросе. Однако, завершаясь, дочерний процесс закрывает унаследованное соединение и для родителя, поэтому вызывайте $client->close() перед форком, и родитель откроет новое соединение, которое ни с одним дочерним процессом не делит.

Тестирование

Поместите в контейнер на время теста клиент с поддельным HTTP-клиентом, и ничто не покинет машину. Подойдёт всё, что реализует OpenEmail\Http\HttpClient, включая анонимный класс, и он видит ровно то, что ушло бы наружу.

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

Каждый записанный запрос является OpenEmail\Http\HttpRequest с method, url, headers, body и timeout, а его body содержит JSON, который был бы отправлен, поэтому json_decode($request->body, true) показывает само сообщение. Верните статус ошибки с конвертом ошибки API, чтобы проверить, как ваш код обрабатывает отказ. В Symfony заново определите сервис для тестового окружения в config/services_test.yaml, передав поддельный HTTP-клиент в аргумент $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