تخطَّ إلى المستندات
PHP

Laravel وSymfony

عميل واحد في الحاوية، ومهمة لا يمكنها الإرسال مرتين، ووحدة تحكم لـ webhook، واختبارات لا تصل إلى الشبكة أبدًا.

الإعداد

ليس للحزمة تكامل خاص مع أي إطار عمل: لا مزوّد خدمة، ولا bundle، ولا ناقل بريد. تسجّل عميلًا واحدًا في الحاوية وتستدعي 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. ويُبنى الـ singleton أول مرة يطلبه فيها شيء ما، ولهذا يعمل أمر مثل php artisan migrate جيدًا دون المفتاح. ومن بعدها يتلقى كل مُنشئ أو تابع handle يذكر OpenEmail العميلَ نفسه.

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

في Symfony، صرّح بالعميل كخدمة تحت اسم صنفه، ويسلّمه الربط التلقائي (autowiring) إلى كل مُنشئ يطلب 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;.

استقبال webhooks

تحقق من كل تسليم قبل أن تتصرف بناءً عليه. يغطي التوقيع البايتات الخام للمتن، فمرّر $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 في وضع العامل أو عامل الطوابير، فيعيش الـ singleton ما دام العامل حيًّا، فيخدم عميل واحد واتصاله المفتوح كل طلب يعالجه العامل. ولا يحمل أي حالة تخص طلبًا بعينه: فـ 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