One platform, two trades, and three quarters of the domain never changed
A multi-tenant Laravel platform sold into two trades. Purchasing, sales, returns, expenses, tenancy and roles are one product; only the nouns on top of them change.
Who, what, and how long
E-commerce & Retail
30 weeks
Dedicated team
Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.
The hard problem
Two verticals had been quoted as two products, which meant two codebases, two release trains, and a bug fixed twice or, more often, once. The commercial pull was toward a third vertical, and the engineering reality was that it would cost as much as the first two combined.
Two codebases meant a bug was fixed twice or, in practice, once. The pharmacy fork had received a fix to purchase-return arithmetic that the restaurant fork never got, and the restaurant fork had a stock-transfer fix the pharmacy lacked. Neither team knew, because there was no mechanism by which either would have found out.
A single tenanted platform where the shared spine is the product. Purchasing, sales, returns, expenses, banks, roles and shops live once; the vertical contributes its own entities and its own vocabulary and nothing else.
How the pieces fit
- 01
Diffed the two domains honestly and found three quarters of the model layer identical
The two domain models were diffed entity by entity before any consolidation, which put roughly three quarters of the model layer in common and settled the argument.
- 02
Made tenancy the boundary: one deployment, isolated data per shop
Retrofitting tenancy meant migrating two populated databases into one, which is where most of the risk in the engagement actually sat.
- 03
Kept purchasing, sales, returns and expenses in the spine, where both trades agree
Purchasing, sales, returns, expenses and banks live in the spine, where both trades genuinely agree, instead of being duplicated with different field names.
- 04
Let each vertical add its own entities without touching the shared ones
Vertical-specific fields are extensions on the shared item, so the pharmacy gets batches and expiry and the restaurant never sees them.
- 05
Put roles and permissions in the spine so an owner, a manager and a register operator mean the same thing in both trades
Roles and permissions sit in the spine, so a manager means the same thing in both trades and a third vertical inherits the model instead of inventing one.
The system we were asked to build
The software vendor sells the same back-office to pharmacies and to restaurants. We built it once (tenancy, roles, purchasing, sales, returns and expenses) and made the trade-specific part a thin layer on top.
A software vendor selling the same back-office to pharmacies and to restaurants, quoted and built as two products by two teams. The commercial pull was toward a third vertical, and the engineering estimate for it was roughly the sum of the first two. That was the moment someone finally asked how different they really were.
Platform Architecture & Engineering
A spine, not a fork
Purchasing, sales, returns, expenses, banks, roles, shops and tenants are built once and shared by every vertical.
Purchasing, sales, returns, expenses, banks, roles, shops and tenancy are built once and shared, because they're the same in every trade, whatever the trade insists. A new vertical contributes its own entities and its own vocabulary and inherits the rest. That's the difference between a second product and a second copy of the first one that has to be maintained forever.
- Eight shared subsystems built once, inherited by every vertical
- A vertical contributes entities and vocabulary, nothing else
- No fork to maintain in parallel
The same dashboard component under a restaurant. The queue, stock and sales panels and the spine half of the rail are identical; kitchen stages, covers, sections and allergen flags are the only things that belong to the trade.
Batch and expiry where it matters
One purchase order, part received. Ordered, received and costs are the spine's purchasing columns; batch, expiry and generics come from a pharmacy extension on the shared item, and a restaurant tenant's items have no such row.
The pharmacy gets batches, generics and stocked units; the restaurant never sees them.
The pharmacy needs batch numbers, expiry dates, generic equivalents and stocked units; the restaurant would be actively worse for having them on screen. Vertical-specific fields are modeled as extensions on the shared item, so each trade sees its own vocabulary and neither pays for the other's complexity.
- Vertical fields modeled as extensions on the shared item
- Batches, expiry and generics exist only where they belong
- Neither trade pays for the other's complexity
Tenancy as the boundary
One deployment, isolated data per shop, and onboarding that isn't a release.
One deployment with data isolated per shop, scoped in the query layer instead of by convention, so a new tenant is a record and an onboarding flow. No release required. That's what made the second vertical viable at all: the cost of a new customer stopped being an engineering cost and became a support one.
- One deployment, per-shop isolation enforced in the query layer
- Onboarding a tenant is a record, not a release
- New customers cost support time, not engineering time
Provisioning the third vertical: eight of eleven modules and the whole roles model arrive inherited, three modules are new to dental, the first practice's onboarding is a list of records, and every read is scoped to its tenant in the query layer.
A partial purchase return as its own document: two of seven receipt lines, credited at the invoiced price after a price change, with its own ledger entries in September while the August receipt it returns against stays unchanged.
Returns modelled properly
Purchase and sale returns are their own entities, never a status on the original, so the ledger stays honest.
A purchase return and a sale return are their own entities with their own documents and ledger entries. That keeps the ledger honest: the original sale still happened and still reports as revenue for its period. It also means a partial return, a return at a different price, and a return after a price change all model correctly and leave the source record intact.
- Returns are their own entities with their own ledger entries
- The original transaction stays intact for its period
- Partial and price-changed returns model correctly
One permission model
A manager means the same thing in both trades, and the third vertical inherits it for free.
One permission model through Spatie, defined against the shared spine, so manager means the same thing in a pharmacy and a restaurant, and a third vertical inherits it without a new matrix being invented. Permissions are checked server-side per action, never by hiding buttons, and a vertical can add its own without redefining the ones it shares.
- One role model across every vertical
- Checked server-side per action, not by hiding controls
- Verticals add permissions without redefining shared ones
Five roles across eight permissions, the spine's banded above the pharmacy's, a key on the two actions that won't commit without a second signature, and this morning's server-side checks allowing, refusing and holding individual actions.
Phase by phase
Phase 1: The Diff
Finding Out How Different They Actually Were
Compared the two domains model by model. The spine (purchasing, sales, returns, expenses, banks, roles, shops, tenants) was identical. Only a quarter of the entities were trade-specific.
- Domain Diff
- Shared Spine Definition
- Vertical Boundary
Phase 2: Tenancy
One Deployment, Isolated Shops
Made the tenant the boundary so a shop's data is isolated without a shop's code being separate, and onboarding a new customer stops being a deployment.
- Tenant Isolation
- Onboarding Flow
- Per-Tenant Settings
Phase 3: The Spine
Built Once, For Both
Built purchasing, sales, returns and expenses as the shared core, with the double-entry between purchase and return modeled as real documents instead of a status flag.
- Purchasing & Returns
- Sales & Returns
- Expenses & Banks
Phase 4: The Verticals
A Quarter Of The Domain
Added medicines, generics, batches and stocked units for the pharmacy; foods, ingredients and covers for the restaurant. Neither vertical can reach into the other.
- Pharmacy Entities
- Restaurant Entities
- Vertical Vocabulary
Phase 5: Roles Everywhere
One Permission Model
Put roles and permissions in the spine so a manager means the same thing in both trades, and a new vertical inherits the whole access model instead of inventing one.
- Role Catalogue
- Permission Checks
- Per-Tenant Roles
The model registry from the domain diff: forty-six spine entities across eight subsystems against seventeen trade-specific ones, which is where the seventy-three percent comes from, all in one codebase.
What it carries now
73%
Domain shared across verticals
1
Codebases maintained
−70%
Cost of the next vertical
No deploy
Onboarding a shop
Domain shared is measured as the proportion of model-layer entities in the merged platform that serve both verticals. Codebases maintained is a count. Cost of the next vertical compares the third vertical's actual engineering time against the estimate produced under the fork model. Onboarding a shop is a state: it requires no deployment.
Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.
What the architecture settled
Two vertical products usually share far more than the people selling them believe. Measure it before forking.
The people selling two verticals describe them as different products because that's how they're sold. The model layer disagreed, and only a diff would have shown it.
A bug fixed in one fork and not the other is the real cost of shipping two codebases.
The real cost of a fork is the fix that lands on one side. Both teams had them, and neither had any way to discover the other's.
Tenancy is what turns onboarding a customer from a deployment into a form.
Tenancy turns a customer from a deployment into a form, which is what changes the economics of a business selling to small operators.
If returns are a status flag instead of an entity, the ledger will eventually disagree with the stock.
A return modeled as a status flag corrupts the ledger quietly: the original sale has to stay intact for its period, and a flag can't express that.
How the work was run
The engagement turned on a week spent diffing the two domains before writing anything. Three quarters of the model layer was identical, which changed the plan from two products to one platform and a thin vertical layer. It was the cheapest week of the project by a wide margin.
Thirty weeks with a dedicated team, opening with an honest diff of the two domain models before anyone assumed how much they shared. That measurement (roughly three quarters identical) is what made consolidation defensible to two teams who each believed their vertical was the special one.
One fix, followed through
Two forks each got a fix the other never did. The spine gets every fix once.
The purchase-return arithmetic fix and the stock-transfer fix, followed first through the two codebases the vendor used to ship, then through the one spine every tenant runs now, and finally into a third vertical that inherits both. Switch tabs, or use the arrow keys once one is focused.
Each trade had its own copy of purchasing, returns and stock. A fix landed in whichever fork its team happened to be working in, and there was no mechanism by which the other team would have found out.
- Two codebases, two release trains
- Purchase-return arithmetic fixed in the pharmacy fork
- Stock-transfer fix lands in the restaurant fork
- Roles and permissions built twice, once per fork
- Nothing tells either team what the other fixed
| What each trade’s tenants run | Pharmacy | Restaurant | Dental |
|---|---|---|---|
| Purchase-return arithmetic fix | Has it | Never got it | Not sold yet |
| Stock-transfer fix | Never got it | Has it | Not sold yet |
| Roles & permissions | Its own copy | Its own copy | Not sold yet |
| Batches, expiry & generics | Its own | Not on screen | Not sold yet |
| Covers, sections & allergens | Not on screen | Its own | Not sold yet |
| Patients, treatment plans & recalls | Not on screen | Not on screen | Not sold yet |
Two fixes, each landed once. Both teams had one the other lacked, and neither knew.
Why a fix now reaches everyone: the trade-specific quarter of the domain sits on top of the spine as extensions, so there is no second copy of purchasing or returns for a fix to miss. Step pacing is illustrative.
From one shop’s request to a ledger that stays honest, in any trade
A pharmacy and a restaurant take the same path through the same code. The only stage that knows which trade it is serving is the vertical extension, and it contributes entities and vocabulary, nothing else.
- 01 · SourceA shop's requestEvery shop in every trade runs on one deployment. No tenant has code of its own, so a new customer is a record, not a release.
- 02 · TenancyTenant scopePer-shop isolation is enforced in the query layer, never left to convention, so data stays with the shop it belongs to.
- 03 · AccessSpatie permission checkChecked server-side per action, not by hiding controls. Roles live in the spine, so a manager means the same thing in every trade.
- 04 · EngineSpine + vertical extensionPurchasing, sales, returns, expenses and banks are built once; a vertical adds its own entities on top and can't reach into another vertical's.
- 05 · StateMySQL ledgerPurchase and sale returns are their own entities with their own ledger entries, so the original transaction stays intact for its period.
What one shop can reach
Tenant isolation & ledger integrity
Each shop's data stays its own
Data is isolated per shop and scoped in the query layer. One deployment serves every tenant, so isolation is one code path instead of something each install has to get right.
A return never rewrites a sale
Returns are their own entities with their own documents and ledger entries. The original still reports for its period, and partial or price-changed returns model correctly.
Refused on the server, not hidden in the UI
Permissions are checked server-side per action. A vertical adds its own permissions without redefining the shared ones, so a manager means the same thing in every trade.
Selling one back office into more than one trade? Scope your build in 3 minutes.
Scope your buildNearby engagements
Data & AnalyticsA checkout that stopped losing sales to a 6-second load
Profiling found the real bottleneck behind a slow checkout (an N+1 query and an oversized bundle) and tuned both, with before-and-after metrics locked in as a baseline against future regressions.
E-commerce & Retail · 5 weeks
Web PlatformsA reader people finish, and a library that remembers where they stopped
A reading platform for a comics catalog: a browse surface people can actually navigate, a reader that gets out of the way, and a history that puts everyone back on the page they left.
E-commerce & Retail · 18 weeks
Web PlatformsOne number, a whole handset, and a database that keeps up with 125 brands
A metered IMEI lookup service that turns fifteen digits into a device, its specifications and its status. It's sold three ways to three audiences and backed by a device database that maintains itself.
Consumer Electronics · 22 weeks
Let's talk
Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.














