Skip to content
Back to Writing

Database-per-Tenant on Laravel 12: How Zenpresa Isolates Every Business and Ships Modules as Composer Packages

Byte Smith · · 12 min read

The first architectural decision on Zenpresa was the one we rejected. The obvious way to build a multi-tenant SaaS on Laravel is a tenant_id column on every table, a global scope that adds WHERE tenant_id = ? to every query, and a prayer that nobody ever writes a query that bypasses the scope. Most platforms are built that way. Most of them have, at some point, shown one customer another customer’s data.

We chose to give every business its own database instead. This post is about why, what that costs, how the module system fits on top of it, and the one bug class that survives even when isolation is done right.

Why one database per tenant

Three reasons, in order of how often they come up.

Blast radius. A bug in a shared-schema system is a cross-tenant bug by default. A forgotten scope, a raw query, a join that pulls the wrong side, an admin tool with a wildcard: each of these leaks data between customers. In a database-per-tenant system those same bugs return the wrong rows within one business. Bad, but recoverable, and never a breach notification.

Per-customer backup and restore. When a business calls and says “we deleted a year of invoices by accident”, the answer in a shared schema is a careful surgical restore from a snapshot of a database that also contains every other customer. With one database per business, the answer is restoring one database. Export on departure is the same story: a dump of one database, not a filtered extract with a legal review.

No forgotten-scope bug class. This is the one we care about most. In a shared schema, tenant isolation is a property you have to re-establish in every query for the lifetime of the codebase. In a database-per-tenant system it is a property of the connection. Once the request is bound to a tenant, every Eloquent model, every raw statement, and every queued job that inherits that context is isolated by construction. New engineers cannot get it wrong, because there is nothing to get right.

We use the stancl/tenancy package on Laravel 12 with Postgres, and it does the heavy lifting of creating a database on signup, swapping the connection per request, and running migrations across every tenant.

Central database vs tenant databases

There are two kinds of database in the system.

The central database holds what exists above any single business: the tenant registry, the domains that map to each tenant, subscription and billing state for the platform itself, platform-level users and roles, the catalogue of modules, and the audit trail of who provisioned what. If a table describes the relationship between a business and the platform, it lives here.

Each tenant database holds everything the business owns: customers, appointments, services, staff, invoices, payments, website pages, branding, email templates, the assistant’s conversation history, and so on. If a table would be exported when the business leaves, it lives here.

The migration set is split the same way. Central migrations run once. Tenant migrations run once per tenant database, and a deploy that adds a column to invoices runs that migration across every business in sequence. The split is enforced by directory, so a migration cannot accidentally end up on the wrong side.

Resolving the tenant per request

Every tenant has at least one domain: a subdomain on our platform by default, and a custom domain once they connect one. The request pipeline looks up the host, finds the tenant, and switches the default database connection before any application code runs. Controllers, models, and services never mention the tenant. They query the “database” and the database is the right one.

The subtle part is everything that does not run inside a request. Queued jobs, scheduled commands, event listeners fired after the response, and the AI assistant’s tool loop all need to know which tenant they belong to. The tenancy package serialises the tenant identifier onto every queued job and re-initialises the connection when the job runs, so a “send invoice reminder” job dispatched inside tenant A’s request executes against tenant A’s database an hour later. For scheduled work that spans tenants, a command iterates the registry and initialises each tenant in turn.

The rule we enforce in code review is simple: if it touches tenant data and it is not in a request, it must be inside a tenant context, and the test must prove it.

Modules as standalone Composer packages

Zenpresa sells features à la carte, so the codebase is organised the same way. Each module — scheduling, invoicing, payments, customer hub, marketing, website builder, AI assistant, and several smaller ones — is a standalone Composer package living in a modules/ directory, wired in with a path repository. Each has its own composer.json, its own namespace, and its own service provider that Laravel auto-discovers.

The application itself is still a single deployable: one Laravel process, one container image, one deploy. This is a modular monolith, not microservices. We get the organisational benefits of modules (clear ownership, clear boundaries, the ability to lift a module out later) without the operational cost of distributed systems.

Practical benefits we did not expect:

  • PHPStan and the test suite run per module. A change to invoicing runs the invoicing tests first. Cross-module tests exist, but they are a separate, slower tier.
  • Dependency direction is visible. A module’s composer.json declares what it depends on. If marketing needs customers, that is a declared dependency. If customers started needing marketing, the cycle would show up in the manifest, not six months later in a debugging session.
  • Extraction is a real option. We have not extracted a module into a separate service, and we may never need to. But the boundaries are already drawn, which is worth more than the extraction itself.

What a module owns

A module owns everything about its feature:

  • Routes for its API endpoints, registered by its service provider under the module’s prefix.
  • Tenant migrations for its tables, discovered from the module’s own migrations directory.
  • Models, services, and jobs in the module’s namespace.
  • Frontend pages in the corresponding React directory, lazy-loaded so a tenant without the module never downloads its code.
  • AI tools that the assistant can call, each tagged with the module key so they only appear when the module is enabled.
  • A feature key that ties it all together.

