Andres Apolinar
Back to systems
ProductArchitect2026

Xirai Motors — Turning paper into an audited state machine

End-to-end management for auto repair shops where every step a vehicle takes leaves an audit row. 44,725 lines, 72 models and 585 test methods to replace a notebook.

  • Laravel 13
  • PHP 8.3
  • Pest 4
  • MySQL
  • Sanctum
  • Docker

Sign in to the system(opens in a new tab)

Leer este caso en español

Constraints

  • A real repair shop runs on paper. Traceability cannot depend on someone remembering.
  • The technician controls their own clock. If they can pause it without approval, billable time stops being evidence.
  • Multi-tenant per company and multi-branch per branch: data isolation is a quota, not a detail.
  • Module changes were closed with zero new migrations, as a self-imposed and verifiable restriction.

The problem

The problem

A real auto repair shop runs on paper. The vehicle arrives with no photographic evidence, the quotation has no traceability, times are not measured, technicians have no measurable performance, and spare parts live in someone’s memory.

The system turns that passage through the shop into an audited nine-stage state machine:

draft → active → in_diagnostics → in_quotation → requesting_parts
      → in_repair → in_quality_control → ready_for_delivery → delivered

Every transition writes its row. Current state is never stored: it is derived. And when someone asks why a vehicle spent six days in one step, the answer is in the database, not in the advisor’s memory.

What makes this domain hard

It is not CRUD with states. There are three real tensions:

The clock. Time is money, and whoever controls it is usually the one billing for it. If the technician can pause their own stopwatch without consequence, billable time stops being evidence and becomes a matter of trust. The answer is four-eyes control: the pause is requested and another role approves or rejects it, with both actions audited.

The owner of the service. A team lead needs to reassign work —an advisor gets sick, another is overloaded, a customer belongs to someone else— and at the same time needs to evaluate real load per person. Both require history. That is why reassigning closes the previous assignment instead of deleting it: the service changes hands without losing who it belonged to.

The quota. The system is multi-company and each company pays for storage. Counting the quota correctly is a filtered COUNT… if every media table carries the company as a column. If it does not, it is a jump across four relations on every upload. The denormalised column is the price of keeping the hottest route in the system cheap, and it is documented as a decision with its accepted cost, not as a schema accident.

Documenting real status, not desired status

There is a detail I find the most honest thing in the whole project.

The repository has a file that declares itself the source of truth about what is implemented, and warns in its header that each module’s README may be marked complete even when the code does not exist. In a later change, the changelog corrected in the uncomfortable direction: a module reported as incomplete was in fact finished, and progress was recalculated upwards.

That is rare to see. The normal thing is for documented status to inflate; here it is instrumented so that it does not.

About the team

I did not write this system line by line on my own. I defined the data model, the domain rules and the order of the modules; my team implemented the modules on top of that design, and the work went through review with pull requests against working branches.

The numbers —44,725 lines, 72 models, 585 tests, 31 of 33 modules closed— describe the magnitude of what was delivered, not a sum of commits. Mine was the design and the core; theirs was the execution under that design.

And one more thing: the project has a single interface in the entire domain, and it belongs to the external vehicle-catalogue provider. That is not accidental. Abstraction was applied exactly where it was needed to test without depending on a third party, and nowhere else out of reflex. A domain with fifteen layers and one interface belongs to someone who understood what they are for.

Decisions

  1. 01

    Service state is an append-only log, not a column that moves

    Every transition of the vehicle writes a row with its entry and exit marks; an active stage is the one with a null exit. Current state is a query, not a stored value. The full history —who moved what and when— is available with no triggers, no parallel audit table, and no chance of the state drifting away from its history.

    RejectedA `current_stage` column that gets overwritten. Cheaper to read, and it means that the day someone asks "why did this car take six days", the answer no longer exists.

  2. 02

    Reassignment closes the previous assignment, never deletes it

    Moving a service between advisors does not remove the previous row: it closes it with an end mark. The service can change hands without destroying who had it, which is exactly what a team lead needs in order to evaluate real workload.

  3. 03

    The media quota is counted on a denormalised column, and the reason is written down

    Every media table carries `company_id` even though it is derivable through relations. Counting a company's quota would mean jumping through services, quotations, repairs and quality control into each of the five media tables. Denormalising turns a check that runs on every upload into an indexed count per table. The accepted cost is keeping the column consistent.

    RejectedThe transitive join — correct in theory and expensive on the hottest route in the system.

  4. 04

    Pausing the clock requires somebody else's signature

    A technician's time is paused through a request that another role must approve or reject, and both actions are audited with their own tests. Four- eyes control applied to the clock, because whoever controls their own stopwatch does not produce evidence.

  5. 05

    Bonuses are a domain with their own actions, not a calculation buried inside something else

    Bonus weeks, bonus records and bonus authorisations are separate entities, with dedicated actions and their own test battery —including the team lead's bonus calculation, which is money paid to one person to evaluate others. Money rules do not hide inside a controller method.

  6. 06

    The profile is what authenticates, and the User record sits behind it

    There are six distinct profiles —admin, supervisor, advisors lead, workshop lead, advisor and technician— and the middleware swaps the `User` for the active profile, so the controller receives the profile and reaches the real user by relation. The effect is that access policies speak the language of the role. The trap —believing you hold a `User` when you hold an advisor— is written into the project documentation precisely because it bites.

  7. 07

    Project status is documented against reality, not against optimism

    The repository has a status file that declares itself the source of truth about implementation, with an explicit warning that each module's README may be marked complete even when the code does not exist. The changelog corrects in the uncomfortable direction: a module marked incomplete was in fact finished, and progress was recalculated upwards without dressing it up.

    RejectedTrusting each module's README. The README is written by whoever wants to close the ticket.

Outcome

  • 44,725 lines of PHP across 360 files, 72 models and 140 migrations.
  • 75 test files with 585 methods, on Pest with the architecture plugin.
  • 31 of 33 modules closed, with a reference change delivered in 3 PRs, 54 files and 39 tests with no regressions.
  • A full module change shipped without opening a single new migration, by self-imposed restriction.
  • 15 application layers with a single interface in the entire domain, and that interface belongs to the external provider.