A clinic platform needs to answer two questions before it reads a patient record: which organization owns the request, and which records may this user access? A domain name helps answer the first question. It does not answer the second, and it does not keep a background worker in the correct database context by itself.
This article examines those boundaries in the VetSpace Laravel API. It is a source-code analysis of tenant identification, PostgreSQL storage selection, provisioning and queued analysis. It is not a claim that an independent security assessment has proved complete isolation, or that the design has achieved a measured commercial outcome.
The useful result is a repeatable organization boundary without a separate codebase for every clinic. Domain resolution chooses the clinic context, provisioning creates its storage and initial records, and explicit cleanup prevents a queued task from leaving that context active. Shared central records still require their own authorization checks.
Compare the storage models before choosing one
In a shared-table design, rows carry an organization identifier. There is one schema to migrate and cross-organization reporting is straightforward. The cost is a pervasive scoping requirement: every query, relationship, export and background task must enforce the correct organization boundary. Database policies can add protection, but they need careful privilege and connection-context design.
A schema-per-tenant design gives each organization its own tables inside one PostgreSQL database. That avoids collisions between identically named tables and supports tenant-specific migrations. It does not create a separate server or an absolute access barrier. The PostgreSQL schema documentation explicitly explains that privileges determine access across schemas and that search_path affects name resolution.
A database-per-tenant design separates connection targets and can make individual backup and restore procedures easier to define. It also increases operational work: migrations, monitoring, connection management and recovery must cover many databases. Databases on the same cluster still share infrastructure. None of these choices alone demonstrates a regulatory compliance guarantee.
What VetSpace actually implements
VetSpace uses stancl/tenancy and a Tenant model implementing TenantWithDatabase, with the package's HasDatabase and HasDomains concerns. The package supports both PostgreSQL database and schema managers, as described in its multi-database documentation. The application's custom manager decides which one to use.
The important detail is that the decision is not simply “enterprise means database, everything else means schema.” Existing storage takes precedence. This expression is excerpted from UserRolePostgreSQLManager::resolveManager(); it depends on that class's existence checks and imported manager classes, so it is not a standalone provisioning script:
$managerClass = match (true) {
$this->existsAsDatabase($name) => PostgreSQLDatabaseManager::class,
$this->existsAsSchema($name) => PostgreSQLSchemaManager::class,
$plan === 'enterprise' => PostgreSQLDatabaseManager::class,
default => PostgreSQLSchemaManager::class,
};
The manager checks PostgreSQL's database catalogue and schema metadata through the central connection. Only when neither storage form exists does the plan choose the initial layout. That preserves older tenants created with their own database even if their current plan is no longer enterprise. A plan change does not migrate their data. Any storage move needs a separately designed copy, validation and cutover procedure.
Separate the central and clinic APIs
The repository has central routes and tenant-domain routes. Central operations include shared owner and pet records and the tenant registry. Clinic-specific operations include branches, appointments and visits. These are different data responsibilities within one application, not evidence of independently deployed services.
On tenant routes, the written middleware array is not the entire execution story. TenancyServiceProvider prepends tenancy middleware to Laravel's middleware priority list. Domain initialization therefore runs before authentication even where auth:sanctum appears earlier in the route declaration. Token lookup must happen against the intended connection; rearranging the route array without checking runtime priority can produce a misleading fix.
A useful verification exercises the real tenant hostname, initializes the expected storage, and authenticates with a token created in that context. Then repeat the request against another tenant domain and assert denial. Checking only whether a domain exists does not test the authentication boundary, and checking only the happy path misses accidental access to another organization's records.
Provisioning is a workflow, not one insert
The provider registers a TenantCreated pipeline: create storage, migrate it, seed it, create the main branch and replicate the owner. The source sets shouldBeQueued(false), so this pipeline is synchronous. Describing it as asynchronous onboarding would contradict the implementation.
The order captures real dependencies: tables must exist before their seed data, and initial clinic records must exist before the clinic can be used. It also introduces partial-failure cases. If a later step fails, a registry row or storage object may already exist. A database transaction in one connection cannot be assumed to reverse every provisioning operation across databases.
For production operation, I would require a documented readiness state and recovery procedure, with tests for failures after each material step. Those are recommendations, not a claim that the inspected pipeline implements a complete compensation system. The practical business benefit comes from knowing whether a clinic is ready, rather than treating a partially completed setup as a successful customer signup.
A queued job must establish its own context
RunAiAnalysis carries two identifiers: the tenant and the analysis. It does not assume that the worker inherited a browser request. The handler looks up the tenant in the central context, returns if it is missing, initializes tenancy and then looks up the analysis. The core processing and cleanup excerpt is:
try {
tenancy()->initialize($tenant);
$analysis = AiAnalysis::find($this->analysisId);
if (! $analysis) {
return;
}
$service->analyze($analysis);
} finally {
if (tenancy()->initialized) {
tenancy()->end();
}
}
This is a method-body excerpt from the real job, not a complete class. The earlier tenant lookup and dependency injection remain necessary. The finally block runs when processing returns or throws, preventing the job from leaving tenant context active for subsequent work. That is especially important in a reused worker process.
It does not establish exactly-once processing, automatic provider recovery or an appropriate retry policy. Those depend on the queue configuration and the analysis service. A missing tenant or analysis currently causes an early return; whether that should also record an operational event is a separate product decision.
Central records need authorization too
Storage isolation only protects records stored inside the selected tenant context. VetSpace also has central owner and pet records linked to clinics. The ScopesToClinic trait constrains queries through clinic_owner and clinic_pet associations. An empty clinic list is made to match nothing rather than remove the restriction.
This distinction matters: a central record can be globally stored yet visible only through an authorized clinic relationship. A review must check which controllers use the scoping mechanism, how the authenticated clinic is chosen, and how writes are authorized. The existence of a trait is not proof that every endpoint uses it correctly.
What to verify and when not to copy this
Before adopting the design, test tenant-domain authentication, cross-clinic reads and writes, a reused worker handling two tenants, partial provisioning failure, and recovery of one tenant from a backup. Exercise both schema-backed and database-backed tenants. A test that covers only the initial plan selection would miss the important existing-storage branch.
The business effect supported by the code is structural: one application can route work to distinct clinic contexts and reuse a defined provisioning sequence. No measured reduction in incidents, support hours or onboarding time is presented here. Such claims would require operational records and a defined baseline.
For a single organization's internal application, this machinery may be unnecessary. For a small SaaS with straightforward authorization and reporting, shared tables may be easier to operate. For stronger infrastructure separation, different databases on one server may still be insufficient. Start with ownership, recovery and access requirements, then choose the simplest model that satisfies them.
For implementation scope rather than architecture detail, see Laravel development. The first useful discussion is about the data boundary and failure cases, not the number of tenants a diagram appears to support.