Multi-Tenancy SaaS Setup with stancl/tenancy

PagibleAI uses column-based multi-tenancy where all tenants share the same database tables, isolated by a tenant_id column on every CMS table. This guide shows how to integrate stancl/tenancy so that the current tenant is automatically resolved for every request.

Requirements

  • Laravel 11.x, 12.x or 13.x
  • PagibleAI CMS installed and configured
  • PHP 8.2+

Install stancl/tenancy

composer require stancl/tenancy

Run the tenancy installation command to publish the configuration, migrations and service provider:

php artisan tenancy:install

This creates:

  • config/tenancy.php — main tenancy configuration
  • app/Models/Tenant.php — the Tenant model
  • A migration for the tenants and domains tables
  • app/Providers/TenancyServiceProvider.php — tenancy event listeners and bootstrappers

Configure the Tenant Model

Since PagibleAI uses a single database with a tenant_id column (not a separate database per tenant), you do not need stancl/tenancy's database-switching bootstrapper. The Tenant model only needs to store tenant metadata and provide a key that maps to the tenant_id column in PagibleAI tables.

Edit app/Models/Tenant.php:

<?php

namespace App\Models;

use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;
}

Disable Database Bootstrappers

PagibleAI isolates tenants via the tenant_id column, so you must not switch databases per tenant. In config/tenancy.php, remove or comment out the database bootstrapper:

'bootstrappers' => [
    // Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class, // NOT needed
    Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
    Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
    Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
],

The CacheTenancyBootstrapper is recommended so that Laravel's cache store is automatically prefixed per tenant, preventing cache key collisions for non-CMS cache usage. PagibleAI's own cache keys already include the tenant ID, so this is purely for your application's other cache usage.

The QueueTenancyBootstrapper ensures that queued jobs run in the correct tenant context.

Connect PagibleAI to stancl/tenancy

Register a listener for the TenancyInitialized event that calls Tenancy::stancl(). This sets PagibleAI's tenant callback to read from stancl's tenant() helper.

In your TenancyServiceProvider (or AppServiceProvider), add the listener to the events method or boot method:

<?php

// In app/Providers/TenancyServiceProvider.php

use Aimeos\Cms\Tenancy;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenancyEnded;

public function events()
{
    return [
        TenancyInitialized::class => [
            function (TenancyInitialized $event) {
                Tenancy::stancl();
            },
        ],
        TenancyEnded::class => [
            function (TenancyEnded $event) {
                Tenancy::$callback = null;
            },
        ],
    ];
}

The TenancyEnded listener resets the callback so that central (non-tenant) routes don't accidentally resolve a stale tenant ID.

Set Up Tenant Identification

stancl/tenancy supports several identification strategies. For a SaaS application, domain-based identification is the most common approach.

Each tenant gets a subdomain (e.g., acme.yourapp.com) or a custom domain. In config/tenancy.php, configure the identification middleware:

'identification' => [
    'resolvers' => [
        Stancl\Tenancy\Resolvers\DomainTenantResolver::class,
    ],
    'middleware' => [
        Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
        // Or for subdomains:
        // Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain::class,
    ],
],

Apply Tenancy Middleware

PagibleAI registers its routes via service providers using the web middleware group. To ensure the tenant is identified on every request, append stancl's identification middleware to the web group in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
        \Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains::class,
    ]);
})

This applies tenant identification to all PagibleAI routes (frontend pages, admin panel, JSON API) since they all use the web middleware group.

If you have central routes (e.g., landing pages, registration) that should not be tenant-scoped, move them to a separate route file without the web middleware group or use stancl's PreventAccessFromCentralDomains middleware to distinguish between central and tenant domains.

Run Migrations

Since all tenants share the same database, run migrations as usual:

php artisan migrate

This creates both the stancl/tenancy tables (tenants, domains) and the PagibleAI CMS tables (cms_pages, cms_elements, cms_files, etc.) in the same database.

Create Tenants

Create tenants and assign domains programmatically, for example in a registration controller or via Artisan tinker:

use App\Models\Tenant;

$tenant = Tenant::create(['id' => 'acme']);
$tenant->domains()->create(['domain' => 'acme.yourapp.com']);

The tenant id (here acme) is what gets stored as tenant_id in all PagibleAI tables. Each tenant's CMS content — pages, elements, files — is automatically scoped to this ID.

Scope Users to Tenants

In a SaaS setup, editors must only be able to log into their own tenant. stancl/tenancy does not add a tenant_id to the users table, so you need to add one yourself.

Create a migration:

php artisan make:migration add_tenant_id_to_users_table

Add the tenant_id column with an index:

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('tenant_id')->after('id')->default('');
        $table->index('tenant_id');
    });
}

Then add a global scope to the User model so that authentication queries are automatically filtered by the current tenant:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected static function booted(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            if ($tenantId = tenant()?->getTenantKey()) {
                $builder->where('tenant_id', $tenantId);
            }
        });

        static::creating(function (User $user) {
            if (empty($user->tenant_id) && $tenantId = tenant()?->getTenantKey()) {
                $user->tenant_id = $tenantId;
            }
        });
    }
}

This ensures:

  • Login isolationAuth::attempt() only finds users belonging to the current tenant
  • Automatic assignment — new users created during a tenant request get the correct tenant_id
  • Query scopingUser::all() only returns users for the current tenant

Assign CMS Permissions

PagibleAI uses the cmsperms column on the User model for authorization. Assign CMS roles using the Artisan command:

php artisan cms:user admin@acme.com --role=admin

Or create a user with tenant and permissions programmatically:

use App\Models\User;

$user = User::create([
    'name' => 'Admin',
    'email' => 'admin@acme.com',
    'password' => bcrypt('password'),
    'tenant_id' => 'acme',
    'cmsperms' => ['admin'],
]);

How It Works

Here is a summary of how the integration works under the hood:

  1. A request arrives at acme.yourapp.com
  2. stancl's InitializeTenancyByDomain middleware in the web group identifies the tenant
  3. The TenancyInitialized event fires
  4. Tenancy::stancl() sets PagibleAI's callback to read from tenant()->getTenantKey()
  5. Every Eloquent query on CMS models automatically adds WHERE tenant_id = 'acme'
  6. All CMS content created during this request gets tenant_id = 'acme'
  7. Authentication queries on the User model are scoped to tenant_id = 'acme'

PagibleAI's column-based isolation means tenants share the same database and tables. This is simpler to manage, migrate, and back up than database-per-tenant approaches, while still providing complete data isolation through Eloquent global scopes.

Troubleshooting

CMS pages show empty content or return 404

The tenant context is not being initialized. Verify that the tenancy middleware is appended to the web middleware group and that Tenancy::stancl() is registered as a TenancyInitialized listener.

Content from other tenants is visible

The tenant_id column is not being filtered. Check that Tenancy::value() returns the expected tenant ID by calling it in a route closure: dd(\Aimeos\Cms\Tenancy::value()).

Users can log into any tenant

The User model is missing the tenant global scope. Ensure the booted() method adds a global scope that filters by tenant_id using the current tenant key.

Artisan commands show no CMS data

CLI commands run outside the tenant context. Set the callback directly: Tenancy::$callback = fn() => 'acme';.

Cache shows stale content after switching tenants

PagibleAI cache keys already include the tenant ID, so this typically affects your application's own cache. Enable CacheTenancyBootstrapper in config/tenancy.php to prefix all cache keys per tenant.