A rent-a-car back-office that ran for over ten years on PHP 5.6, rebuilt as a modern multi-tenant SaaS - carrying a decade of bookings, contracts and documents across without asking a single user to reset their password. Then taken well past where the old system could go.
FlexiRent wasn't broken. It ran a real rent-a-car business every day for a decade. It was just standing on a stack the world had moved off - and that's a harder problem than a rewrite of something nobody uses.
The usual approach is to read the old code, guess at the business rules, and ship something that looks better but quietly gets the details wrong. The operator notices in week two, when a contract prints the wrong deposit.
A decade of production behaviour is not in the codebase. It's in the edge cases, the workarounds and the numbers people check every morning. That's what a rewrite has to preserve first.
FlexiRent wasn't inherited from a stranger. LumiVerse built it, then supported it in production for over ten years with a real fleet operator using it daily.
That's ten years of watching how the work actually happens: which screens get opened first thing in the morning, which fields are always left blank, what the operator does when a van comes back damaged on a Sunday. None of that is written down anywhere. It's the reason the rebuild could skip the discovery phase and go straight to building.
The rule that made this safe: change the technology or change the product, never both at once. The new system had to be provably identical before it was allowed to be better.
A complete read-only backup of production: 2,855 files, the full database, the web server config. The live server was never modified. Every later decision could be checked against a frozen, verified copy.
PHP 5.6, Apache and MySQL packaged into Docker so the legacy system ran on a laptop. Not to keep it alive - to have a reference implementation to compare against, screen by screen, number by number.
Every table mapped to a target entity, every implicit relationship documented, every risk listed before a line of new code: the JSON blobs, the invalid zero-dates, the CSV permissions, the PDF generator.
A faithful replica on the new stack - same screens, same numbers, no new ideas - tagged and frozen as the rollback point. Improvements were not allowed until this version matched the old one.
MySQL to PostgreSQL: JSON blobs normalized, real foreign keys added, invalid dates converted. Then automated cross-database integrity checks compared row counts and records against the source until they matched.
A written runbook with a rollback at every phase: freeze the old app, re-migrate fresh data, verify against pre-freeze numbers, then switch. Rehearsable in full without touching production.
The legacy system stored passwords as bcrypt hashes. Bcrypt is bcrypt - the new backend verifies those same hashes directly. So every user kept the exact username and password they already had. No reset emails, no support calls, no "we've migrated, here are your new credentials" message. The old system's file uploads were carried over the same way: same documents, same links, still downloadable.
Same business rules. Entirely different foundation.
A faithful replica was the safety requirement, not the goal. Once the data was proven and the old system still sat there as a rollback, the new foundation started earning its keep - with capabilities a PHP 5.6 stack was never going to carry.
Most fleet software reports revenue. Rentalica reports what's left. Every cost is entered once with a period - one-off, monthly or annual - then spread across the months it actually covers and charged to the vehicle that incurred it. Leasing, liability and comprehensive insurance, registration, tyres, servicing and fuel all land on the right van.
Photograph an invoice or a fuel slip. The scan is read on-site, then the total, date, vendor, supplier tax ID, invoice number and registration plate are pulled out, the cost category is guessed and the vehicle matched. Croatian decimal formatting included. Nothing saves until a person confirms it.
Ask which passenger van does the fewest kilometres a month, in plain Croatian, and get three things back: the answer, the table it came from, and the exact query that produced it. Nothing is hidden behind a chat bubble - the operator can check the work before trusting it.
Driving licences, ID cards, passports and vehicle registrations are classified and read by a self-hosted OCR service, so the scans stay on your own infrastructure. Machine-readable zones are parsed with their ICAO check digits - a clean read validates itself.
Every upload lands in one place and is searchable by what's written inside it, because the extracted text is stored next to the file. A link table records what each document is attached to - so you can go from a document back to its vehicle, cost or contract, and find the ones attached to nothing.
One directory for customers, companies and partners, segmented with groups and tags. Each contact opens a full profile: every rental, every contract, what they still owe, plus linked tasks and documents. Licence numbers, categories and expiry dates sit on the record itself.
To-do lists per person and shared across the team, each task pinned to the vehicle, booking or customer it concerns, with an owner, a priority and a due date. Registration, insurance and inspection expiries raise their own reminders without anyone remembering to look.
An assistant that writes queries against a live business is only as trustworthy as the thing standing between it and the data. These limits are enforced in code and in PostgreSQL - not politely requested in a prompt.
Every generated statement is parsed into a syntax tree and each table it touches is enumerated - including the ones buried in subqueries and comma joins. Anything outside the approved set is rejected before it reaches the database. Staff accounts and password hashes are permanently out of reach.
Before execution, every referenced table is rewritten as a filtered view of that tenant's rows, so an unqualified query resolves to the right data by construction rather than by good behaviour. Common table expressions are refused outright - one could otherwise redefine that filter.
Queries run inside a read-only transaction with a statement timeout and a hard row cap, and that transaction is always rolled back. A write isn't declined by the model's better judgement - it cannot happen on that connection.
If the software running your business is old, undocumented and too important to break, that's the problem we solve. Parity first, your data proven intact, a cutover you can roll back.