Skip to content
SaaS architecture · Laravel

Tenant isolation in multi-tenant Laravel SaaS

Tenant scoping lessons from Shoprocket and Basecomp: resolving store or operator context on HTTP, queue jobs and payment callbacks before anything writes to the database.

Indesh Prinja 10 min read

Based on production work on Shoprocket . This is engineering perspective, not product documentation.

Tenant boundary
Editorial diagram of tenant isolation flow: entry, tenant scope, query and write steps with icons
Every entry point has to resolve the tenant before the database hears about the problem.

Most multi-tenant bugs do not look like Hollywood data breaches. They look like one order updated for the wrong store, inventory deducted from the wrong catalog, or a refund attached to someone else's payment record because one code path never resolved tenant context.

I have shipped tenant boundaries on Shoprocket and Basecomp, two Laravel codebases with very different domains but the same rule at the database layer: figure out which tenant owns the work, then run the mutation.

Scoping queries is not enough

Adding where store_id = ? to Eloquent queries helps until the request is a queue job with no HTTP session, or a PayPal callback that only includes a provider reference. Tenant identity has to be resolved at the boundary of every entry point, not assumed because the last controller did the right thing.

For HTTP, middleware can bind the current store or operator. For jobs, the dispatching code has to pass tenant ID explicitly, and workers should fail closed when it is missing instead of inferring from whatever happens to be in the payload.

Dashboard routes are the easy path

Operator context
Basecomp administrative dashboard showing revenue, visitor and order metrics for competition operators
Each operator sees their own competitions and revenue. The backend has to enforce that boundary even when the UI feels like a single admin product.

Merchant and operator dashboards make tenant context feel obvious because the user logged into one account. The bugs I remember came from background work: nightly reports, import jobs, webhook replays, one-off artisan commands someone ran in production because staging looked fine.

Payment callbacks rarely include a tidy tenant ID

Webhooks usually arrive with gateway identifiers. You look up the payment, then the store or operator, then you load the business record. That lookup belongs at the top of the handler. Half a service method in is half an incident waiting to happen, especially when money is involved.

If you want the gory details on callback handling, I covered that separately in Handling payment webhooks in production SaaS.

Admin tools need their own rules

Platform support legitimately spans tenants. Abuse review, billing oversight and global metrics are real requirements. They still need explicit authorization and separate code paths. Turning off scoping because someone is an admin is how cross-tenant writes slip in unnoticed.

What we actually changed in the codebase

Both platforms shared queues, workers and MySQL. Isolation became a habit across layers:

  • tenant ID on service methods that write data, not just on controllers
  • global scopes used carefully so they do not hide legitimate cross-tenant admin reads
  • tests on job and webhook paths, not only happy-path HTTP tests
  • logs that include tenant identity when state changes

None of that is novel Laravel advice. It is the unglamorous work that keeps a shared app from cross-wiring customer data when the product gets busy.