Skip to content

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

  1. Render in the backend with dompdf (dompdf/dompdf 3.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 to resources/pdf, images are embedded as data URIs. The template is plain HTML, so a later move to a headless-browser renderer only swaps TicketPdfRenderer; the view model (TicketPdfData) and the template stay.
  2. 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.
  3. 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's qr_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 no qr_code_data yet. 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.
  4. The stadium map is real, not a dummy PNG: StadiumMapSvgRenderer draws 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.
  5. 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.
  6. Access: GET /tickets/{id}/pdf for the buyer or holder, gated exactly like the QR code (visibility tier full, 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}/pdf for support agents at any time, with the emergency print's protective guards (valid status, match not over, holder not blacklisted), audit logged as ticket_pdf_download and 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 in TicketPdfDataFactory, print it in the template.
  • New art: drop the file into resources/pdf/ (see its README) — the factory embeds it.
  • Per-match theming: TicketPdfData::$themeColor is the single hook; today it is the HNS navy for all.
  • Barcodes / QR: BarcodeImageFactory and QrImageFactory produce data URIs; changing the QR payload means changing Ticket::$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. TicketPdfTest asserts 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.