Get in touch

From the workshop

WorkOrders

The email comes in, and the work order writes itself.

Trades and facilities firms run on email: a customer describes a fault, somebody retypes it into a job. This reads the message and everything attached to it, writes the job, works out how urgent it is, puts it in front of the whole crew on their phones, and answers the customer back. Multi-tenant Laravel with native iOS and Android field apps, built to replace a system that has been running for thirteen years.

In beta Web iOS Android
Laravel Livewire SwiftUI Kotlin Email intake Urgency triage Stripe
The build story

The build story

Thirteen years of jobs, and nobody dared touch it.

A plumbing firm gets an email. Something is leaking, here is the address, can somebody come. Then a person reads it, works out how bad it sounds, retypes it into a job, decides who is nearest, and writes back. Multiply that by every trade, every property manager and every facilities team in the country and you have an enormous amount of skilled people doing data entry.

There is already a system that does this. It has been running one plumbing company since 2013, it holds 104,854 work orders, and it is PHP 7 on Zend Framework 1 on an AWS Lightsail box. It works. That is the problem: it works well enough that replacing it is all risk and no applause, so for thirteen years nobody did.

I tried once, in 2025, and got a prototype: a pricing page, some Livewire screens, thirty-four commits, and then nothing for a year. On the 28th of July 2026 I deleted it. The commit is called “Wipe parked prototype and scaffold fresh Laravel 13 skeleton”, and starting again was cheaper than reading what I had written the first time. Since that morning: 402 commits in six weeks, 34 models, 85 migrations, and 3,874 tests across 274 files.

Every other decision depends on this one

The old system serves one company. The new one is a product, so the first thing built was tenancy, and the rule is that a mistake here is not a bug, it is somebody reading another firm’s customer list.

Rows are scoped by company_id through a global query scope, which is the ordinary answer. The two useful parts are the ones that ordinary does not cover. First, each company answers on its own host, yourfirm.workorders.nz rather than a shared address, because a session cookie belongs to the host that set it: somebody who works for two firms then holds two independent sessions in two tabs instead of one account to be switched between. Every route declares which hosts it may answer on and a middleware holds it to that.

Second, and this is the part that took the longest to see: the scope does not apply when nobody is signed in. That is deliberate, because console commands and importers have to work across tenants. The consequence is that every unauthenticated surface has to name its company explicitly, from a credential, or it reads every tenant at once and silently narrows to the wrong one when a staff user of some other firm happens to be signed in in the same browser. The customer portal is that surface. So is inbound email, which arrives at a catch-all address with no company and no user, and where exactly one thing is allowed to decide whose it is: an exact, case-insensitive match on the company’s own intake address. No domain matching, no prefix matching, no falling back to the only company in the database. Anything unmatched is filed unattributed and goes no further.

None of which survives the first careless model added six months from now, so an architecture test walks every model in the codebase and fails the build unless it is either scoped or on an exemption list with a written reason beside it.

The email comes in, and nobody types anything

One call reads the message and its attachments and returns the whole job: a one-sentence summary of the work required, the thread as clean text and as markdown, an urgency, and the customer and site address if the email actually stated them. The schema is enforced by the provider, and the instructions are blunt about the failure mode that matters: answer only from what the email says, return null rather than filling a field in, and never infer an address from a signature block.

The legacy system made three separate calls per email, each with its own prompt, its own failure mode and its own bill, and one of them was never finished. Collapsing them into one is cheaper, and it is atomic, so you can no longer end up with a summary and no body. In front of it sits a content-addressed cache keyed on provider, model and prompt, which is the best idea the old system had: maintenance email repeats itself, the same photo gets forwarded three times, and the same building reports the same fault every winter. The import brought 28,645 answers across that had already been paid for once, and they replay for nothing.

The rest of the guardrails are unglamorous and all of them exist because of a specific afternoon. There is a master switch, and off means no provider is ever called and the panels are simply hidden, so the app behaves exactly as it does when a vendor is down. The model is per-feature, so a pass over every inbound email uses a cheap fast one while a person asking a question about a job can justify better. Enrichment runs on its own queue, because a backlog of it must never delay the mail. Video description gets a longer budget than a photo and a bounded wait on the far end, since a job killed mid-upload has paid for the upload and bought nothing. And no provider is named anywhere in application code: the owner sets a key and a name in the environment, and Gemini, Anthropic or OpenAI all work.

On the marketing side of it all, the promise is a sentence long: the email comes in, and the work order writes itself.

The bug I kept on purpose

In the old mobile API, marking a job ongoing and marking it completed share a code path. So telling the system “parts are ordered, back Thursday” stamps a completion time on the job and files your progress note in the completed note, overwriting whatever was there. It has done this for years.

