Portfolio Dev Log Contact
Laravel Docker SaaS

Building a Multi-Tenant SaaS Backend with Laravel & Docker

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

  1. Request arrives with subdomain, header (X-Tenant-ID), or JWT claim
  2. Tenant middleware resolves the tenant record and binds it to the request context
  3. Global Eloquent scope automatically applies where tenant_id = ? on scoped models
  4. 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 modelsBelongsToTenant for 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:cache and route:cache in the image build or entrypoint
  • Queue workers as separate containers — don’t run workers inside the web container
  • Health check endpoint for orchestrators (/health returning DB + Redis status)

Security checklist

  • Never trust client-supplied tenant_id in 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.

Related project

View on GitHub View in portfolio