Production-grade multi-tenant SaaS boilerplate. Tenant isolation, plan-based feature gating, billing integration, and a full REST API scaffold — ready to build on.
Every SaaS company builds multi-tenancy from scratch and makes the same mistakes: leaky tenant scoping, global query contamination, billing wired too late, no feature gating until a customer asks.
This kit solves those problems before you write a line of product code. It's the architectural foundation I've built (and rebuilt) across multiple production SaaS products — extracted into a clean, documented, opinionated boilerplate.
If you're starting a SaaS product, clone this and ship your first feature on day one.
┌──────────────────────────────────────┐
│ Nginx (reverse proxy) │
│ tenant-a.yourapp.com → app:8080 │
│ tenant-b.yourapp.com → app:8080 │
└────────────────┬─────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Laravel Application │
│ │
│ ┌────────────────────────────────┐ │
│ │ TenantResolutionMiddleware │ │
│ │ - Reads subdomain / header │ │
│ │ - Loads tenant from cache │ │
│ │ - Scopes DB connection │ │
│ │ - Sets feature flags │ │
│ └────────────┬───────────────────┘ │
│ │ │
│ ┌────────────▼───────────────────┐ │
│ │ Global Query Scope (Eloquent) │ │
│ │ All queries auto-filtered by │ │
│ │ tenant_id — no manual where() │ │
│ └────────────┬───────────────────┘ │
│ │ │
│ ┌────────────▼───────────────────┐ │
│ │ Feature Gate │ │
│ │ Plan → features map │ │
│ │ Usage limits enforced here │ │
│ └────────────────────────────────┘ │
└──────────┬───────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
PostgreSQL Redis
(shared schema, (tenant config cache,
tenant_id column) rate limits, queues)
Isolation strategy: Shared schema + tenant_id column. The global query scope auto-applies WHERE tenant_id = ? on every Eloquent query — no manual scoping required.
| Layer | Technology |
|---|---|
| Framework | Laravel 11 / PHP 8.2+ |
| Database | PostgreSQL 15 |
| Cache | Redis 7 |
| Queue | Laravel Horizon (Redis-backed) |
| Auth | Laravel Sanctum (API tokens + session) |
| Billing | Paystack + Stripe (both drivers included) |
| Background jobs | Laravel Queues + Horizon dashboard |
| Reverse proxy | Nginx (wildcard subdomain config included) |
| Containerisation | Docker + Docker Compose |
| CI/CD | GitHub Actions (test → lint → build) |
| Testing | Pest PHP — unit, feature, and integration tests |
- Tenant registration with subdomain provisioning
- Subdomain and custom domain routing (Nginx config included)
- Tenant suspension and activation (cascade delete via DB foreign key)
- Per-tenant configuration storage (timezone, locale, branding)
- Tenant context available anywhere via
tenant()helper
- Global Eloquent scope auto-applies
WHERE tenant_id = ?on all queries - Middleware enforces tenant resolution before any controller logic
- Tenant resolved from subdomain, custom domain, or
X-Tenant-IDheader - Redis cache for tenant lookups (5-minute TTL, invalidated on update)
- Multi-tenant user model (users belong to tenants, not global)
- Role-based access control: Owner, Admin, Member, custom roles
- API token auth (Sanctum) with per-token scopes
- Invite-based user onboarding with secure random tokens (7-day expiry)
- Plan model: Free, Starter, Pro, Enterprise (fully configurable)
- Feature flags tied to plan:
$tenant->can('feature_name') - Usage limits tied to plan:
$tenant->withinLimit('seats', count($users)) - Paystack integration: subscription creation, webhook handling, plan sync
- Stripe integration: identical interface, swap driver in config
- Billing portal endpoints (subscribe, cancel, invoice list)
- Failed payment webhook handling — suspends tenant on non-payment
- Versioned REST API (
/api/v1/...) - Consistent response envelope:
{ data, meta, errors } - Pagination on all collection endpoints
- Request validation inline in controllers (Form Request classes are the extension point)
- Rate limiting per tenant (configurable)
- API documentation via Scribe (auto-generated from code)
- Full Docker Compose stack: app, postgres, redis, nginx, horizon
.env.examplewith all variables documented- GitHub Actions: lint (Pint), static analysis (PHPStan level 6), tests, build
- Makefile with common commands (
make test,make migrate,make horizon) - Health check endpoint for load balancer integration
The Docker stack bundles everything the app needs (PHP, PostgreSQL, Redis, Nginx, Horizon), so the only software you must install on your machine is:
| Package | Version | Why |
|---|---|---|
| Docker Engine | 20.10+ | Runs the app, database, cache, queue and proxy containers |
| Docker Compose | v2 (docker compose) |
Orchestrates the multi-container stack |
| Git | any | Cloning the repository |
Everything else is provisioned inside the containers and pinned for you:
- PHP 8.3 with the
pdo_pgsql,pcntl,zipandopcacheextensions (seeDockerfile) - PostgreSQL 15 and Redis 7 (see
docker-compose.yml) - Composer dependencies, including
predis/predis(the pure-PHP Redis client — no PECL/phpredisextension required, so the image builds anywhere)
You do not need PHP, Composer, PostgreSQL, Redis or Node installed locally to run the kit with Docker. Node + npm are only needed if you want to rebuild the frontend assets (
npm install && npm run build); the app renders fine without them.
git clone https://github.com/ykachala/saas-multitenant-kit.git
cd saas-multitenant-kit
cp .env.example .env
# Build the images and start the stack (postgres, redis, app, horizon, nginx)
docker compose up -d --build
# Generate the Laravel application key (writes APP_KEY into your .env)
docker compose exec app php artisan key:generate
# Create the schema and seed two demo tenants
docker compose exec app php artisan migrate --seedApp: http://localhost:8080
Horizon dashboard: http://localhost:8080/horizon
Health check: http://localhost:8080/api/v1/health
The Makefile wraps the common commands — make up, make migrate-fresh, make test,
make shell, make tinker, etc.
The seeder creates two tenants (acme on the Pro plan, globex on Starter). Resolve a
tenant with the X-Tenant-ID header (subdomain, custom domain, or X-Tenant-Subdomain
also work). Demo credentials: owner@acme.test / password.
# Log in and grab a Sanctum token
curl -s http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'X-Tenant-ID: acme' \
-d '{"email":"owner@acme.test","password":"password"}'
# Use the returned token on authenticated routes (tenant-scoped automatically)
curl -s http://localhost:8080/api/v1/users \
-H 'Accept: application/json' \
-H 'X-Tenant-ID: acme' \
-H 'Authorization: Bearer <token>'# Run the full suite (runs against an isolated in-memory SQLite database)
docker compose exec app php artisan test
# Or with Pest directly
docker compose exec app ./vendor/bin/pestThe test suite is hermetic — it uses an in-memory SQLite database and array cache/queue
drivers (configured in phpunit.xml), so running it never touches your dev Postgres or Redis.
Add to /etc/hosts:
127.0.0.1 acme.localhost
127.0.0.1 globex.localhost
Then access http://acme.localhost:8080 — the middleware resolves the tenant automatically.
saas-multitenant-kit/
├── app/
│ ├── Http/
│ │ ├── Middleware/
│ │ │ ├── ResolveTenant.php # Core tenant resolution
│ │ │ └── EnforceFeatureGate.php # Plan/feature enforcement
│ │ └── Controllers/Api/V1/
│ ├── Models/
│ │ ├── Tenant.php
│ │ ├── User.php # Scoped to tenant
│ │ └── Concerns/BelongsToTenant.php # Reusable trait
│ ├── Services/
│ │ ├── TenantService.php
│ │ ├── BillingService.php # Billing driver interface
│ │ └── Billing/
│ │ ├── PaystackDriver.php
│ │ └── StripeDriver.php
│ └── Scopes/
│ └── TenantScope.php # Global Eloquent scope
├── database/
│ ├── migrations/ # Ordered, documented
│ └── seeders/ # Demo tenant + data
├── routes/api.php # Versioned API routes
├── config/tenancy.php # Tenancy configuration
├── nginx/ # Nginx wildcard config
├── docker-compose.yml
├── Makefile
└── .github/workflows/
└── ci.yml
This is a starting point, not a constraint. Common customisations:
- Switch isolation strategy — set
TENANCY_MODE=schemain.envfor per-schema isolation - Add a billing provider — implement
BillingDriverInterface, register inconfig/tenancy.php - Add feature flags — define in
config/features.php, gate with$tenant->can('flag') - Add a custom domain — store in
tenants.custom_domain, Nginx cert provisioning via certbot
I've built multi-tenant SaaS from scratch multiple times. The first time takes weeks to get right. The second time, you make different mistakes. By the third, you know exactly what the foundation needs to look like.
This is that foundation. The architectural decisions here are deliberate, documented, and production-hardened.
- nexus-scheduler — AI scheduling engine that can be embedded into this SaaS kit
- hookstream — webhook delivery engine for outbound events
Author: Yoweli Kachala | LinkedIn | Cape Town, South Africa