DigiSpace
  • by admin

Laravel Multi-Tenant SaaS Architecture: Domain-Based Tenancy and PostgreSQL Isolation

Laravel Multi-Tenant SaaS Architecture: Domain-Based Tenancy and PostgreSQL Isolation

Multi-tenancy is easy to describe and difficult to keep safe. One application serves several organisations, each organisation expects its own users and data, and the platform owner still needs a central place for billing, support and administration. A domain lookup is only the first step. The real work is keeping tenant context correct across HTTP requests, authentication, queues, events and background services.

The business value is controlled growth: one product can serve many clinics while their operational data stays separated, onboarding follows a repeatable path, and the team can change the platform without creating a different codebase for every customer.

Start with the boundary, not the table column

VetSpace is a veterinary practice platform with a central owner portal and a tenant-specific clinic application. A clinic receives its own domain, staff work inside that clinic context, and pet owners use a shared central surface. That is a different problem from adding tenant_id to a few tables. The application has to decide which context a request belongs to before it reads the data behind that request.

The project uses Laravel 13, PostgreSQL and stancl/tenancy. Tenant domains are represented by a dedicated Domain model, while Tenant uses the package's database and domain concerns. The tenant model can therefore carry both the business identity and the connection details needed to initialise the correct context.

One application, two API contexts

VetSpace keeps central and tenant routes in the same Laravel application, but they do different jobs. The central API owns accounts, pets, orders, the clinic catalogue and subscription information. Tenant routes handle branches, services, doctors, rooms, appointments, visits and clinic reviews.

This separation is visible in the route files rather than hidden in a large controller. Tenant routes are loaded by TenancyServiceProvider, and tenant_api.php applies domain initialisation before the request reaches a tenant controller. The result is a straightforward rule: a central endpoint reads central data; a clinic endpoint runs inside the clinic's database context.

VetSpace multi-tenant architecture: central API and tenant API resolved by domain

Choosing isolation per plan

The platform does not treat every tenant as identical. The architecture supports a PostgreSQL schema or database for a tenant, with the plan deciding which level of isolation is appropriate. A shared database with separate schemas keeps smaller tenants operationally manageable. A dedicated database gives an enterprise tenant a stronger boundary and a simpler answer to questions about backup, restore and data residency.

This choice has a cost. More isolation means more connection management, migrations and operational work. Less isolation reduces that overhead but makes application-level scoping and restore procedures more important. The useful decision is the one that matches the product's support, compliance and recovery requirements; “database per tenant” is not automatically the right answer for every customer.

Compare the isolation options before choosing one

Common Laravel designs fall into three groups: shared tables with a tenant key, separate PostgreSQL schemas, and separate databases. Shared tables are simple to operate but every query boundary must be correct. Schemas add a database-enforced namespace while keeping one PostgreSQL installation. Separate databases improve isolation and tenant-specific recovery, but they increase connection, migration and monitoring work. The official Stancl tenancy documentation treats domain identification and database management as separate concerns for exactly this reason.

In VetSpace, the plan-aware choice is part of the product model. It lets the platform avoid paying enterprise isolation costs for every small clinic while keeping a stronger option available when the business case requires it. That is a product decision expressed through infrastructure, not a package default.

Middleware order is part of the security model

In a tenant API, authentication cannot be treated as an unrelated first step. The tenant has to be identified before the application knows which database should resolve a tenant-bound user or token. The project makes that dependency explicit by putting InitializeTenancyByDomain into the middleware priority from TenancyServiceProvider.

protected function makeTenancyMiddlewareHighestPriority(): void
{
    $tenancyMiddleware = [
        Middleware\PreventAccessFromCentralDomains::class,
        Middleware\InitializeTenancyByDomain::class,
        Middleware\InitializeTenancyBySubdomain::class,
    ];

    foreach (array_reverse($tenancyMiddleware) as $middleware) {
        $this->app[Kernel::class]
            ->prependToMiddlewarePriority($middleware);
    }
}

The route declaration explains why this matters: even if auth:sanctum appears before the tenancy middleware in the array, Laravel's priority list makes domain initialisation run first. This is the kind of detail that deserves a test and a comment because a future developer can otherwise “simplify” the route and change the data boundary by accident.

Provisioning a tenant is a lifecycle, not an insert

Creating a tenant starts a pipeline. The project creates the database, runs tenant migrations, seeds the tenant data, creates the main branch and replicates the owner. Deleting a tenant runs the corresponding database cleanup. Keeping these steps together makes onboarding repeatable and gives the platform one place to reason about partial failure.

JobPipeline::make([
    Jobs\CreateDatabase::class,
    Jobs\MigrateDatabase::class,
    Jobs\SeedDatabase::class,
    CreateMainBranch::class,
    ReplicateOwnerToTenant::class,
])->send(fn (Events\TenantCreated $event) => $event->tenant)
  ->shouldBeQueued(false);

The pipeline is deliberately synchronous at this boundary. Tenant creation should not report success while the clinic is still missing its tables or its first branch. Other work can be queued after the tenant is in a valid state.

Queued work must carry tenant context

Background jobs are where multi-tenancy becomes operationally real. RunAiAnalysis stores the tenant ID and analysis ID, looks up the tenant in the central context, initialises tenancy, performs the analysis, and ends the tenant context in a finally block. That last step matters when a long-running worker processes more than one tenant during its lifetime.

try {
    tenancy()->initialize($tenant);
    $analysis = AiAnalysis::find($this->analysisId);
    $service->analyze($analysis);
} finally {
    if (tenancy()->initialized) {
        tenancy()->end();
    }
}

The same principle appears in jobs that replicate users, create a clinic's main branch and synchronise VetCard data. A queue payload that contains only a record ID is not enough when that ID is meaningful only inside one tenant database.

Central records still need a second boundary

Not every record belongs exclusively to one clinic. Owners and pets live in a central space and can be linked to several clinics through pivot tables. VetSpace uses a ScopesToClinic trait to constrain those reads and writes to the authenticated user's clinic IDs, optionally including a branch. This protects against an insecure direct object reference even when the model itself is central.

That distinction is easy to miss: tenant database isolation protects tenant-local tables, while relationship scoping protects shared records. Both boundaries are required for a platform where an owner can visit more than one clinic.

What this architecture buys the business

The benefit is not the number of Laravel packages in composer.json. It is the ability to add a clinic without copying the application, to give different plans an appropriate isolation level, and to keep central billing and tenant operations connected without mixing their data. It also makes the operational risks visible: tenant migrations, queue context, domain routing, backup strategy and cross-clinic access all become explicit design work.

Where this approach is too expensive

A small internal tool with one organisation does not need domain-based tenancy, tenant database managers and a provisioning pipeline. A simple membership product may be better served by one database with carefully enforced row scoping. Multi-tenancy earns its complexity when several organisations share a product and the cost of data leakage, duplicated deployments or manual onboarding is higher than the infrastructure overhead.

For the service context behind this architecture, see Laravel Development Services.

When the boundary is real, design it early. Share the domains, data-isolation requirement and expected onboarding flow, and we can map the smallest architecture that keeps those boundaries dependable.

Share this post