The office’s CSV export reads those columns. The spreadsheets built on it, the reporting, and thirteen years of history all assume that shape. Fixing it would have been one line and would have broken the one screen she uses every day, so the rebuild reproduces it exactly, with a test pinning the behaviour and a docblock explaining it to whoever tries to fix it next. What the rebuild adds is the history the overwrite destroys: a proper append-only event carrying the real progress note, so nothing is lost even though the column still lies.

This is most of what a migration actually is. Every status string, all five of them, is frozen because roughly seventy-five phones in the field run a fixed binary that speaks exactly those five words, with no forced-update path, and nobody is reinstalling them on cutover night.

The login that let anyone be the boss

The field app signs in with an organisation code: one string per company, shared by everybody, read down the phone, no expiry, and never rotated because rotating it locks out the whole crew at once. Carrying that into the new API was the obvious call, since it meant the app could be repointed without touching its login screen.

It was also a privilege escalation, and I did not see it for weeks. The new login matched an existing user by an email address the caller supplied. The same API hands any technician the full staff list, including the owner’s email. So the shared code plus an address anyone on the crew could read returned a bearer token as the owner, and overwrote his name and phone number on the way through. Provisioning was capped at technician. Matching was capped at nothing.

The replacement is a one-time code issued to one named person from the Team screen, and the rule it is built around is that an access code never lets the caller choose who they are. The code either names the person or creates the single person it was issued for; the name and email on the request are used only in that second case and ignored entirely otherwise. Deactivating somebody now deletes their tokens and any unredeemed code, which it previously did not, so switching someone off used to leave the phone in their pocket reading the board indefinitely.

There is a related thing the sign-in screen still gets wrong and I am leaving alone on purpose. The green tick on the code field is a presence check: it means the box is not empty, not that the code is real. The obvious fix is to ask the server on Continue, and an unauthenticated endpoint that says whether a code is good is a free oracle for brute-forcing an eight-character string. So the API deliberately has no such route, and the tick gets deleted rather than made honest.

One Stripe account, eight products, and every webhook gets everything

A Stripe webhook endpoint is not scoped to the app that created it. Every enabled endpoint on an account receives every event of the types it subscribes to, from every product on that account. WorkOrders bills on an account shared with about seven other things I have built, and four endpoints on it subscribe to subscription events, this one among them. Other products’ events have been arriving here for as long as both have existed.

This is Stripe working as designed and there is no setting to change it. I checked rather than assumed: the v1 endpoint API rejects a metadata filter outright, and the v2 one filters by account, which is the Connect answer to a different question. Cashier has its own guard and it is not enough, because it checks the customer and one account means one customer namespace, so a customer sitting in our own table can carry somebody else’s subscription and walk straight through the door.

So the guard lives in the application, as one predicate applied in one place, in front of everything. Our own marker on the object settles it. Failing that, a subscription is ours if we already hold its id, or if the customer is ours and a line item is priced at something in our own catalogue; an invoice is ours only if it names a subscription we know. Anything else gets a 200 and is dropped, because a non-2xx makes Stripe retry an event you deliberately ignored, for ever.

It is worth being specific about the cost of getting this wrong, because a sibling app of mine already has. A five dollar donation on one site made a completely different site book a sale and send a push notification naming one of its real users, on the strength of a generic id in the metadata. On a free game that is embarrassing. Here the same fault sits on the cancel path and takes a paying company’s access away.

What is actually done, and what is not

The import runs green end to end against a real production snapshot: 1 company, 17 users, 3,289 customers, 104,854 work orders and 62,159 attachments, with that same CSV export coming out byte-identical over all 104,854 records once whitespace is trimmed. A separate sweep decoded 114,441 serializations through the iOS app’s own models looking for a field the phone would choke on, and found none. Running the import twice reports nothing inserted and nothing updated, which is the property you want on the night.

And yet: nobody is on it yet. The old system is still live, still taking email, still serving seventeen phones, entirely untouched. There is no dual-write and no synchronisation, deliberately. There is one DNS change, made by hand, on an evening agreed with a plumbing company, and until somebody makes it the rebuild sits alongside the old one, deployed and validated and serving nobody. That is not a gap in the plan. On a system that is somebody’s whole business, a single reversible moment beats a clever gradual migration that can be half-broken for a week.

Meanwhile the product side of it runs ahead of the migration: self-serve signup, a twenty day trial, plans and refunds, a customer portal, a public API a facilities platform can raise jobs into, and native iOS and Android apps for the crew. The next real test is not a feature. It is the first customer who is not the one the system was written for.

Running your business on software nobody dares replace? Here’s how we could work together.

Visit WorkOrders