php-fpm in production

No FFI-style enablement dance. proc_open — what symfony/process uses under the hood to run maildeno-engine — is available by default in both the CLI and php-fpm SAPIs. The one thing worth checking on locked-down shared hosts is that proc_open/proc_close haven’t been disabled via disable_functions.

Each render spawns the binary fresh — there’s no compiled-module state to amortize — so there’s no strict requirement to build one client per worker the way some FFI-based setups need, though nothing stops you from doing so for consistency.

The engine constructor takes an optional timeoutSeconds for the subprocess call (default 30):

use Maildeno\NativeEngine;

$engine = new NativeEngine('/path/to/maildeno-engine', timeoutSeconds: 10.0);
Classic php-fpm tears down the request’s object graph after every response — a MaildenoClient built fresh per request means a fresh MemoryStore too. Use disk caching if you want template JSON to survive across requests. See Caching.

Laravel

Bind a singleton in a service provider so the client (and its cache) are shared for the lifetime of the request:

// app/Providers/AppServiceProvider.php
use Maildeno\MaildenoClient;

public function register(): void
{
    $this->app->singleton(MaildenoClient::class, function () {
        return new MaildenoClient([
            'apiKey' => config('services.maildeno.key'),
            'cache'  => ['type' => 'disk', 'path' => storage_path('framework/cache/maildeno')],
        ]);
    });
}
// app/Http/Controllers/RenderEmailController.php
use Maildeno\MaildenoClient;
use Maildeno\MaildenoError;
use Illuminate\Http\Request;

class RenderEmailController extends Controller
{
    public function __invoke(Request $request, MaildenoClient $maildeno)
    {
        $data = $request->validate([
            'template_id' => ['required', 'uuid'],
            'name'        => ['required', 'string'],
            'plan'        => ['required', 'string'],
        ]);

        try {
            $html = $maildeno->renderHtml($data['template_id'], [
                'merge_tags' => ['text' => ['name' => $data['name']]],
                'context'    => ['plan' => $data['plan']],
            ]);
            return response()->json(['html' => $html]);
        } catch (MaildenoError $e) {
            return response()->json(
                ['error' => $e->code, 'message' => $e->getMessage()],
                $e->status ?: 500
            );
        }
    }
}

Using storage_path() for the disk cache keeps it writable, cleared alongside the rest of Laravel’s cache, and shared by every php-fpm worker on the box.

Symfony

Register the client as a service and inject it wherever you need it:

# config/services.yaml
services:
    Maildeno\MaildenoClient:
        arguments:
            $config:
                apiKey: '%env(MAILDENO_API_KEY)%'
                cache:
                    type: disk
                    path: '%kernel.cache_dir%/maildeno'
// src/Controller/RenderEmailController.php
use Maildeno\MaildenoClient;
use Maildeno\MaildenoError;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class RenderEmailController
{
    #[Route('/api/render-email', methods: ['POST'])]
    public function __invoke(Request $request, MaildenoClient $maildeno): JsonResponse
    {
        $body = json_decode($request->getContent(), true);

        try {
            $html = $maildeno->renderHtml($body['template_id'], [
                'merge_tags' => ['text' => ['name' => $body['name']]],
            ]);
            return new JsonResponse(['html' => $html]);
        } catch (MaildenoError $e) {
            return new JsonResponse(
                ['error' => $e->code, 'message' => $e->getMessage()],
                $e->status ?: 500
            );
        }
    }
}

Plain PHP

<?php
require 'vendor/autoload.php';

use Maildeno\MaildenoClient;
use Maildeno\MaildenoError;

$maildeno = new MaildenoClient(['apiKey' => getenv('MAILDENO_API_KEY')]);

$input = json_decode(file_get_contents('php://input'), true);

header('Content-Type: application/json');

try {
    $html = $maildeno->renderHtml($input['template_id'], [
        'merge_tags' => ['text' => ['name' => $input['name']]],
    ]);
    echo json_encode(['html' => $html]);
} catch (MaildenoError $e) {
    http_response_code($e->status ?: 500);
    echo json_encode(['error' => $e->code, 'message' => $e->getMessage()]);
}

CLI scripts and cron jobs

A one-off script or Artisan/Symfony console command lives for the duration of the run, so a memory cache is fine even though it won’t persist to the next invocation:

<?php
require 'vendor/autoload.php';

use Maildeno\MaildenoClient;

$client = new MaildenoClient(['apiKey' => getenv('MAILDENO_API_KEY')]);

foreach (getAllUsers() as $user) {
    $html = $client->renderHtml('onboarding-template', [
        'merge_tags' => ['text' => ['name' => $user->name]],
    ]);
    sendEmail($user->email, $html);
}

If the script renders the same template many times in a loop, that still means one maildeno-engine subprocess spawn per iteration — the memory cache only skips the network fetch, not the render step. See Caching.