Appearance
Documents
The paper a shop hands a customer: the estimate, the work order and the invoice are one PDF, rendered from a work order through one of three layouts the shop picks in its settings. Each inspection sold on the work order follows the invoice in the same PDF, on its own page.
Everything below is shop-side. The client portal links to the same download route (pdf.workOrders), but nothing in routes/webClient.php chooses a layout.
The one rule
Only product and storage lines carry money. A service line's sub_total is a roll-up of its children (WorkOrderLine::recalculateTotals()), so summing every line double-counts. CalculateWorkOrderTotal sums products()->notCanceled() plus storageTickets()->notCanceled(), and RenderWorkOrderPdf::viewData() reads that same set — anything else and the printed table stops matching the printed total.
The templates follow from this: a service renders as a heading over its indented parts and labour, never as a row with its own amount beside theirs. row() classifies with $line->isService(), not by whether children happen to exist — a service with no products attached is still a heading.
The same rule is recorded in .ai/rules/work-orders.md for anyone editing the totals.
The three layouts
| Case | View | Margin | Shape |
|---|---|---|---|
compact | resources/views/invoices/compact.blade.php | 0.55 in | Inverse header block, three-panel info grid, tight bordered rows |
airy | resources/views/invoices/airy.blade.php | 0.7 in | Logo left / title right, vehicle card, zebra rows, highlighted balance |
clean | resources/views/invoices/clean.blade.php | 0.8 in | Large document number, borderless rows, four-column totals strip |
App\Entities\InvoiceTemplate holds both mappings — view() and margin(). The shop's choice lives in shops.invoice_template, a string defaulting to airy; a shop that has never chosen one therefore gets airy without a backfill.
The three files are presentation only. Every amount they render is computed in RenderWorkOrderPdf::viewData(), which is what makes it possible to change one layout without re-deriving the money in three places. Header identifiers are read straight off $workOrder — order_number, created_at, and client_po_number, the customer's own PO number, printed as a "PO number" row only when set.
How a work order becomes a PDF
app/Actions/Printing/RenderWorkOrderPdf.php is the single entry point, shared by both callers:
| Caller | Path |
|---|---|
DownloadInvoiceController | GET /pdf/workOrders/{workOrder} (routes/web.php:94), gated by authorize('view', $workOrder); a client user additionally needs the work order's shop to have client_portal (Plans & modules) |
RenderPrintJob | queued, saves to the print disk, then tells the agent to fetch it — see Vroum Connector |
A single inspection prints on its own through RenderInspectionPdf (shop.workOrders.inspection.pdf, DownloadInspectionController) — see Inspections › Printing. It reuses RenderWorkOrderPdf::heading(), number() and vehicle(), which are public and static for that reason: change them and both documents change.
handle() does five things, in order:
- Eager-loads the shop (with its media), client, contact, car, the line tree, and the inspections with everything their reports read.
- Sets the locale — see Language.
- Resolves the template from the shop, falling back to
airy. - Builds the
spatie/laravel-pdfbuilder:format('letter'), the footer view, and the margins. It also passesinspectionPageHeight, the page height left once the template's margins and the footer band are taken (RenderInspectionPdf::printableHeight()). - Points Browsershot at the
chromiumcontainer whenbrowser-shot.host_ipis set.
viewData() returns everything the templates read:
| Key | What it is |
|---|---|
heading | translated Invoice / Work order / Estimate, chosen by which number the work order has |
number | invoice_number ?? order_number ?? estimate_number |
rows | the line table, flattened — see Rows |
taxableSubTotal / exemptSubTotal | split of the billed lines by taxable |
taxes | the work order's tax snapshot, with zero-amount rows dropped |
vehicle | year/make/model/trim joined, null when there is no car |
logo | the shop logo as a data URI, or null |
inspections | one BuildInspectionReport array per inspection, in line order; an inspection whose line skips() drops is left out |
money / quantity | locale-aware formatting closures |
Rows
rows() walks the root lines in order, then each line's children, and returns a flat array. Every entry carries a type:
type | Rendered as | Source |
|---|---|---|
note | one cell spanning all seven columns | a line with no related and a description |
group | a heading with technician and appointment, no amounts | $line->isService() |
item | a full row: part number, technician, qty, unit price, tax flag, amount | everything else |
Child rows carry indented => true. skips() drops two kinds of line before they reach the array: one whose service_status is cancelled (ServiceStatus::isCancelled(), the same set the notCanceled scope uses), and a note with a blank description. Skipping a cancelled parent skips its children with it.
Language
The document prints in the customer's language, not the operator's. handle() sets app()->setLocale($workOrder->contact?->locale ?? config('app.locale')) before building the view.
Two things make this less obvious than it looks:
- It is set in the action rather than at the call sites because
spatie/laravel-pdfrenders the view lazily, afterhandle()has returned. Setting it in a controller would be undone by the time the HTML is produced. - A queue worker outlives the job it is running, so
RenderPrintJobcaptures the worker's locale before rendering and restores it in afinally. Without that, one shop's French invoice leaves the next job on that worker in French.
contact_user_id is filled by SyncClientCarContactWorkOrder from the car owner, falling back to the client's primary user, so most work orders have a contact without anyone setting one.
The strings live in lang/fr.json and are reached with __() from the Blade templates — not the vue-i18n catalogs under resources/js/Locales/, which never reach a PDF.
The footer
The footer is Chrome's, not an element in the document. handle() passes invoices/partials/footer.blade.php to ->footerView() and reserves margin() + FOOTER_HEIGHT (0.3 in) at the bottom, so Chrome draws it in that band and content can never reach it.
The earlier implementation was a position: fixed div in the body. Chrome repeats those on every page but does not reflow content around them, so it printed over the last rows of every page except the last.
Consequences worth knowing before editing that partial:
- Chrome renders it as its own document: no stylesheet, no web font, and
font-size: 0by default. Every rule has to be inline, and the design-system font stack is not available to it. class="pageNumber"andclass="totalPages"are filled in by Chrome.- Page size and margins come from
->format('letter')and->margins(). A CSS@pagerule is ignored unlesspreferCSSPageSizeis set, which is whypartials/styles.blade.phpdeclares neither.
Styling
invoices/partials/styles.blade.php declares the design tokens inline and the templates reference them with var(--token). This is a deliberate exception to the theme rule: the headless Chromium that renders the PDF cannot load the compiled stylesheet, so Tailwind classes would produce a blank-styled document. .claude/rules/theme.md records the exemption alongside the one for mail templates.
inspections/partials/styles.blade.php follows the same exception for the status colours of the appended inspection reports; the three layouts include it after their own styles.
tr.group { break-after: avoid } keeps a service heading from being stranded at the foot of a page, separated from the parts billed under it.
The shop logo
Shop implements HasMedia with a single logo collection (Shop::LOGO_COLLECTION, registered singleFile()), uploaded from the general settings page. A shop without one prints no logo — there is no house fallback.
Shop::logoDataUri() inlines it into the HTML as a data: URI rather than a URL, because the headless browser runs in its own container and cannot reach this application's storage. It returns null when the media row exists but the file does not, so a lost file degrades to "no logo" instead of throwing mid-render.
Validation lives in UpdateGeneralSettings::rules(): image, 2 MB, and a 2000×2000 pixel bound. The dimension bound is not cosmetic — the file is base64-inlined into every invoice and decoded by Chromium on each render, including queued prints, so a small file at absurd dimensions is still an expensive document.
Settings surface
GET /{shop}/settings/general (SettingsController::general()) exposes shop.invoice_template and shop.logo alongside the rest of the general settings. PUT goes to UpdateGeneralSettings, which handles the whole form in one request: the logo arrives as a file on the same payload, and remove_logo clears it.
Because the collection is singleFile(), a new upload replaces the previous logo and only an explicit removal clears it — saving the form without touching the logo keeps it.
The Vue side is resources/js/Pages/Shop/Settings/General.vue; onLogoChange resets remove_logo to false, which is what keeps a remove-then-pick from sending both flags and clearing the logo it just stored.
Gotchas
- A work order created at
OPENor beyond gets anorder_numberautomatically (AddOrderNumberToWorkOrder), soheading()reportsWork order, notEstimate. Building an estimate fixture means creating it withWorkOrderStatus::ESTIMATE. - A line's
totalis recalculated on save fromprice × quantityplus core-charge and environmental fees, so settingtotaldirectly in a factory does not survive. - Amounts print with a bare
$, notCA$: there is no shop currency column, and an ISO-prefixed code reads as a foreign currency in Quebec.money()overrides the currency symbol on anintlNumberFormatterand lets the locale decide grouping, decimals and which side the symbol sits on. work_order_lines.parent_work_order_line_idis indexed as of2026_09_18_135901. PostgreSQL does not index a foreign key on its own, and loading a line's children sequentially scanned the table.
Files
| Concern | Files |
|---|---|
| Rendering | app/Actions/Printing/RenderWorkOrderPdf.php, app/Actions/Printing/RenderInspectionPdf.php |
| Layouts | resources/views/invoices/{compact,airy,clean}.blade.php, resources/views/invoices/partials/{styles,footer}.blade.php, resources/views/inspections/** |
| Template choice | app/Entities/InvoiceTemplate.php, database/migrations/2026_09_17_191528_add_invoice_template_to_shops_table.php |
| Logo | app/Models/Shop.php (LOGO_COLLECTION, registerMediaCollections(), logoDataUri()) |
| Settings | app/Actions/Settings/UpdateGeneralSettings.php, app/Http/Controllers/Shop/SettingsController.php, resources/js/Pages/Shop/Settings/General.vue |
| Entry points | app/Http/Controllers/DownloadInvoiceController.php, app/Actions/Printing/RenderPrintJob.php |
| Translations | lang/fr.json |
| Tests | tests/Feature/WorkOrder/InvoiceTemplateTest.php, tests/Feature/WorkOrder/InspectionReportTest.php, tests/Feature/WorkOrder/PrintWorkOrderTest.php, tests/Feature/Settings/GeneralSettingsTest.php, resources/js/Pages/Shop/Settings/__tests__/General.spec.ts |