Back to projects
L4 · Multi-tenant Live

001 · Technical case study

Multi-tenant
SaaS platform.

B2B platform with strict per-organisation isolation, dual-layer payments (platform subscription + connected accounts with split payouts) and an OAuth integration layer extensible to any provider.

Role
Full-stack engineer
Year
2025 — ongoing
Status
Live · production
Scope
Architecture · Backend · Frontend · Infra
Team
Solo + design partner
Sector
B2B SaaS · NDA

Plain English

So what is multi-tenant, exactly?

Picture an office building. Every company that signs up rents its own office inside the same building: their desk, their cabinets, their keys. They share the building, the lift and the wifi — but no company ever sees another's paperwork.

In code that translates to: one app, one server, but each customer gets their own isolated database. When a user signs in, the system reads the URL, works out which company they belong to, and only gives them access to THEIR database. Company A literally cannot reach Company B's data.

The trick: one architecture, infinite customers. Without spinning up 50 separate installs, 50 servers, or 50 deploys.

1 app

One codebase,
one deploy

N customers

Each one,
their own world

0 leaks

Full
isolation

The building, drawn

THE BUILDING — ONE APP, ONE SERVER TENANT 001 Company A its DB isolated TENANT 002 Company B its DB isolated TENANT 003 Company C its DB isolated + N URL: /tenant-001/... URL: /tenant-002/...

/tenant-001/dashboard and /tenant-002/dashboard run the exact same code — but read and write to different databases. The first URL segment is the key that only opens THEIR office.

By the numbers

The codebase, quantified.

Snapshot of the production repo — counted directly from source.

0

Migrations

35 central · 67 tenant

0

Eloquent models

Domain entities

0

Controllers

Inertia + REST

0

Services

Business logic

0

Background jobs

Horizon queues

0

Vue files

82 pages · 179 components

0

LOC routes

6 route files

0 +

OAuth providers

Pluggable layer

Schema map

Two databases, 43 tables.

Central DB owns identity, billing and tenancy metadata. Each tenant DB owns its full domain.

Central DB

Identity · Billing · OAuth

16 tables
tenantsdomainscentral_userstenant_usercentral_subscriptionscentral_subscription_planscentral_oauth_configsoauth_statescentral_account_share_tokensbilling_plan_assignmentssubscriptionssubscription_itemscustomersrolespermissionssessions

Tenant DB

Per organisation · Isolated

25 tables × N tenants
cuentascuenta_usuariocuenta_subscriptioncuenta_metric_snapshotspublicacionspublicacion_metric_snapshotspublication_readshistoriasguionsguion_inline_commentscommentstipoctipo_cuentabilling_planssubscriptionssubscription_itemsnotification_settingsnotification_send_logtenant_notificationstenant_oauth_configsmediatagstaggablesjobsfailed_jobs
central_user_id (string) cross-DB · no FK

Module map

Eight domain clusters.

How the 32 models cluster into bounded contexts. Each module owns its own services, jobs, and routes.

01 6 entities

Tenancy & Auth

Path-based isolation, central users, cross-DB permissions.

  • › Tenant
  • › CentralUser
  • › MainUsers
  • › Role
  • › Permission
  • › AccountShareToken
02 5 entities

Billing & Plans

Dual-layer subscriptions: platform Cashier + per-account billing.

  • › CentralSubscription
  • › CentralSubscriptionPlan
  • › BillingPlan
  • › BillingPlanAssignment
  • › CuentaSubscription
03 4 entities

OAuth Layer

Per-tenant + central OAuth configs + state manager.

  • › CentralOAuthConfig
  • › TenantOAuthConfig
  • › OAuthService
  • › OAuthStateManager
04 7 entities

Publishing

Posts, stories, scripts, accounts — the content domain.

  • › Publicacion
  • › Historia
  • › Guion
  • › GuionInlineComment
  • › Cuenta
  • › CuentaUsuario
  • › Tipoc
05 4 entities

Analytics

Time-series snapshots of accounts and posts for trend analysis.

  • › CuentaMetricSnapshot
  • › PublicacionMetricSnapshot
  • › PublicationRead
  • › SnapshotAccountMetricsJob
06 4 entities

Notifications

Per-tenant settings, send log, queued delivery via Brevo.

  • › TenantNotification
  • › NotificationSendLog
  • › NotificationSetting
  • › NotificationService
07 4 entities

Media & Annotations

Spatie MediaLibrary on S3 + comment threads on scripts.

  • › Media
  • › Comment
  • › Comentario
  • › MediaUploadService
08 3 entities

Auto-publish Pipeline

Self-rescheduling fan-out — see the deep-dive →

  • › AutoPublishScheduler
  • › ProcessAutoPublishPosts
  • › CheckAutoPublishSystemJob
Read deep-dive →

01

Tenancy.

Architecture · Isolation · Routing

Built on stancl/tenancy 3.9 with path-based identification: every request resolves tenant context before the controller, switching DB, cache, filesystem and queue connections.

  1. 01

    Path-based tenant resolution

    InitializeTenancyByPath middleware resolves the tenant from the first URL segment and switches the active DB, cache and filesystem connections before the controller runs. The whole functional domain lives under /{tenant_id}/… without colliding with central routes.

  2. 02

    Database-per-tenant isolation

    Each organisation gets its own database (tenant{id}) with 27 tables migrated via artisan tenants:migrate. The central DB only holds 16 globally-scoped tables: tenants, domains, users, billing, OAuth configs.

  3. 03

    Cross-database relationships

    Users live in the central DB but are referenced from tenant DBs via central_user_id as a string — no physical FKs. CentralUser::can() overrides Spatie Permission to always resolve against the central connection, regardless of the active context.

  4. 04

    Permission cache isolation

    A ClearPermissionCacheForTenancy middleware purges the permission cache when entering tenant context, killing the classic cross-authorisation bug that appears when workers are reused across tenants.

