Multi-Tenancy Implementation for SaaS Web Application

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Multi-tenancy for SaaS web application

Multi-tenancy is an architecture where one installation serves multiple independent organizations (tenants). Each tenant sees only their data. Three architectural models differ in isolation level, cost, and complexity.

Three multi-tenancy models

Pool (Shared Everything): all tenants in one database, each table has tenant_id column. Cheap, scales easily, but isolation only at application level.

Silo (Database per Tenant): each tenant — separate database. Full isolation, easy tenant data migration, compliance (GDPR right to erasure — just delete DB). Expensive with many tenants.

Bridge (Schema per Tenant): one PostgreSQL cluster, separate schemas (tenant_acme, tenant_globex). Compromise: good isolation, one PostgreSQL process, complex schema management.

Pool model: Row-Level Security

Most common approach for SaaS. Protection at PostgreSQL level, not just ORM:

-- Table with tenant_id
ALTER TABLE articles ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenants(id);
CREATE INDEX articles_tenant_id_idx ON articles(tenant_id);

-- RLS policy
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON articles
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Set context before queries
SET app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
SELECT * FROM articles; -- automatically sees only own

Laravel Tenancy integration:

// TenantScope — global scope for all models
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where($model->getTable() . '.tenant_id', tenant()->id);
    }
}

// HasTenant trait
trait HasTenant
{
    protected static function bootHasTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function ($model) {
            $model->tenant_id ??= tenant()->id;
        });
    }
}

// Initialize tenant from request
class InitializeTenancy
{
    public function handle(Request $request, Closure $next)
    {
        $subdomain = explode('.', $request->getHost())[0];
        $tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();

        app()->instance('tenant', $tenant);

        // Set PostgreSQL context for RLS
        DB::statement("SET app.tenant_id = '{$tenant->id}'");

        return $next($request);
    }
}

Tenant identification

Subdomain: acme.app.example.com — most convenient.

// Route by subdomain
Route::domain('{tenant}.example.com')->group(function () {
    Route::middleware([InitializeTenancy::class])->group(function () {
        // All protected routes
    });
});

Custom domain: app.acme.com → wildcard SSL (Let's Encrypt, Caddy automatic HTTPS) + DNS record + database record.

Path-based: example.com/acme/... — no SSL issues, but less professional URL.

Silo model: Dynamic Database Connections

// Dynamic connection switching
class TenantDatabaseManager
{
    public function connectTenant(Tenant $tenant): void
    {
        $config = [
            'driver'   => 'pgsql',
            'host'     => $tenant->db_host ?? config('database.connections.pgsql.host'),
            'database' => "tenant_{$tenant->id}",
            'username' => $tenant->db_user,
            'password' => Crypt::decrypt($tenant->db_password),
        ];

        Config::set("database.connections.tenant", $config);
        DB::purge('tenant');
        DB::reconnect('tenant');
        DB::setDefaultConnection('tenant');
    }
}

Migrations for all tenants:

// Artisan command: tenants:migrate
Tenant::each(function (Tenant $tenant) {
    app(TenantDatabaseManager::class)->connectTenant($tenant);
    Artisan::call('migrate', ['--force' => true]);
});

Schema model on PostgreSQL

-- Create schema for new tenant
CREATE SCHEMA tenant_acme;

-- Copy structure from template schema
SELECT clone_schema('tenant_template', 'tenant_acme');

-- Connect to schema
SET search_path TO tenant_acme, public;
// search_path as tenant context
DB::statement("SET search_path TO tenant_{$tenant->slug}, public");

New tenant onboarding

class ProvisionTenantJob implements ShouldQueue
{
    public function handle(Tenant $tenant): void
    {
        // 1. Create database/schema
        TenantDatabaseManager::create($tenant);

        // 2. Run migrations
        Artisan::call('tenants:migrate', ['--tenant' => $tenant->id]);

        // 3. Seeds: default roles, settings
        Artisan::call('tenants:seed', ['--tenant' => $tenant->id]);

        // 4. DNS if needed (Cloudflare API)
        CloudflareDNS::createSubdomain($tenant->subdomain);

        // 5. Welcome email
        Mail::to($tenant->owner_email)->send(new TenantWelcome($tenant));

        $tenant->update(['status' => 'active']);
    }
}

Cross-tenant data

Some data is global — not bound to tenant:

// Models without TenantScope
class Country extends Model { } // no HasTenant
class Plan extends Model { }    // pricing plans — global

// Super admin sees all tenants
class SuperAdminScope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (!auth()->user()?->isSuperAdmin()) {
            $builder->where('tenant_id', tenant()->id);
        }
    }
}

File isolation

// S3/MinIO — separate prefix per tenant
Storage::disk('s3')->put(
    "tenants/{$tenant->id}/uploads/{$filename}",
    $fileContent
);

// Or separate buckets for enterprise tier
$bucket = $tenant->plan === 'enterprise'
    ? "tenant-{$tenant->id}"
    : "tenants-shared";

Feature flags per tenant

// Enable features at tenant level
class TenantFeature extends Model
{
    // tenant_id, feature, enabled, config (JSON)
}

// Usage
if (tenant()->hasFeature('advanced_analytics')) {
    // Show BI dashboard
}

// Or via Laravel Pennant
Feature::for($tenant)->active('advanced_analytics');

Timeline

Pool model with RLS, TenantScope, subdomain routing, provisioning job: 2–3 weeks. Silo with dynamic connections, custom domains, wildcard SSL, cross-tenant analytics for superadmin: 1–2 months.