A Tiny PHP Invoice-PDF API: Slim 4, dompdf, and the JPY Rounding Footgun
Every small-business backend eventually needs "POST invoice JSON β get PDF". I wrote the smallest possible version of that β 4 PHP files, one HTML template, one Docker image, 39 tests β and ran headfirst into the JPY rounding footgun that trips up everyone coming from USD.
π¦ GitHub: https://github.com/sen-ltd/invoice-pdf
The problem
If you run any SaaS or internal billing system for more than six months, this feature request lands in your backlog:
Can we email invoices as PDFs?
You have options. Commercial APIs like DocRaptor, PDF.co, Lob, and the handful of "invoice as a service" startups all do this for you. They also cost real money β usually a few cents per document β and they send your customer data through a third party for a transformation that has no business being remote. It's a pure function: invoice fields in, 25 KB of PDF bytes out.
The temptation is to pull in a PDF library and wire it up to an endpoint. That temptation is correct. The part people get wrong is treating the PDF as the hard problem. It isn't. The PDF renderer is a 10-line wrapper. The hard part is the bit upstream of the renderer: validating the JSON and getting the currency rounding right.
This is a walkthrough of a working self-hosted invoice-PDF microservice in PHP. Slim 4 for the HTTP layer, dompdf for the renderer, and some opinions about where the real complexity hides.
Why PHP and why dompdf
PHP because the target reader for this service is a small company with an existing PHP billing app that already has an invoice table in MySQL. A PHP microservice drops into their deployment story with zero new primitives.
dompdf because it's the PHP PDF library I keep coming back to after trying all of them. Quick survey:
- wkhtmltopdf β excellent output, but it's a binary you shell out to, needs Qt, and is archived. Not going in a production image in 2026.
- mpdf β good unicode story, very heavy, somewhat byzantine API for footers and headers.
-
tcpdf β battle-tested but its API is the 2005 ImperativeDraw school of PDF generation:
$pdf->Cell(),$pdf->SetXY(). You can't design an invoice layout in a CSS file. -
dompdf β "give me HTML + CSS 2.1, I'll give you a PDF." That's the right API for an invoice. You design the invoice in a normal
.htmlfile with inline styles, and the renderer handles the rest.
dompdf has real downsides β it's big (~12 MB of Composer deps), CSS 3 support is uneven, no CJK fonts out of the box β and I'll come back to those. But the API shape is correct for this use case.
The design
Four source files, one template, one fixture:
src/
βββ Validator.php # JSON β normalised invoice, or per-field errors
βββ Calculator.php # subtotal / tax / total with currency-aware rounding
βββ Renderer.php # dompdf wrapper
βββ Middleware/
βββ JsonRequestLogger.php
templates/
βββ invoice.html.php # the invoice template β the customisation surface
public/
βββ index.php # Slim app factory
Three HTTP routes:
-
POST /invoiceβ validated JSON body, returnsapplication/pdfwith aContent-Disposition: attachmentheader -
POST /invoice/previewβ same input, returns the rendered HTML so you can iterate on the template without waiting for dompdf on every save -
GET /healthβ version info including the running dompdf version
The preview endpoint is the secret weapon. Rendering a PDF through dompdf takes ~60 ms on my laptop, and while that's fine at runtime, it's painful when you're adjusting a margin and reloading every two seconds. Preview mode returns the raw HTML, which is what dompdf is about to render, in under a millisecond. You design against /invoice/preview and trust the renderer.
The validator: never hand junk to dompdf
The single most important rule when using a heavyweight HTML renderer from a web handler is: validate everything up front. dompdf is a 30,000-line library that internally uses a port of PHP's DOM extension and its own CSS parser. Feeding it something unexpected β a null where a string is expected, an array where a scalar is expected, a malformed date β can crash it in ways that are surprising to debug. You don't want any request to reach $dompdf->render() unless every field has been checked.
Here's the validator's entry point:
public static function validate(mixed $body): array
{
$errors = [];
if (!is_array($body)) {
return ['ok' => false, 'errors' => ['_body' => 'must be a JSON object']];
}
$invoiceNumber = $body['invoice_number'] ?? null;
if (!is_string($invoiceNumber) || trim($invoiceNumber) === '') {
$errors['invoice_number'] = 'required, must be a non-empty string';
} elseif (strlen($invoiceNumber) > 64) {
$errors['invoice_number'] = 'must be 64 characters or fewer';
}
// ... issue_date, due_date, currency, seller, buyer, items, notes
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
return ['ok' => true, 'invoice' => [
'invoice_number' => trim((string) $invoiceNumber),
// ... normalised
]];
}
Two things worth calling out:
- The validator returns an error map, not an exception. A real billing frontend wants to show a form with red underlines under every bad field, not pop up one error at a time. So the validator collects every problem and returns them together. The HTTP handler turns that into a 422 with
{"error":"validation_failed","fields":{"invoice_number":"...","items.0.quantity":"..."}}. - On success, it returns a normalised invoice, not the original.
quantity: "3"becomesquantity: 3.0,invoice_number: " INV-1 "becomes"INV-1". The renderer gets types it can trust.
Date validation uses checkdate() to reject things like 2026-02-30:
private static function isIsoDate(mixed $v): bool
{
if (!is_string($v) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
return false;
}
[$y, $m, $d] = array_map('intval', explode('-', $v));
return checkdate($m, $d, $y);
}
The regex catches format; checkdate catches "this isn't a real day". Both are needed.
The JPY rounding footgun
Here's the bit that trips people up, and the thing this service is opinionated about:
JPY has zero fractional digits. There is no such thing as Β₯0.01. The yen is not decimalised. Every JPY amount is a whole integer.
A developer who cut their teeth on USD invoices will write this kind of code without thinking:
$tax = round($subtotal * $rate, 2);
If $subtotal is 37035 JPY and $rate is 0.10, that code produces 3703.5. Put that on an invoice and hand it to a Japanese accounting system and it will be rejected. Β₯3,703.5 is not a valid amount. It has to be 3704 (half up) or 3703 (half down), depending on your rounding rule β but it has to be a whole integer.
The Calculator in this service treats rounding as a property of the currency, not a hardcoded constant:
public static function decimalsFor(string $currency): int
{
return match ($currency) {
'JPY' => 0,
'USD', 'EUR',
'GBP' => 2,
default => 2,
};
}
public static function totals(array $invoice): array
{
$decimals = self::decimalsFor((string) $invoice['currency']);
$subtotal = 0.0;
$tax = 0.0;
$lines = [];
foreach ($invoice['items'] as $item) {
$qty = (float) $item['quantity'];
$price = (float) $item['unit_price'];
$rate = (float) $item['tax_rate'];
$lineSubtotal = self::round($qty * $price, $decimals);
$lineTax = self::round($qty * $price * $rate, $decimals);
$lineTotal = self::round($lineSubtotal + $lineTax, $decimals);
$lines[] = [
'subtotal' => $lineSubtotal,
'tax' => $lineTax,
'total' => $lineTotal,
];
$subtotal += $lineSubtotal;
$tax += $lineTax;
}
return [
'subtotal' => self::round($subtotal, $decimals),
'tax' => self::round($tax, $decimals),
'total' => self::round($subtotal + $tax, $decimals),
'lines' => $lines,
];
}
private static function round(float $v, int $decimals): float
{
return round($v, $decimals, PHP_ROUND_HALF_UP);
}
Three things are important:
-
decimalsis fetched once at the top and used for every calculation. You never have two places in the same pipeline disagreeing about how many decimals JPY has. - Rounding happens at every line item, not just on the final total. This matches how a paper invoice works: each row has a fixed amount the accountant can reconcile, and the totals are the sum of the rounded rows. Rounding only at the end gives you totals that don't match what you'd get by adding the visible line amounts β a classic source of "off by one yen" bugs.
- Rounding is half-up.
PHP_ROUND_HALF_UProunds away from zero:0.5β1,12.345β12.35. This matches what accountants expect and what most tax authorities assume in their examples.
The format() helper uses the same table to display amounts:
public static function format(float $amount, string $currency): string
{
$decimals = self::decimalsFor($currency);
$symbol = match ($currency) {
'JPY' => 'Β₯',
'USD' => '$',
'EUR' => 'β¬',
'GBP' => 'Β£',
default => '',
};
return $symbol . number_format($amount, $decimals, '.', ',');
}
So format(12345.0, 'JPY') is Β₯12,345 and format(12345.67, 'USD') is $12,345.67. Unit-tested both ways.
If you only remember one thing from this post: currency decimals are metadata about the currency, not a constant you inline at the call site. Put them in a function and call the function everywhere.
The renderer: 40 lines wrapping dompdf
Once the validator has done its job, the renderer is almost trivial:
public function renderHtml(array $invoice): string
{
$totals = Calculator::totals($invoice);
$currency = (string) $invoice['currency'];
ob_start();
$seller = $invoice['seller'];
$buyer = $invoice['buyer'];
$items = $invoice['items'];
$notes = (string) ($invoice['notes'] ?? '');
$invoiceNumber = (string) $invoice['invoice_number'];
$issueDate = (string) $invoice['issue_date'];
$dueDate = (string) $invoice['due_date'];
$fmt = static fn (float $v): string => Calculator::format($v, $currency);
include $this->templateDir . '/invoice.html.php';
return (string) ob_get_clean();
}
public function renderPdf(array $invoice): string
{
$html = $this->renderHtml($invoice);
$options = new Options();
$options->set('isRemoteEnabled', false); // crucial
$options->set('isHtml5ParserEnabled', true);
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return $dompdf->output();
}
The single most important line in this file is $options->set('isRemoteEnabled', false);. Without it, a malicious or confused JSON body could put an <img src="http://169.254.169.254/latest/meta-data/"> into the rendered HTML and dompdf would happily fetch it. Invoice templates should only ever reference local resources. Lock it down.
The template itself (templates/invoice.html.php) is a plain PHP include. No Twig, no Blade, no Smarty. It's a 100-line HTML file with inline CSS and <?= $h($invoiceNumber) ?> interpolations. Customising the invoice means editing one file with normal HTML and CSS. That's the whole point β the HTML template is the customisation surface, and it should feel like editing a webpage, not a DSL.
The tradeoffs
Being honest about the downsides:
- Image size: The final Docker image is ~78 MB (alpine 3.19 + PHP 8.2 + gd + Composer deps including dompdf). That's bigger than a Go or Rust service doing the same job, and most of the weight is dompdf itself. I think it's fine β it's a "one small container behind a billing dashboard" service, not something you're running 200 copies of. If you're fleet-deploying invoice renderers, pick a language where the PDF library is smaller.
- No CJK fonts: dompdf ships with DejaVu Sans, which covers Latin/Cyrillic/Greek and misses ζ₯ζ¬θͺ entirely. If you need to render Japanese invoices (and the whole point of getting JPY rounding right is that you probably do), you need to add a CJK font like IPAexGothic to dompdf's font dir and reference it in the template CSS. The service doesn't do that out of the box because CJK fonts add ~5 MB per script and not everyone needs them.
-
No digital signatures: Real e-invoicing regimes (EU PAdES, Japanese ι»εεΈ³η°ΏδΏεζ³) want signed PDFs. dompdf doesn't do signatures. If you need them, bolt on a separate signing step with something like
setasign/fpdi-fpdfplus a certificate. Out of scope for v1. - No storage, no state: The service is stateless. It renders a PDF from input and throws everything away. If you want an audit trail, keep the JSON in your billing DB and re-render on demand.
- No auth: Put it behind your internal gateway.
These aren't hidden β the README lists them up front. A microservice is honest about what it is.
Try it in 30 seconds
git clone https://github.com/sen-ltd/invoice-pdf
cd invoice-pdf
docker build -t invoice-pdf .
docker run --rm -p 8000:8000 invoice-pdf
# in another shell:
curl -sS -X POST http://localhost:8000/invoice \
-H "Content-Type: application/json" \
-d @tests/fixtures/sample.json \
-o invoice.pdf
open invoice.pdf
39 PHPUnit tests, including a renderer test that checks the output starts with %PDF- and ends with %%EOF so you know dompdf actually produced a real PDF and not just a string. Run them inside the image too:
docker run --rm --entrypoint /app/vendor/bin/phpunit invoice-pdf --no-coverage -c /app/phpunit.xml
The takeaway
The interesting engineering in an invoice-PDF service isn't the PDF. It's the four things upstream of the renderer:
- Validate everything before the heavyweight library sees it.
- Treat currency decimals as metadata, not a constant.
- Round at the line level so printed rows reconcile with printed totals.
- Make the HTML template the customisation surface so rebranding doesn't require a new deploy pipeline.
Do those four things right and the renderer becomes a 40-line wrapper. Do them wrong and you'll spend a weekend debugging why your Japanese customer's invoice shows Β₯3,703.5.
MIT-licensed on GitHub: https://github.com/sen-ltd/invoice-pdf. Built as entry #178 of a 100+ portfolio project for SEN εεδΌη€Ύ.

Top comments (0)