Enabling a module for a tenant is a subscription flag. Middleware checks the flag on every request to a module route and returns a clear “module not enabled” response rather than a 404. The frontend reads the same flags to decide which navigation items and pages to show. The AI tool registry reads them to decide which tools the model can see. One flag, three enforcement points, all reading from the same source of truth.

Trade-offs we accepted

Database-per-tenant is not free, and it would be dishonest to present it as such.

N-database migrations. A deploy that changes a tenant table runs the migration against every tenant. With a modest number of tenants this takes seconds. With many thousands it takes real time and needs to be resilient to a failure halfway through. We run tenant migrations as a queued, resumable job that records progress per tenant, and we design migrations to be additive so a partially-migrated fleet is still functional.

Cross-tenant reporting is harder. “How many appointments were booked across the platform last month?” is a one-liner in a shared schema and a fan-out in ours. We push platform-level aggregates into the central database on a schedule rather than querying tenants live. The trade is worth it: reporting is a scheduled concern, isolation is an every-request concern.

Connection management. Postgres connections are not free, and a single process serving many tenants in one worker lifetime needs to reuse connections carefully. The tenancy package handles the switch; we had to tune pool sizes and make sure long-running workers do not leak a connection per tenant they touch.

Local development is heavier. A fresh dev environment has to provision at least one tenant database before anything works. We wrapped that in a single command, but it is still more than migrate --seed.

The bug class that survives

Isolation by connection removes the forgotten-scope bug from tenant tables. It does not remove it from central tables that are tenant-scoped.

Some data is legitimately central but belongs to a specific tenant: the tenant’s domains, its subscription, its platform-level API keys, the audit log of its provisioning. Those rows live in the central database with a tenant identifier column, and any query against them from inside a tenant context still needs the predicate. The tenancy package cannot help here because the central connection is, by design, not tenant-bound.

This is the exact shape of bug that a single-tenant test fixture cannot catch. A test that provisions one tenant, queries the central domains table, and asserts the result contains the tenant’s domain will pass whether or not the predicate is present, because there is only one tenant’s data to return. The test must provision two tenants and assert the second one’s rows do not appear. We now have a base test case that does exactly that for every central table with a tenant column, and a review checklist item that asks “is this central data tenant-scoped, and does the test prove the other tenant is excluded?”

The lesson generalises beyond our stack: whatever your isolation boundary is, find the data that lives outside it, and test that data with two tenants, never one.

Where this pays off later

Two decisions that follow directly from the architecture have paid for themselves several times over.

The first is the AI assistant. Because the connection is tenant-bound, every tool the assistant calls is isolated by construction. The tool does not filter by tenant; it cannot reach another tenant’s database at all. That let us focus the assistant’s security work on the problems that actually remain, which the next posts in this series cover.

The second is cost control. Per-tenant metering of the assistant’s usage, per-tenant rate limits, and per-tenant caps are all natural when the tenant is a first-class runtime concept rather than a column. The patterns from our earlier post on LLM API rate limiting and cost control slot in without adaptation.

If you are building a platform where customers’ data must never touch, and you can afford the operational overhead, we would make the same choice again. If you are building a consumer app with millions of tiny accounts, you probably should not. The right answer depends on what a leak would cost, and for the businesses Zenpresa serves, the answer is: everything.

Next in the series: Building an Agentic Assistant in Laravel with the Anthropic PHP SDK: Tools, Model Lanes, Prompt Caching, and Per-Tenant Metering

FAQ: database-per-tenant on Laravel

Is database-per-tenant slower than a shared schema?

Per-request, no. Once the connection is switched, queries hit a smaller database with a smaller working set, which is usually faster than a shared table with a tenant predicate on every index. The costs are elsewhere: migrations run once per tenant, cross-tenant reporting needs a fan-out or a scheduled aggregate, and connection pooling needs care in long-running workers.

How do queued jobs know which tenant they belong to?

The tenancy layer serialises the tenant identifier onto every job dispatched from within a tenant context and re-initialises the tenant, including the database connection, when the job runs. The rule we enforce is that any code touching tenant data outside a request must run inside an explicit tenant context, and its test must prove it.

What is a modular monolith and why not microservices?

A modular monolith is one deployable application whose code is organised into strictly bounded modules, in our case standalone Composer packages with their own routes, migrations, models, and service providers. We get clear ownership and dependency direction without distributed-system overhead. Extraction into separate services remains possible because the boundaries already exist.

Does database-per-tenant eliminate cross-tenant data leaks?

For data in tenant databases, yes, by construction. For central tables that carry a tenant identifier — domains, subscriptions, platform API keys — the predicate is still required and the tenancy package cannot enforce it. Test those tables with two tenants and assert the second tenant’s rows are excluded; a single-tenant fixture will pass whether the filter exists or not.

How do you handle a migration failing partway across thousands of tenant databases?

Tenant migrations run as a queued, resumable job that records progress per tenant, so a failure resumes from the tenant that failed rather than restarting. Migrations are designed to be additive so that a partially-migrated fleet still serves requests correctly while the job catches up.