---
title: "Laravel and Symfony"
description: "One client in the container, a job that cannot send twice, a webhook controller, and tests that never reach the network."
url: "https://openemail.uk/docs/php/frameworks"
area: "PHP"
category: "Getting started"
---

# Laravel and Symfony

One client in the container, a job that cannot send twice, a webhook controller, and tests that never reach the network.

## Setting up

The package has no framework integration of its own: no service provider, no bundle and no mail transport. You register one client in the container and call the API from a job, a controller or a service, the same way as from any PHP program. The client is safe to keep for the life of a process, so register it once as a shared service.

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

In Laravel, keep the key in `.env` and read it through `config/services.php`, so it survives `php artisan config:cache`. The singleton is built the first time something asks for it, which is why a command such as `php artisan migrate` runs fine without the key. From then on any constructor or `handle` method that names `OpenEmail` receives the same client.

**config/services.yaml**

```
services:
    OpenEmail\OpenEmail:
        arguments:
            $apiKey: '%env(OPENEMAIL_API_KEY)%'
            $timeout: 15
```

In Symfony, declare the client as a service under its class name, and autowiring hands it to every constructor that asks for `OpenEmail`. Services are shared by default, so the whole container uses one client.

The examples use Laravel 12 or later and Symfony 6.4 or later. The client itself depends on no framework, so it works in older versions too, with their own way of registering a service.

## Sending from a job

Send from a queued job rather than from the request, so a slow or failing send never holds up a page. Derive `idempotencyKey:` from the record the send is about. A job that runs again, because the queue retried it or a worker died after the API answered, then sends with the same key, and the API replays the message it already sent instead of sending a second one.

**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 <billing@acme.com>',
                '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']]);
    }
}
```

A `NetworkException` is left to escape, so the queue tries the job again after `$backoff` seconds, and the same key makes the next run a replay if the first one did reach the API. A `ValidationException` fails the job at once, since a message the API refused as written cannot succeed when sent again. By the time a `NetworkException` reaches the job, the client has already tried the send again itself, twice by default, under the same key.

> Keep the key stable for the record and the purpose, as `invoice:42:email` is. A key built from a timestamp or from `uniqid()` is new on every run, and a retried job would then send twice. Reusing a key with a different body, such as an invoice that changed between two runs, is refused with a 422 `idempotency_key_reuse` rather than sent.

A Symfony Messenger handler works the same way. Messenger retries a failed message on its own, and throwing `UnrecoverableMessageHandlingException` tells it not to.

**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 <hello@acme.com>',
                '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 has a `ValidationException` of its own. When a file needs both, import one under another name, such as `use OpenEmail\Exception\ValidationException as OpenEmailValidationException;`.

## Receiving webhooks

Verify every delivery before you act on it. The signature covers the raw bytes of the body, so pass `$request->getContent()` to the verifier, not a parsed or re-encoded copy, with `$request->headers`, from which it reads the signature header.

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

A delivery carries no CSRF token, so a route in `routes/web.php` has to leave that middleware out, or Laravel refuses the POST with a 419 before your controller runs. A route in `routes/api.php` has no CSRF check to begin with.

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

Hand the event to the queue and answer at once: a delivery that gets no answer within 5 seconds counts as failed and is sent again later. The event’s `id` is the same on every retry and replay of it, so store the ids you have handled and skip one you have seen.

> Answer 400 for a forged or stale delivery and nothing else. A missing secret is a different failure: `OpenEmail::verifyWebhookSignature()` throws `InvalidArgumentException` for it, which the controllers above do not catch, so a misconfigured app answers 500 and the delivery is tried again once you fix it, rather than every event being turned away as forged.

## Long-running workers

Under PHP-FPM the container is built for every request, and so is the client. Under Laravel Octane, RoadRunner, FrankenPHP in worker mode or a queue worker, the singleton lives as long as the worker, so one client and its open connection serve every request the worker handles. It holds no state that belongs to one request: a per-call `apiKey:` replaces the credential for that call alone.

> A worker that forks, as `pcntl_fork` does, gives the child a connection of its own: the child stops using the one it inherited and opens a new one on its first request. A child that exits still closes the inherited connection for the parent, so call `$client->close()` before you fork, and the parent opens a fresh one that no child shares.

## Testing

Put a client with a fake HTTP client in the container for the length of a test and nothing leaves the machine. Anything that implements `OpenEmail\Http\HttpClient` works, an anonymous class included, and it sees exactly what would have gone out.

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

Each recorded request is an `OpenEmail\Http\HttpRequest` with `method`, `url`, `headers`, `body` and `timeout`, and its `body` is the JSON that would have been sent, so `json_decode($request->body, true)` shows the message itself. Return an error status with the API’s error envelope to test how your code handles a refusal. In Symfony, define the service again for the test environment in `config/services_test.yaml`, with a fake HTTP client as its `$httpClient` argument.

**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
```
