← All work

Writing / Multi-tenancy

3 min read

Multi-tenant Laravel: the request is the easy part

Why the tenant boundary has to survive jobs, schedulers and payment callbacks

Most multi-tenant Laravel write-ups stop at middleware: resolve the tenant, scope the queries, done. That is the easy part. On HisabKarLay — a SaaS POS running 1,200+ merchants on one Laravel stack — the rule is that the tenant boundary has to hold in web requests, queued jobs, schedulers and payment callbacks, or it doesn’t hold at all. A merchant’s data leaking into another merchant’s report is not a bug report; it is the end of trust in the product.

My contribution
Author — from production work at HisabKarLay
Scope
Tenant context in jobs, schedulers and callbacks
Stack
Laravel / Queues / JazzCash / Multi-tenancy

01 /Queued jobs forget who they belong to

A job dispatched during a request carries whatever you put in its payload — nothing more. If the job assumes “the current tenant” exists when it runs, it will either crash or, worse, run against the wrong tenant’s data.

What works is boring on purpose. The tenant ID goes into the job payload explicitly, as a plain value — not as a model that gets re-fetched into a different context. At the start of handle(), the job resolves and sets the tenant context the same way middleware does for requests: one shared resolver for both paths, so the rules cannot drift apart. And it fails closed — if the tenant cannot be resolved, the job fails loudly instead of falling back to a default tenant or an unscoped query. A failed job is retryable; a cross-tenant write is not.

Reports, invoice generation and gateway sync all run this way: heavy work off the request path, tenant context rebuilt inside the job.

02 /Schedulers have no tenant at all

A scheduled command runs for every tenant or for none. The mistake is writing the command as if there were one database’s worth of data.

The pattern that survives: the scheduler iterates tenants explicitly and runs the per-tenant work as separate, tenant-scoped jobs. Each unit of work is small, attributable to one merchant, and can fail without poisoning the rest of the run. If one tenant’s data is broken, one job fails — not the whole night’s processing.

03 /Payment callbacks arrive with no session

A JazzCash callback does not know about your login session, your middleware assumptions, or which merchant’s checkout started the flow — it knows what the gateway sends back. On a shared stack with per-tenant credentials, the callback has to be matched to the right tenant from the transaction reference itself, then verified against that tenant’s credentials before any order state changes.

Two rules from running this in production. Never trust the callback payload to set state directly: match the reference, load the order in that tenant’s scope, verify, then update. And make the handler idempotent — gateways retry, and a callback processed twice must not create two payments.

04 /Testing the boundary, not the happy path

Isolation tests matter more than feature tests here. The ones worth keeping: tenant A cannot read tenant B’s records by ID; a job dispatched under tenant A writes only to A; a callback carrying tenant B’s reference cannot mutate tenant A’s order. If any of these fail, nothing else about the feature matters.

05 /The short version

Middleware is 10% of multi-tenancy. The other 90% is discipline in the places without requests: carry the tenant ID explicitly, resolve it the same way everywhere, fail closed, and test the boundary like an attacker. One codebase serving many businesses only works if every background path respects the same walls as the web path.

The takeaway

Middleware is 10% of multi-tenancy. The rest is discipline in the places without requests.

Discuss the implementation
Next case studyHisabKarLay: one POS, many tenants