Laravel और Symfony
container में एक क्लाइंट, एक जॉब जो दो बार नहीं भेज सकता, एक वेबहुक controller, और ऐसे टेस्ट जो कभी नेटवर्क तक नहीं पहुँचते।
सेटअप
पैकेज का अपना कोई फ़्रेमवर्क integration नहीं है: न service provider, न bundle, न mail transport। आप container में एक क्लाइंट register करते हैं और किसी जॉब, controller या service से API कॉल करते हैं, ठीक वैसे ही जैसे किसी भी PHP प्रोग्राम से। क्लाइंट को प्रोसेस के पूरे जीवनकाल तक रखना सुरक्षित है, इसलिए उसे एक बार shared service के रूप में register करें।
<?php return [ 'openemail' => [ 'key' => env('OPENEMAIL_API_KEY'), 'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'), ],];<?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 जैसी कमांड कुंजी के बिना भी ठीक चलती है। उसके बाद OpenEmail का नाम लेने वाला हर constructor या handle मेथड वही क्लाइंट पाता है।
services: OpenEmail\OpenEmail: arguments: $apiKey: '%env(OPENEMAIL_API_KEY)%' $timeout: 15Symfony में, क्लाइंट को उसकी क्लास के नाम से एक service के रूप में घोषित करें, और autowiring उसे OpenEmail माँगने वाले हर constructor को दे देता है। services डिफ़ॉल्ट रूप से shared होती हैं, इसलिए पूरा container एक ही क्लाइंट इस्तेमाल करता है।
उदाहरण Laravel 12 या उसके बाद के और Symfony 6.4 या उसके बाद के संस्करण इस्तेमाल करते हैं। क्लाइंट ख़ुद किसी फ़्रेमवर्क पर निर्भर नहीं है, इसलिए यह पुराने संस्करणों में भी काम करता है, उनके अपने तरीक़े से service register करके।
जॉब से भेजना
रिक्वेस्ट के बजाय queue में रखे जॉब से भेजें, ताकि धीमा या विफल send कभी किसी पेज को न रोके। idempotencyKey: को उस रिकॉर्ड से बनाएँ जिसके बारे में send है। जो जॉब दोबारा चलता है, क्योंकि queue ने उसे retry किया या API के जवाब देने के बाद कोई worker बंद हो गया, वह फिर उसी कुंजी के साथ भेजता है, और API दूसरा संदेश भेजने के बजाय पहले भेजे गए संदेश को replay करता है।
<?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 को बाहर जाने दिया जाता है, इसलिए queue $backoff सेकंड बाद जॉब को फिर आज़माती है, और अगर पहला प्रयास API तक पहुँच गया था तो वही कुंजी अगले रन को replay बना देती है। ValidationException जॉब को तुरंत विफल कर देता है, क्योंकि जिस संदेश को API ने उसके लिखे रूप के कारण अस्वीकार किया, वह दोबारा भेजने पर सफल नहीं हो सकता। जब तक NetworkException जॉब तक पहुँचता है, क्लाइंट उसी कुंजी के साथ send को ख़ुद पहले ही दोबारा आज़मा चुका होता है, डिफ़ॉल्ट रूप से दो बार।
कुंजी को रिकॉर्ड और उद्देश्य के लिए स्थिर रखें, जैसे invoice:42:email है। timestamp या uniqid() से बनी कुंजी हर रन पर नई होती है, और तब retry किया गया जॉब दो बार भेज देगा। किसी कुंजी को अलग बॉडी के साथ दोबारा इस्तेमाल करना, जैसे दो रन के बीच बदला हुआ invoice, भेजे जाने के बजाय 422 idempotency_key_reuse के साथ अस्वीकार किया जाता है।
Symfony Messenger का handler भी इसी तरह काम करता है। Messenger विफल संदेश को अपने आप retry करता है, और UnrecoverableMessageHandlingException throw करने से उसे ऐसा न करने को कहा जाता है।
<?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 है। जब किसी फ़ाइल को दोनों चाहिए हों, तो एक को दूसरे नाम से import करें, जैसे use OpenEmail\Exception\ValidationException as OpenEmailValidationException;।
वेबहुक प्राप्त करना
हर delivery पर कार्रवाई करने से पहले उसे सत्यापित करें। सिग्नेचर बॉडी के कच्चे बाइट्स पर बनता है, इसलिए सत्यापक को पार्स या दोबारा encode की गई कॉपी नहीं, बल्कि $request->getContent() पास करें, $request->headers के साथ, जिससे वह सिग्नेचर हेडर पढ़ता है।
<?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(); }}<?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]);delivery में कोई CSRF टोकन नहीं होता, इसलिए routes/web.php के route को वह middleware हटाना होगा, वरना आपके controller के चलने से पहले ही Laravel POST को 419 के साथ अस्वीकार कर देगा। routes/api.php के route में तो शुरू से ही कोई CSRF जाँच नहीं होती।
<?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); }}event को queue को सौंपें और तुरंत जवाब दें: जिस delivery को 5 सेकंड के भीतर जवाब नहीं मिलता, उसे विफल माना जाता है और बाद में फिर भेजा जाता है। event का id उसके हर retry और replay पर एक जैसा रहता है, इसलिए जिन id को आप संभाल चुके हैं उन्हें सहेजें और जो पहले देख चुके हैं उसे छोड़ दें।
400 सिर्फ़ जाली या पुरानी delivery के लिए लौटाएँ, किसी और चीज़ के लिए नहीं। ग़ायब secret एक अलग विफलता है: उसके लिए OpenEmail::verifyWebhookSignature() InvalidArgumentException throw करता है, जिसे ऊपर के controllers catch नहीं करते, इसलिए ग़लत कॉन्फ़िगर किया गया ऐप 500 लौटाता है और आपके ठीक करने के बाद delivery फिर आज़माई जाती है, बजाय इसके कि हर event को जाली मानकर लौटा दिया जाए।
लंबे समय तक चलने वाले workers
PHP-FPM में container हर रिक्वेस्ट के लिए बनता है, और क्लाइंट भी। Laravel Octane, RoadRunner, worker मोड में FrankenPHP या किसी queue worker में singleton तब तक रहता है जब तक worker रहता है, इसलिए एक क्लाइंट और उसका खुला कनेक्शन worker द्वारा संभाली जाने वाली हर रिक्वेस्ट के काम आते हैं। इसमें किसी एक रिक्वेस्ट की कोई स्थिति नहीं रहती: प्रति-कॉल apiKey: सिर्फ़ उसी कॉल के लिए क्रेडेंशियल बदलता है।
fork करने वाला worker, जैसा pcntl_fork करता है, चाइल्ड को उसका अपना कनेक्शन देता है: चाइल्ड विरासत में मिले कनेक्शन का इस्तेमाल बंद कर देता है और अपनी पहली रिक्वेस्ट पर नया कनेक्शन खोलता है। फिर भी ख़त्म होने वाला चाइल्ड विरासत में मिला कनेक्शन पैरेंट के लिए भी बंद कर देता है, इसलिए fork से पहले $client->close() कॉल करें, ताकि पैरेंट एक नया कनेक्शन खोले जिसे कोई चाइल्ड साझा नहीं करता।
टेस्टिंग
टेस्ट की अवधि के लिए container में नकली HTTP क्लाइंट वाला क्लाइंट रखें, और मशीन से बाहर कुछ नहीं जाता। OpenEmail\Http\HttpClient को implement करने वाली कोई भी चीज़ काम करती है, anonymous class समेत, और वह ठीक वही देखती है जो बाहर गया होता।
<?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 के error envelope के साथ कोई error status लौटाएँ। Symfony में, टेस्ट एनवायरनमेंट के लिए config/services_test.yaml में service को फिर से परिभाषित करें, और उसके $httpClient आर्ग्युमेंट के रूप में नकली HTTP क्लाइंट दें।
<?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"}'); }}services: App\Tests\FakeOpenEmailHttpClient: ~ OpenEmail\OpenEmail: arguments: $apiKey: 'oe_test_fake' $httpClient: '@App\Tests\FakeOpenEmailHttpClient' $maxRetries: 0 $disableUpdateNotice: true