Technical Article
SaaS Platform Architecture: The Decisions That Come First
Seven decisions a multi-tenant platform has to make before features, in the order they bind each other, with what each one cost on four platforms I run.
SaaS platform architecture is mostly a question of order. Before features, a multi-tenant platform has to decide how tenants share storage, how a request finds its tenant, what happens when that tenant is missing, how background work keeps it, and only then who inside a tenant may see what. Get the order right and later features sit on solid ground. Get it wrong and every feature inherits the gap. This guide walks through those decisions in the order they bind each other, using four platforms I run or have built.
Four platforms, two designed and two inherited
Everything below comes from the code of four platforms:
- Omnitech CRM, a multi-tenant CRM I architected from scratch in 2026. The code was written by an autonomous build agent working to my architecture and reviewed by me (how that works).
- This website’s platform: one Laravel codebase that serves mmuzammilq.com and GrowBizNest as separate tenants.
- IMFlow360, the backend behind a salon and spa POS with customer displays, the IMBeautyGlow web and mobile apps, a mini-POS in development, and planned retail and restaurant POS apps. It started from a purchased commercial script, bought with its source code, that I have extended into a multi-sector platform.
- DinDin, a restaurant platform built on the same commercial base.
Two of these were designed by me from day one. Two were inherited and extended. That mix is the useful part: it shows what each early decision costs when you get to make it, and when you don’t.
The decision stack
There are seven decisions, and they come in this order. Each layer assumes the one above it: permissions are meaningless if isolation can be bypassed, and per-tenant behaviour is unsafe if background work loses the tenant.
Tenancy model: shared database, tenant column
All four platforms use the same model: one shared database, with a tenant identifier on every tenant-owned row. Omnitech and this site’s platform use tenant_id. IMFlow360 scopes by business_id with branch_id below it; DinDin by restaurant_id and branch.
The other common models are a schema per tenant or a database per tenant; AWS’s whitepaper on SaaS tenant isolation, listed in the sources below, compares them well. I have not run either in production, so I won’t pretend to compare them from experience. What I can say is why the shared model fit these four: every tenant runs the same schema and the same release, tenants are small to mid-sized businesses, and cross-tenant reporting for the platform operator is a normal need.
The cost of the shared model is that isolation becomes an application responsibility. Nothing in the database stops one tenant’s query from reading another’s rows. Everything below exists to pay that cost.
Three schema rules follow from it:
$table->foreignId('tenant_id')->constrained();
// Unique within a tenant, not across the platform.
$table->unique(['tenant_id', 'pipeline_id', 'name']);
// The tenant column leads, because every query filters by it.
$table->index(['tenant_id', 'module']);- Every tenant-owned table carries the tenant column, set on create and never changed afterwards. This site’s platform has a test that a row cannot be moved to another tenant.
- Uniqueness is per tenant, not global. Omnitech has a test that the same email address can exist in two tenants.
- Indexes and unique keys start with the tenant column, because almost every query filters by it first.
Tenant resolution: never fall back
A request has to find its tenant before anything else runs.
- Omnitech resolves the tenant from the subdomain. A default tenant exists only for local development; in production a missing subdomain is an error, “rather than a silent fallback into a real workspace”, as its configuration puts it.
- This site’s platform resolves from the host name. An unknown host returns 404 and never falls back to another tenant; a suspended tenant returns 503; a non-primary domain redirects to the tenant’s primary domain, so search engines see one canonical host per tenant.
The rule in both: an unresolved tenant is an error, never a guess. There is no default tenant. A request is either the platform’s super admin, working deliberately across tenants, or a tenant admin or user inside exactly one tenant. A convenient default tenant is an easy way for a shared database to leak, because the code looks correct in development, where there is only one tenant.
Isolation enforcement: fail closed or fail open
This is the decision with the largest consequences, and it is where my four platforms split in two.
Fail closed (Omnitech, this site’s platform). Every tenant-owned model gets a global scope that adds the tenant filter. If no tenant is bound, the scope throws instead of running the query. Creating a row without a tenant also throws. Code that genuinely needs every tenant has to call an escape hatch with a deliberately loud name (withoutTenancy() in Omnitech, acrossTenants() here), which is easy to spot in review. Omnitech’s configuration calls strict mode “the single most important safety net in a shared-database design”.
Fail open (IMFlow360, DinDin). The inherited base also uses global scopes, but when no user is signed in or no business is resolved, the scope adds no filter at all and the query returns every tenant’s rows. It is a deliberate choice in that codebase: the operator console and the installer run without a tenant and need to see everything. The comment above the scope says the fail-open behaviour “is unchanged and must stay”. DinDin’s equivalent warns that every migration touching tenant data must remember to remove the scope by hand.
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = app(TenantContext::class)->id();
if ($tenantId === null) {
throw TenantNotResolved::forQuery($model::class);
}
$builder->where($model->qualifyColumn('tenant_id'), $tenantId);
}
}Both work while everyone is careful. The difference is what happens when someone isn’t. With fail-closed scoping, a forgotten tenant produces an exception in development or in the error log. With fail-open scoping, it produces a correct-looking page full of other tenants’ data.
If I were starting IMFlow360 again, I would make its scopes fail closed from the first day. Adding strictness to a codebase that was built to be lenient is far harder than starting strict, because every console screen, report and import that relies on the open path has to be found and given an explicit opt-out first.
The deeper comparison of the two approaches is in Multi-tenant isolation: two working answers.
Background work: the tenant has to travel
A queued job runs outside the request that created it, so it has no host name to resolve a tenant from. Omnitech attaches the tenant id to every job payload when the job is queued, restores the tenant (and the tenant’s permission context) before the job runs, and clears it afterwards. This site’s platform has a test that a job runs under the tenant that dispatched it and that the request’s tenant is restored afterwards.
Queue::createPayloadUsing(fn (): array => array_filter([
'tenant_id' => $context->id(),
]));
Queue::before(function (JobProcessing $event) use ($context): void {
$tenantId = $event->job->payload()['tenant_id'] ?? null;
if ($tenantId !== null && $tenant = Tenant::find($tenantId)) {
$context->set($tenant);
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
}
});
Queue::after(fn () => $context->forget());Under fail-closed scoping, a job that lost its tenant throws, which is correct but not useful; propagating the tenant is what makes strict mode workable. Under fail-open scoping, the same job silently runs across all tenants.
The same rule applies beyond the database. In Omnitech, file storage paths, cache keys and log context all read the tenant from the one context set at the start of the request, so a cached value or an uploaded file can’t be served to the wrong tenant.
Permissions and record scope: two layers above the tenant
Once a request is safely inside one tenant, two separate questions remain: what is this user allowed to do, and which of the tenant’s records may they see? Omnitech keeps them apart. Permissions come from a central registry of modules and actions, plus named high-risk capabilities such as revealing stored credentials or deleting permanently. Record visibility is a separate setting per role and module, with six levels from “assigned only” to “all records”, applied after the tenant filter: it decides whose rows within the tenant, never which tenant.
That separation came directly from migrating off Perfex CRM, where visibility was baked into permission names (view, view_own, minimal_access), so granting edit rights could quietly widen what someone could read.
The full design, with the scope levels, the registry and the tests, is in Multi-tenant access control.
Per-tenant behaviour: one platform, several businesses and apps
The shared model pays off when one codebase has to serve very different customers. IMFlow360 is the clearest case I run:
- One tenant boundary, four business types. Each business has a
business_type(retail, restaurant, beauty and salon or spa, appointment services) that switches catalogue, order and workflow behaviour, instead of a fork per industry. The cloud already does this switching; parts of it are still being completed for all four sectors. - Five business modules per tenant. Each tenant gets CRM, HR and payroll, accounting, eCommerce and inventory, and project management from the same backend.
- Several front-ends on one backend. The salon and spa POS (Flutter, with built-in and separate customer displays), the IMBeautyGlow website (Next.js) and mobile app (React Native) all talk to the same API. A Flutter mini-POS is in development and retail and restaurant POS apps are planned.
The rule I apply: differentiate with data and modules, not with forks. A new business type or app should be configuration and a client, not a copy of the backend. IMFlow360 has never been forked: every sector and every app runs on the same backend.
Data changes: migrations and imports
Tenancy also shapes how data moves. Omnitech’s import from Perfex CRM runs inside a transaction, is idempotent (keyed on the legacy id) and never writes to the legacy database; it prints source-versus-destination counts and a list of records that need a human decision (the full story).
On the inherited base, schema migrations have to remove the tenant scope explicitly, which is one more place the fail-open design asks people to remember.
A one-page decision record
Before building, write one line per layer. Copy this table into your design document and replace the middle column with your decision; the last column is the test that has to exist before the decision counts as made.
| Layer | Decide | What fails if it is wrong | Test that proves it |
|---|---|---|---|
| 1. Tenancy model | Shared database, schema or database per tenant | Isolation, cost or reporting needs you can’t meet later | Same unique value allowed in two tenants |
| 2. Resolution | Host, subdomain, header or token; what an unknown one returns | Requests land in a default tenant | Unknown host returns 404, never another tenant |
| 3. Isolation | Fail closed or fail open; the name of the escape hatch | Silent cross-tenant reads | Query and create without a tenant both throw |
| 4. Background work | How jobs, caches, files and logs carry the tenant | Jobs run as nobody, or as everybody | A job runs under the tenant that dispatched it |
| 5. Permissions + scope | Where permissions are registered; how record scope is set | Visibility leaks through edit rights | List query and record policy agree for each scope |
| 6. Per-tenant behaviour | What varies by data or module, and what never forks | A fork per customer or sector | Each business type passes the same core suite |
| 7. Data changes | How migrations and imports stay inside a tenant | An import writes across tenants | Rerunning an import changes nothing |
Where to go next
- If your platform already exists and you are not sure which side of the fail-open line it is on, the Platform Change-Readiness Scorecard scores tenant isolation, permissions and thirteen other factors in about ten minutes.
- If you want an outside review, the Platform & Integration Review produces a tenancy and security risk register and a prioritised backlog in two to three weeks.
References
Evidence and further reading
All insights →Facing a similar problem?
Platform & Integration Review
For SaaS founders and engineering leads whose platform is getting slower and riskier to change.
Request an assessmentLet's build what's next
Have a complex system that needs to be built right?
Whether you are starting from an idea, replacing an existing platform, or scaling a system, let's talk.
Better Technology.
Brighter Possibilities.

