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
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()));
}
}{
"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
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
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
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
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.