Multi-Tenancy SaaS Setup with stancl/tenancy

PagibleAI uses column-based multi-tenancy: tenants share the same database tables, while every CMS query is isolated by a tenant_id value. This guide integrates stancl/tenancy 3.10 or newer for domain identification without creating a database per tenant.

Requirements

  • Laravel 11.x, 12.x or 13.x
  • PagibleAI CMS installed and configured
  • PHP 8.2+
  • stancl/tenancy 3.10 or newer

Install stancl/tenancy

composer require stancl/tenancy:^3.10
php artisan tenancy:install

The installer publishes config/tenancy.php, the tenant and domain migrations, and app/Providers/TenancyServiceProvider.php. Keep the Stancl migrations in the central database and run them together with the PagibleAI migrations.

Configure shared-database tenancy

PagibleAI does not use Stancl's database-per-tenant feature. The tenant model only needs domain support:

<?php

namespace App\Models;

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

class Tenant extends BaseTenant
{
    use HasDomains;
}

Disable database creation and switching

Remove DatabaseTenancyBootstrapper from the bootstrappers array in config/tenancy.php. In TenancyServiceProvider, also remove the generated TenantCreated job pipeline containing CreateDatabase, MigrateDatabase, and SeedDatabase. Tenant creation must not create or migrate another database.

// config/tenancy.php
'bootstrappers' => [
    // Optional: requires a cache store that supports tags.
    Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,

    // Optional: enable only when tenant-specific filesystem roots are wanted.
    // Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,

    Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
],

The cache and filesystem bootstrappers affect your application services; PagibleAI already includes the tenant in its own cache keys and stores tenant ownership in its models. Keep QueueTenancyBootstrapper when your application dispatches tenant-aware jobs.

Connect PagibleAI to Stancl

Register PagibleAI's Stancl adapter once during application boot. The callback reads Stancl's current tenant whenever a CMS model needs it, so it must remain registered on central requests and in long-running workers. Do not capture a tenant ID and do not reset the callback when tenancy ends.

<?php

namespace App\Providers;

use Aimeos\Cms\Tenancy;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Tenancy::stancl();
    }
}

Identify the tenant before Pagible routes

Stancl 3 identifies tenants with route middleware rather than an identification array in config/tenancy.php. PagibleAI's route families do not all use Laravel's web group, and the complete-page cache runs before web. Therefore, merely appending InitializeTenancyByDomain to web is too late for cache keys and does not cover JSON:API.

If the same Laravel application serves central and tenant domains, add a small global middleware that skips configured central hosts and initializes every tenant host:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;

class InitializeTenant
{
    public function handle(Request $request, Closure $next): mixed
    {
        if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) {
            return $next($request);
        }

        return app(InitializeTenancyByDomain::class)->handle($request, $next);
    }
}

Register the middleware first

Prepend the middleware in bootstrap/app.php so tenant identification happens before routing middleware, authentication, JSON:API, and PagibleAI's page-cache lookup:

use App\Http\Middleware\InitializeTenant;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(InitializeTenant::class);
})

Keep central routes in their normal web route groups; skipping tenant initialization does not require removing Laravel's session or CSRF middleware. PagibleAI routes are registered by package service providers, so explicitly prevent the admin, API, JSON:API, GraphQL, broadcasting, MCP, sitemap, search, and page endpoints from being used on central domains unless central access is intentional. For the catch-all page route, add PreventAccessFromCentralDomains to the published config/cms/theme.php page group:

// config/cms/theme.php
'pageroute' => [
    'middleware' => [
        \Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains::class,
    ],
],

Run migrations

All tenants share one database, so run migrations once:

php artisan migrate

This creates the Stancl tenants and domains tables and the PagibleAI CMS tables in the same database. Do not run tenant-database migrations.

Create tenants

Create a tenant and assign its domain in a registration service, seeder, or Tinker:

use App\Models\Tenant;

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

The tenant key (acme) becomes the tenant_id stored on PagibleAI records. Tenant middleware must be active before any CMS query or write.

Scope users to tenants

Stancl does not add tenant ownership to your central users table. Add a required, indexed tenant_id column. Do not use an empty-string default, because a missing tenant context must never turn into a shared account namespace.

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

Scope authentication queries and creation fail-closed. A tenant-only user model should return no rows and refuse user creation when Stancl has no current tenant:

<?php

namespace App\Models;

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

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

            if ($tenantId === null) {
                $builder->whereRaw('1 = 0');
                return;
            }

            $builder->where('tenant_id', (string) $tenantId);
        });

        static::creating(function (User $user): void {
            $tenantId = tenant()?->getTenantKey();

            if ($tenantId === null) {
                throw new LogicException('A tenant is required to create a tenant user.');
            }

            $user->tenant_id ??= (string) $tenantId;
        });
    }
}

If the application also has central administrators, use a separate central guard/model or bypass this scope only inside narrowly scoped, trusted central code. Do not make the tenant scope silently optional.

Create users and assign CMS roles

Run user creation and the PagibleAI role command inside Stancl's tenant context. This initializes both Stancl and the PagibleAI callback correctly:

use App\Models\Tenant;
use Illuminate\Support\Facades\Artisan;

$tenant = Tenant::findOrFail('acme');

$tenant->run(function (): void {
    Artisan::call('cms:user', [
        'email' => 'admin@acme.com',
        '--role' => 'admin',
    ]);
});

Application code that creates a user should use the same context:

$tenant->run(function (): void {
    User::create([
        'name' => 'Admin',
        'email' => 'admin@acme.com',
        'password' => bcrypt('use-a-generated-secret'),
        'cmsperms' => ['admin'],
    ]);
});

How it works

  1. A request arrives at acme.yourapp.com.
  2. The prepended middleware identifies acme before any PagibleAI route or cache lookup.
  3. Tenancy::stancl() reads the active Stancl tenant key.
  4. PagibleAI's model scopes add WHERE tenant_id = 'acme', and new CMS records receive the same value.
  5. The User scope limits authentication and editor queries to acme.
  6. At the end of the request, Stancl ends tenancy; the permanent PagibleAI callback remains safe because it reads current state rather than retaining an old ID.

This order matters for Laravel Octane and other long-running workers: never store a request's tenant ID in static application state.

Troubleshooting

CMS pages show empty content or return 404

Check that InitializeTenant is prepended globally and that the host is attached to the expected tenant. Appending identification only to web is too late for complete-page cache lookup.

Content from another tenant is visible

Inspect tenant()?->getTenantKey() and \Aimeos\Cms\Tenancy::value() during the same request. Both must return the same non-empty tenant key before the first CMS query.

Users can log into another tenant

Ensure the User model has the fail-closed tenant scope, the tenant middleware runs before authentication, and tenant user records never have an empty or null tenant_id.

Artisan commands show no CMS data

Run the command inside $tenant->run(...). Setting only PagibleAI's callback is insufficient when your User model and other application code use Stancl's tenant() helper.

Central domains expose CMS endpoints

The global identification wrapper intentionally skips central hosts. Explicitly block package-provided CMS routes on those hosts, and use PreventAccessFromCentralDomains for tenant-only route groups.

Application cache leaks between tenants

Enable CacheTenancyBootstrapper only with a tag-capable cache store. PagibleAI's own cache keys already contain the tenant value; the bootstrapper is for application cache entries.