02

Payments.

Dual-layer · Connected accounts

Two money flows living on the same infrastructure — uncoupled, non-overlapping, with deterministic access rules.

Layer 1

Platform subscriptions

The platform charges each tenant via laravel/cashier 15. CheckTenantSubscription middleware intercepts every request to the tenant namespace and blocks access unless three invariants hold: a valid subscription ID, status active or trialing, and a future current_period_end.

  • Cashier 15
  • Webhook sync
  • Middleware gate
Layer 2

Connected accounts (per merchant)

Per-tenant onboarding of connected accounts so they can charge their own end customers. StripeConnectService orchestrates destination charges to the merchant account with automatic split payouts and an application fee retained on every transaction. Webhooks reconcile each connected account's state.

  • StripeConnectService
  • Destination charges
  • Application fee
  • KYC sync

// access invariants — checked on every tenant request

public function isPaid(): bool
{
    return $this->stripe_subscription_id
        && in_array($this->status, ['active', 'trialing'])
        && (!$this->current_period_end || $this->current_period_end->isFuture());
}

03

OAuth.

6+ providers · pluggable

Un único OAuthService abstrae la lógica común; cada provider añade un controller especializado.

Provider
Flavor
Controllers
Status
Instagram
Graph API + SDK
4
Live
Facebook
Pages + Instagram Business
2
Live
TikTok
Webhook-signed
1
Live
Google
OAuth + Gemini API
1
Live
LinkedIn
happyr/linkedin-api
1
Ready
Twitter / X
twitteroauth v7
1
Ready

04

Lifecycle.

Tenant onboarding flow

  Manager registers     ┌──────────────────────┐
   on /register   ───▶  │  CentralUser created │
                        └──────────┬───────────┘
                                   │
                                   ▼
                        ┌──────────────────────┐
                        │  Tenant + Domain     │ ◀─── tenants:create
                        │  central_subscription│
                        └──────────┬───────────┘
                                   │ tenants:migrate
                                   ▼
                        ┌──────────────────────┐
                        │  Tenant DB seeded    │ 27 tables
                        │  permissions cleared │ ClearPermissionCache
                        └──────────┬───────────┘
                                   │ first request
                                   ▼
                        ┌──────────────────────┐
   CheckTenantSubscr.   │  isPaid()? ───── no  │ ───▶ /billing
   ────────────────────▶│        │             │
                        │       yes            │
                        │        ▼             │
                        │  Dashboard           │
                        └──────────┬───────────┘
                                   │
                ┌──────────────────┼──────────────────┐
                ▼                  ▼                  ▼
         ┌──────────┐      ┌────────────┐    ┌─────────────┐
         │ Connect  │      │  Connect   │    │   Create    │
         │ social   │      │  Stripe    │    │ publication │
         │ accounts │      │  account   │    │   draft     │
         └──────────┘      └────────────┘    └─────────────┘

05

Integrations.

External services orchestrated

OAuth providers
Instagram (Graph + SDK) · Facebook · TikTok · Google · LinkedIn · Twitter — through a Socialite manager with automatic refresh
AI generation
Google Gemini for script generation (google-gemini-php/client)
Media pipeline
Spatie MediaLibrary 11 + AWS S3 (Flysystem v3) — on-demand conversions, fallbacks, streaming
Realtime
Laravel Reverb 1.7 (native WebSocket) for dashboard updates without polling
Transactional email
Brevo via sarfrazrizwan/laravel-brevo with prioritised Horizon queues
Auth dual-scope
Spatie Permission resolving against the central DB from tenant context
PDF generation
Spatie laravel-pdf for billing reports
Tags & taxonomy
Spatie laravel-tags scoped per tenant

06

Operations.

Async · Deploy · Quality

Queues
Horizon 5.33 on Redis · persistent workers via Supervisor
Scheduler
Central cron dispatching tenant-scoped jobs
Deploy
Push-to-main = automatic build + deploy to production
Observability
Horizon metrics · Pail logs · webhook traces
Tests
Pest PHP 3 · feature tests on in-memory SQLite
Code quality
Pint + ESLint + Prettier pre-commit

07

Stack.

Production dependencies — curated

Core

  • PHP 8.2
  • Laravel 12
  • Inertia.js 2.0
  • Vue 3
  • TypeScript
  • Tailwind v4
  • Reka UI

Tenancy

  • stancl/tenancy 3.9
  • Path-based identification
  • Database-per-tenant

Payments

  • Laravel Cashier 15
  • Stripe Connect
  • Webhook-driven state

Async · Realtime

  • Horizon 5.33
  • Redis · Predis 3
  • Laravel Reverb 1.7
  • Supervisor

Integrations

  • Socialite (5+ providers)
  • Gemini AI
  • Spatie MediaLibrary
  • AWS S3
  • Brevo

Quality

  • Pest PHP 3
  • Laravel Pint
  • ESLint
  • Prettier
  • SSR-ready Vite