Skip to content

Invoice PDF generation ​

Goal: when an order is paid, answer the request at once and render the invoice PDF in a worker. A retry or a duplicate message must never produce a second invoice.

Files ​

The project starts Redis from its own docker-compose.yml. src/Hermes.php is the same as in the welcome email tutorial.

php
<?php

declare(strict_types=1);

namespace App;

use Predis\Client;
use Tomaj\Hermes\Driver\PredisSetDriver;
use Tomaj\Hermes\Emitter;

final class Hermes
{
    public static function redis(): Client
    {
        return new Client(getenv('REDIS_URL') ?: 'tcp://127.0.0.1:6379');
    }

    public static function driver(Client $redis): PredisSetDriver
    {
        // Queue key "hermes", poll once a second, delayed messages and retries in "hermes_schedule".
        return new PredisSetDriver($redis, 'hermes', 1, 'hermes_schedule');
    }

    public static function emitter(): Emitter
    {
        return new Emitter(self::driver(self::redis()));
    }
}
json
{
    "require": {
        "php": ">=8.4",
        "tomaj/hermes": "dev-master",
        "predis/predis": "^2.3 || ^3.0",
        "dompdf/dompdf": "^3.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}
php
<?php

declare(strict_types=1);

use App\Hermes;
use Tomaj\Hermes\Message;

require __DIR__ . '/../vendor/autoload.php';

// POST /?order=1042 after the payment is confirmed.
$orderId = filter_input(INPUT_GET, 'order', FILTER_VALIDATE_INT);
if (!is_int($orderId)) {
    http_response_code(422);
    echo 'Missing order id.';
    return;
}

Hermes::emitter()->emit(new Message('invoice.generate', ['order_id' => $orderId]));

http_response_code(202);
echo "Invoice for order {$orderId} is being generated.";
php
<?php

declare(strict_types=1);

namespace App;

use Dompdf\Dompdf;
use RuntimeException;
use Tomaj\Hermes\Handler\HandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;

final class InvoicePdfHandler implements HandlerInterface
{
    use RetryTrait;

    public function __construct(private string $directory) {}

    #[\Override]
    public function handle(MessageInterface $message): bool
    {
        $orderId = (int) ($message->getPayload()['order_id'] ?? 0);
        $target = "{$this->directory}/invoice-{$orderId}.pdf";

        // A retry or a duplicate message must not create a second invoice.
        if (is_file($target)) {
            return true;
        }

        $order = $this->loadOrder($orderId);

        $dompdf = new Dompdf();
        $dompdf->loadHtml($this->render($order));
        $dompdf->setPaper('A4');
        $dompdf->render();

        // Write to a temporary file first, so a crash never leaves half a PDF behind.
        $tmp = $target . '.tmp';
        if (file_put_contents($tmp, $dompdf->output()) === false || !rename($tmp, $target)) {
            throw new RuntimeException("Cannot write {$target}");
        }

        return true;
    }

    /** @return array{id: int, customer: string, lines: list<array{0: string, 1: float}>} */
    private function loadOrder(int $orderId): array
    {
        // Replace with a database query.
        return ['id' => $orderId, 'customer' => 'Jane Doe', 'lines' => [['Hosting, October', 19.0], ['Domain', 12.5]]];
    }

    /** @param array{id: int, customer: string, lines: list<array{0: string, 1: float}>} $order */
    private function render(array $order): string
    {
        $rows = '';
        $total = 0.0;
        foreach ($order['lines'] as [$item, $price]) {
            $rows .= sprintf('<tr><td>%s</td><td style="text-align:right">%.2f EUR</td></tr>', htmlspecialchars($item), $price);
            $total += $price;
        }

        return sprintf(
            '<h1>Invoice %d</h1><p>%s</p><table width="100%%">%s<tr><th>Total</th><th style="text-align:right">%.2f EUR</th></tr></table>',
            $order['id'],
            htmlspecialchars($order['customer']),
            $rows,
            $total,
        );
    }
}
php
<?php

declare(strict_types=1);

use App\Hermes;
use App\InvoicePdfHandler;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;

require __DIR__ . '/../vendor/autoload.php';

$directory = __DIR__ . '/../storage/invoices';
if (!is_dir($directory)) {
    mkdir($directory, 0o775, true);
}

$redis = Hermes::redis();
$driver = Hermes::driver($redis);
$driver->setShutdown(new PredisShutdown($redis));
$driver->setMaxProcessItems(200); // dompdf is memory hungry, start a fresh process now and then

$dispatcher = new Dispatcher($driver);
$dispatcher->registerHandler('invoice.generate', new InvoicePdfHandler($directory));
$dispatcher->handle();

Run it ​

bash
composer install
docker compose up -d redis
php bin/worker.php                       # terminal 1
php -S localhost:8001 -t public          # terminal 2
curl -X POST 'http://localhost:8001/?order=1042'

storage/invoices/invoice-1042.pdf appears within a second. Check it with pdftotext storage/invoices/invoice-1042.pdf -.

Test it ​

Send the same order twice. The second message finds the file and returns true without rendering. Delete the PDF and send it again to render a new one. make e2e runs both checks.

Pitfalls ​

  • Make the handler idempotent. A retry carries the same payload. The is_file() check plus the write-then-rename() means a crash or a retry never leaves two invoices or half a file.
  • Pass ids, not documents. The message holds order_id. The worker loads the current order itself, so the payload stays small and never holds stale data.
  • dompdf uses a lot of memory. setMaxProcessItems(200) lets the worker exit cleanly now and then. Run it under a process manager, as in the tutorial, so a new one starts.
  • Store files where every worker can read them. With several servers, write to shared storage or S3 instead of the local disk.

Full source ​

Every file of this recipe, with its README.md, Makefile and the e2e.sh check, is in examples/invoice-pdf on GitHub. Clone the repository, cd examples/invoice-pdf, and run make install up, then make e2e.

FAQ ​

Why check if the file exists? ​

A retry or a duplicate message must not create a second invoice.

Why write to a temporary file first? ​

rename() is atomic, so a crash never leaves half a PDF under the final name.

Why does the worker exit after 200 messages? ​

dompdf uses a lot of memory. A fresh process keeps memory flat. See Shutdown and max items.

Can I email the invoice too? ​

Yes. Emit a second message from the handler, or send it right after writing the file, as in the welcome email tutorial.

Where should invoices be stored with several servers? ​

On shared storage or S3, so every worker and the web app can read them.

Released under the MIT License.