One of my backend projects is a multi-tenant SaaS API built with Laravel and deployed with Docker. The goal: multiple clients on one deployment, each with isolated data, without running a separate server per customer.
The source is on GitHub. This post explains the architecture choices — not Laravel basics, but the decisions that make multi-tenancy work in production.
Why multi-tenant?
Separate deployments per client don’t scale operationally. You want one codebase, one pipeline, shared monitoring — but hard boundaries so Client A never sees Client B’s data. For a B2B SaaS dashboard and API, that boundary is non-negotiable.
Tenant isolation strategy
I used a shared database, shared schema model with a tenant_id column on tenant-scoped tables. It’s the right trade-off for this project’s scale:
- Cheaper and simpler to operate than database-per-tenant
- Easier migrations across all tenants at once
- Still secure when every query is scoped through middleware
How tenancy is resolved
- Request arrives with subdomain, header (
X-Tenant-ID), or JWT claim - Tenant middleware resolves the tenant record and binds it to the request context
- Global Eloquent scope automatically applies
where tenant_id = ?on scoped models - Jobs and queued workers re-hydrate tenant context before handling tasks
Cross-tenant queries without the scope are a common bug class — we added tests that assert isolation on every major resource endpoint.
Laravel structure
- Tenant model + middleware — single entry point for resolution
- Traits on models —
BelongsToTenantfor automatic scoping - Policies — authorization checks tenant ownership before update/delete
- API resources — consistent JSON shapes per client-facing contract
- Migrations — all tenant tables include indexed
tenant_id
Docker setup
Containerization keeps dev and production aligned. The stack:
- PHP-FPM — Laravel application container
- Nginx — reverse proxy, static assets, rate limiting
- MySQL — primary datastore (PostgreSQL works too; this project used MySQL)
- Redis — cache, sessions, queue backend
docker-compose wires services for local dev. Production uses the same images with environment-specific .env files — no “works on my machine” surprises.
Deployment lessons
- Run
php artisan config:cacheandroute:cachein the image build or entrypoint - Queue workers as separate containers — don’t run workers inside the web container
- Health check endpoint for orchestrators (
/healthreturning DB + Redis status)
Security checklist
- Never trust client-supplied
tenant_idin request body — derive from auth token only - Encrypt sensitive tenant config at rest
- Per-tenant rate limits to prevent noisy-neighbor abuse
- Audit log table for admin actions (who changed what, when)
When to choose a different model
If you have enterprise clients demanding physical isolation, or strict compliance requiring separate databases, move to database-per-tenant or schema-per-tenant. The Laravel tenancy packages ecosystem supports switching strategies — but start simple unless regulation forces complexity.