ADR 0003 — Ticket PDF: HTML/Twig template rendered by dompdf¶
- Status: Accepted (HNSTIK-156, 15 Sep 2026)
- Deciders: HNS Ticketing team; engine and gating confirmed with the client
- Related: ADR 0002 — Admin stadium map editor (the map on the ticket)
Context¶
HNS hands fans a one-page A4 ticket (two vendor samples are attached to HNSTIK-156): a coloured header band with the match, venue, date and time and the two associations' badges; a stub with a vertical Code128 barcode on the left and a gate-scannable QR on the right (the vendor samples carry two Code128 barcodes; the QR replaced the second one on the client's ask, 16 Sep 2026), the holder's name, entrance gate, sector, row/seat and price (or "Pozivnica" for a free ticket) plus the organiser block; fold lines; the legal text next to a stadium map; a row of prohibited items; the bilingual "OVO JE VAŠA ULAZNICA" block with folding illustrations; and a sponsors strip. In the vendor's PDFs the whole design is a single raster with only the variable text on top — every change means a new image. The ticket asks us to "templatize this PDF considering in future we might need to change it".
The backend had no PDF library and no headless browser; the admin portal prints a plain HTML page in the browser (emergency print), the fan app shows the QR only inside 5 hours of kick-off.
Decision¶
- Render in the backend with dompdf (
dompdf/dompdf3.1, pure PHP) from a Twig HTML/CSS template (templates/pdf/ticket.html.twig). No new container, no network at render time: remote resources are disabled, file access is chrooted toresources/pdf, images are embedded as data URIs. The template is plain HTML, so a later move to a headless-browser renderer only swapsTicketPdfRenderer; the view model (TicketPdfData) and the template stay. - Layout as one absolutely positioned 595×838 pt canvas — dompdf has no flexbox/grid and no CSS
gradients, so each section sits at fixed coordinates, colours are flat, and the vertical barcodes are
PNGs pre-rotated with GD rather than CSS transforms. Fonts: dompdf's bundled DejaVu Sans (covers the
Croatian alphabet); metrics are generated on first render into
var/cache/dompdf. - Every variable part is a slot on the view model, filled by
TicketPdfDataFactory: match (competition, Croatian title, English match line, venue | Croatian genitive date | Zagreb time), badges (both teams' country flags — association logos later, per the ticket; the HNS crest is kept as an asset for that day), holder, seat (gate from the live sector, sector name from the per-match snapshot the app shows, row/seat from the inventory), price / Pozivnica, document number and barcodes, organiser constants (a parameter block, not template literals), the stadium map and the static art. The stub's QR carries the ticket'sqr_code_data— byte-for-byte the payload the app's QR and the emergency print already use, so one gate scanner reads all three; it is rendered at error-correction level M, and falls back to the barcode string for a ticket that has noqr_code_datayet. Row identifiers are printed without a leading "Red", because the label beside them already reads "Red/Row". Flags come from an explicit FIFA→ISO map: a code that is not in it gets no badge, never a guessed one — a wrong national flag on an official ticket is worse than none. - The stadium map is real, not a dummy PNG:
StadiumMapSvgRendererdraws the sector polygons the admin placed in the map editor (ADR 0002, normalised 0–1 on the shared 1200×800 pitch plan) as an SVG image. Colours are per match: every sector takes the colour of the price category it sells under for that match (set on the match's stadium configuration); sectors the match does not sell are neutral grey; all sectors carry a black outline; and — per the client (16 Sep 2026), like the samples — the ticket's own sector is not highlighted. A stadium with no shapes yet gets the bare pitch and a "karta u pripremi" caption. - Static art lives in
resources/pdf/with a README: HNS Family logo, prohibited-items row, folding guide and — explicitly as a placeholder until the client decides — one sponsors PNG. MIT flag SVGs (lipis/flag-icons) keyed by ISO code with a FIFA-trigramme map. - Access:
GET /tickets/{id}/pdffor the buyer or holder, gated exactly like the QR code (visibility tierfull, i.e. less than 5 hours to kick-off; away tickets with an external PDF answer 409 with that URL).GET /admin/support/tickets/{id}/pdffor support agents at any time, with the emergency print's protective guards (valid status, match not over, holder not blacklisted), audit logged asticket_pdf_downloadand not counted as a reprint. PDFs are generated on demand and never stored — they always reflect the current seat and gate.
How to change the template¶
- Copy or move things: edit the CSS coordinates in
templates/pdf/ticket.html.twig; every section is a Twig block (header,stub,legal,map,yours,sponsors). - New data: add a field to
TicketPdfData, fill it inTicketPdfDataFactory, print it in the template. - New art: drop the file into
resources/pdf/(see its README) — the factory embeds it. - Per-match theming:
TicketPdfData::$themeColoris the single hook; today it is the HNS navy for all. - Barcodes / QR:
BarcodeImageFactoryandQrImageFactoryproduce data URIs; changing the QR payload means changingTicket::$qrCodeData, which the gate scanner and the emergency print share. - Moving the stub or the organiser block: they are absolutely positioned neighbours, so keep
stub.left + stub.width <= .org.left— the row/seat line is the widest in the stub and used to print into the organiser text on stadiums with long row identifiers.TicketPdfTestasserts the geometry. - Check the result with
tests/E9/F6/TicketPdfTest.php(one page, headers, content) and by rendering the PDF of a local ticket through the support endpoint.
Consequences¶
- Positive: no new infrastructure; deterministic, offline rendering in ~0.4 s per ticket; the map on the ticket is always the one admins maintain; the design is data-driven and reviewable in a PR.
- Negative: every request re-renders — about 0.3 s of CPU and ~520 KB out, of which ~330 KB is the static artwork re-embedded each time. That is fine for support, but the fan endpoint opens exactly when everyone is inside the 5-hour window before kick-off; if that becomes a load problem the answer is a short-lived cache keyed on ticket + holder + seat, not a redesign.
- Negative: dompdf's CSS subset constrains the design (no gradients, no flex) — the header is a flat band with a red rule instead of the sample's gradient; a future redesign that needs modern CSS means switching the renderer to headless Chromium (Gotenberg), which the architecture allows.
- Follow-ups (separate tickets): association logos / per-match header colour, real sponsor management, attaching the PDF to the order confirmation e-mail (EmailService already supports attachments), Petrol / box-office printing of the same PDF.