Back to blog
laravel blade architecture pdf api billing

Three modules in a day, and a Blade directive that silently refused to compile

The Starting Point

Three modules to land: quotes (built Thursday, merging today), contracts, and then billing — which is the one that matters most architecturally, because a whole family of provider modules will plug into it later.

Plus a walkthrough on each as it went in, which is now just how this works.

What Actually Happened

Quotes merged, and two small things from looking at it

Products moved under Quotes in the sidebar — the only place they're used. First module to use the navigation's child items, which the sidebar has been able to render for weeks and nothing had asked for.

The document preview needed its own download button. The preview opens in a tab of its own, so the download button back on the quote page is a tab away — useless at the moment you're looking at the thing you want to download.

The line tree itself was ported exactly from the source — items, calculated groups, uplifts on pure or total cost that never feed each other — and tested for the first time. The source has no tests over any of that arithmetic.

Contracts, and a seam so quotes never learns the name

Contracts does a lot to quotes: a "convert to contract" button, freezing the accepted version once a contract exists, and refusing to delete a quote that has one.

Every one of those goes through a seam quotes exposes, so quotes never learns contracts exists. Core gained one thing to make the last one work — a delete guard a record type can register, honoured by the generic record delete, with the reason shown on the page rather than a bare refusal.

Terms are either an active Markdown template or an uploaded file, never both. That sounds fussy and isn't: "which one is actually in force" has to have exactly one answer.

Billing, public API first

This is the one I spent the most thought on, because step 7 of the roadmap is a family of provider modules that all plug into this.

So: the contract first, before the features. A bill-source contract, a registry modules declare into rather than a container tag, a general envelope with no industry identifier anywhere in it, a provider-owned document that billing stores and renders but never reads inside, an apportionment across accounts and sites, and an ingest that's idempotent on (source, reference).

The test that keeps it honest: its tests are written against a provider billing has never heard of. If the contract only works for the provider I ported from, it isn't a contract.

There's a token endpoint taking the same payload too, so a provider need not be a PHP module in this deployment at all.

Credit notes turned out to be a nice simplification: a credit note is a kind of bill, allocated against an invoice exactly as a payment is. Reconciliation then has no special case at all.

Deliberately not carried: the visual designer (its third appearance in this port — I've now dropped it three times), two industry-specific export formats which belong to the provider, the legacy transactions path, and customer bank details.

The bug of the day

syntax error, unexpected identifier "num", expecting "]"

A Blade directive glued directly to a word character is not compiled. It sits there as literal text and then the surrounding PHP blows up on it.

It only surfaced on one path — the contract document rendered with quote data — so everything else looked fine. Both places rewritten, and contracts gained a test for it, because "it compiles everywhere except this one combination of data" is not something you want to rediscover at a client's cutover.

Also: the demo seeder's template lookup went through the business-unit scope, so a second run made a second template rather than finding the first. Idempotent seeders stop being idempotent the moment a global scope sits between you and the row you're looking for.

And the first look at billing found the obvious thing

The bills were there and the document was not — reachable only from a work page one level in. It is the entire point of a bill.

"Document" is an action on the record now, as it already was for quotes and contracts. Whose work-page actions got renamed for what they actually do while I was in there.

Same shape as every gate walkthrough so far: the thing is built, and it's one page further in than anyone would look.

What Worked

A prompt that writes itself

This is my favourite thing I've built in a while.

A deployment edits the document its customers receive. Which means somebody has to write a template — and the source gave them nothing at all to do that with.

Three things now exist:

A guide — "Writing a template" — explaining the two things you actually write (a value, and a repeated block) with worked examples, and then every path the chosen bill can print, generated from a real bill. Generated rather than written, so it cannot drift from the code. And so that the paths under a provider's own document are named at all — no hand-written page could know what an undocumented provider sends.

Download gives you the template's own HTML, because a template is work, and work you cannot get out of a text box is work you can lose.

And the prompt. Download it, attach a PDF of the bill you post today, hand both to whatever AI you use, paste the answer back in.

The source had the idea — a "Copy AI Prompt" button in its designer. This is the version that cannot go stale: the rules, every printable path for the chosen bill, and that bill as JSON, all generated on the spot.

It states the constraints the renderer imposes — self-contained, no fetched fonts or images, A4, repeating table headers — because those are exactly the things a model gets wrong by default. And asked for with an existing template, it carries that template's body, so the answer improves what's there instead of replacing it.

The screen says plainly that the file contains a real customer's bill. Which it does, and people should know that before they upload it somewhere.

What I Learned

  • A Blade directive immediately followed by a word character doesn't compile. No warning. It becomes literal text and something downstream fails with a nonsense syntax error. Give it whitespace.
  • A global scope will break an "idempotent" seeder. Your firstOrCreate can't find the row it made last time if a scope is hiding it.
  • Write the integration contract before the features it's for, and test it against a consumer that doesn't exist yet. If the tests only pass for the thing you ported from, you've written an implementation with a contract-shaped name.
  • A credit note is a bill. Looking for the shape something already is beats inventing a second one — reconciliation lost an entire special case.
  • Generate documentation from the data, not from your understanding of the data. The path list can't drift, and it can describe a payload nobody has documented.
  • If you're going to suggest people hand data to an AI, say what's in the file. It's a real customer's bill.
  • The thing people came for should be on the record page, not one click further in. Sixth or seventh time I've written some version of that sentence in these notes.

Tech Notes

  • Stack: PHP 8.4, Laravel, MySQL (Docker), headless-Chromium document renderer, Sanctum for the token API
  • Billing: source contract + registry (declared, not container-tagged), ingest idempotent on (source, reference), provider document stored and rendered but never parsed, token endpoint accepting the same payload
  • Contracts → quotes goes entirely through seams quotes exposes; core gained a record-type delete guard that surfaces its reason on the page
  • Templates keep the source's placeholder syntax deliberately, so an existing client template migrates at cutover
  • Generated-on-demand: the path list, the sample JSON, and the AI prompt — none of it hand-maintained
# the bug, roughly
@directiveword   → never compiled, becomes literal text
@directive word  → fine
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.