Belgelere geç
PHP

Laravel ve Symfony

Konteynerde tek bir istemci, iki kez gönderemeyen bir iş, bir webhook denetleyicisi ve ağa hiç ulaşmayan testler.

Kurulum

Paketin kendine ait bir framework entegrasyonu yoktur: service provider, bundle ya da posta aktarımı (mail transport) yoktur. Konteynere tek bir istemci kaydedersiniz ve API'yi bir işten, bir denetleyiciden ya da bir servisten, herhangi bir PHP programından çağırdığınız gibi çağırırsınız. İstemciyi bir süreç boyunca tutmak güvenlidir; bu yüzden onu paylaşılan bir servis olarak bir kez kaydedin.

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'de anahtarı .env içinde tutun ve config/services.php üzerinden okuyun; böylece php artisan config:cache sonrasında da korunur. Singleton, bir şey onu ilk kez istediğinde kurulur; php artisan migrate gibi bir komutun anahtar olmadan sorunsuz çalışmasının nedeni budur. O andan itibaren OpenEmail tipini belirten her kurucu ya da handle metodu aynı istemciyi alır.

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

Symfony'de istemciyi sınıf adıyla bir servis olarak tanımlayın; autowiring onu OpenEmail isteyen her kurucuya verir. Servisler varsayılan olarak paylaşılır, bu yüzden tüm konteyner tek bir istemci kullanır.

Örnekler Laravel 12 veya üstünü ve Symfony 6.4 veya üstünü kullanır. İstemcinin kendisi hiçbir framework'e bağlı değildir, bu yüzden daha eski sürümlerde de, her birinin kendi servis kaydetme yöntemiyle çalışır.

Bir işten gönderme

Yavaş ya da başarısız bir gönderim hiçbir sayfayı bekletmesin diye istekten değil, kuyruktaki bir işten gönderin. idempotencyKey: değerini gönderimin ilgili olduğu kayıttan türetin. Kuyruk onu yeniden denediği için ya da API yanıt verdikten sonra bir worker çöktüğü için yeniden çalışan bir iş bu durumda aynı anahtarla gönderir ve API ikinci bir ileti göndermek yerine daha önce gönderdiği iletiyi yeniden oynatır.

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

Bir NetworkException dışarı kaçmaya bırakılır; böylece kuyruk işi $backoff saniye sonra yeniden dener ve ilk çalıştırma API'ye gerçekten ulaştıysa aynı anahtar bir sonraki çalıştırmayı yeniden oynatmaya dönüştürür. Bir ValidationException işi hemen başarısız kılar, çünkü API'nin yazıldığı hâliyle reddettiği bir ileti yeniden gönderildiğinde başarılı olamaz. Bir NetworkException işe ulaştığında istemci gönderimi aynı anahtarla zaten kendisi yeniden denemiştir, varsayılan olarak iki kez.

Anahtarı, invoice:42:email örneğinde olduğu gibi kayıt ve amaç için sabit tutun. Bir zaman damgasından ya da uniqid() ile üretilen bir anahtar her çalıştırmada yenidir ve bu durumda yeniden denenen bir iş iki kez gönderir. Bir anahtarı farklı bir gövdeyle, örneğin iki çalıştırma arasında değişmiş bir faturayla yeniden kullanmak gönderimle sonuçlanmaz, 422 idempotency_key_reuse ile reddedilir.

Bir Symfony Messenger handler'ı da aynı şekilde çalışır. Messenger başarısız bir iletiyi kendiliğinden yeniden dener; UnrecoverableMessageHandlingException fırlatmak ona bunu yapmamasını söyler.

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'in kendine ait bir ValidationException sınıfı vardır. Bir dosyada ikisine de ihtiyaç duyulduğunda birini başka bir adla içe aktarın, örneğin use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.

Webhook'ları alma

Her teslimatı, ona göre işlem yapmadan önce doğrulayın. İmza gövdenin ham baytlarını kapsar; bu yüzden doğrulayıcıya ayrıştırılmış ya da yeniden kodlanmış bir kopyayı değil, $request->getContent() değerini, imza başlığını okuyacağı $request->headers ile birlikte geçirin.

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

Bir teslimat CSRF tokenı taşımaz; bu yüzden routes/web.php içindeki bir rota o middleware'i dışarıda bırakmalıdır, yoksa Laravel denetleyiciniz çalışmadan önce POST isteğini 419 ile reddeder. routes/api.php içindeki bir rotada zaten CSRF denetimi yoktur.

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

Olayı kuyruğa devredin ve hemen yanıt verin: 5 saniye içinde yanıt almayan bir teslimat başarısız sayılır ve daha sonra yeniden gönderilir. Olayın id değeri her yeniden denemede ve yeniden oynatmada aynıdır; bu yüzden işlediğiniz kimlikleri saklayın ve daha önce gördüğünüz birini atlayın.

Yalnızca sahte ya da eskimiş bir teslimata 400 ile yanıt verin, başka hiçbir şeye değil. Eksik bir gizli anahtar farklı bir hatadır: OpenEmail::verifyWebhookSignature() bunun için yukarıdaki denetleyicilerin yakalamadığı bir InvalidArgumentException fırlatır; böylece yanlış yapılandırılmış bir uygulama 500 ile yanıt verir ve her olayın sahte diye geri çevrilmesi yerine, siz sorunu düzelttiğinizde teslimat yeniden denenir.

Uzun süre çalışan worker'lar

PHP-FPM altında konteyner her istek için kurulur, istemci de öyle. Laravel Octane, RoadRunner, worker modundaki FrankenPHP ya da bir kuyruk worker'ı altında singleton, worker yaşadığı sürece yaşar; bu yüzden tek bir istemci ve açık bağlantısı, worker'ın işlediği her isteğe hizmet eder. İstemci tek bir isteğe ait hiçbir durum tutmaz: çağrı başına bir apiKey: kimlik bilgisini yalnızca o çağrı için değiştirir.

pcntl_fork gibi fork yapan bir worker, alt sürece kendi bağlantısını verir: alt süreç devraldığı bağlantıyı kullanmayı bırakır ve ilk isteğinde yeni bir bağlantı açar. Yine de sona eren bir alt süreç, devralınan bağlantıyı üst süreç için de kapatır; bu yüzden fork etmeden önce $client->close() çağırın, böylece üst süreç hiçbir alt süreçle paylaşmadığı yeni bir bağlantı açar.

Test etme

Bir test süresince konteynere sahte bir HTTP istemcisine sahip bir istemci koyun; hiçbir şey makineden çıkmaz. OpenEmail\Http\HttpClient arayüzünü uygulayan her şey, anonim bir sınıf da dahil, işe yarar ve dışarı gidecek olanı tam olarak görür.

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

Kaydedilen her istek method, url, headers, body ve timeout içeren bir OpenEmail\Http\HttpRequest nesnesidir ve body değeri gönderilecek olan JSON'dur; bu yüzden json_decode($request->body, true) iletinin kendisini gösterir. Kodunuzun bir reddi nasıl ele aldığını test etmek için API'nin hata zarfıyla birlikte bir hata durumu döndürün. Symfony'de servisi test ortamı için config/services_test.yaml içinde yeniden tanımlayın ve $httpClient argümanı olarak sahte bir HTTP istemcisi verin.

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