Multi-Tenancy SaaS Setup with stancl/tenancy

PagibleAI 0.12+ uses column-based multi-tenancy: you keep tenants in the same database tables while every CMS query is isolated by a tenant_id value. This guide connects stancl/tenancy 3.10 or newer for domain identification without creating a database per tenant. First complete Install PagibleAI CMS, then finish these steps before you serve tenant traffic.

Requirements

  • PagibleAI 0.12+
  • Laravel 11.x, 12.x or 13.x
  • 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, routes/tenant.php, and app/Providers/TenancyServiceProvider.php. Register the generated provider in bootstrap/providers.php. Keep the Stancl migrations in the central database and run them together with the PagibleAI migrations.

<?php

return [
    App\Providers\AppServiceProvider::class,
    App\Providers\TenancyServiceProvider::class,
];

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;
}

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 for application cache entries; requires a tag-capable store.
    // Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,

    // Optional when your application needs tenant-specific filesystem roots.
    // Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,

    // Optional for your own queued jobs that rely on Stancl's context.
    // Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
],

PagibleAI already includes the tenant in its model scopes, cache keys, search records, and managed file paths. Enable Stancl's cache or filesystem bootstrapper only when your other application services need the same separation. PagibleAI's queued search job carries an explicit tenant ID and restores its PagibleAI context while it runs, so QueueTenancyBootstrapper is optional for that job. Enable it for your own Stancl-aware jobs, and do not mark their queue connection as central.

Connect PagibleAI to Stancl

Register PagibleAI's Stancl integration once during application boot. Tenancy::stancl() keeps the callback connected to Stancl's current tenant() value and listens for initialization and termination. Each transition replaces PagibleAI's scoped Tenancy instance. The Access service detects the changed tenant, refreshes its request-local catalog and grant caches, and activates the selected permission-package scope. Do not add listeners that capture a tenant ID, reset Tenancy::$callback, or manually retain resolved catalog results.

<?php

namespace App\Providers;

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

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

Identify tenants 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);
    }
}

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);
})

Route-specific GraphQL middleware

The global InitializeTenant middleware above already initializes /graphql. If you do not prepend it globally and instead append Stancl middleware only to Laravel's web group, Lighthouse's dedicated GraphQL route remains outside that group and resolves the empty tenant. Add tenant identification directly to Lighthouse's route stack before its cookie, session, CSRF and authentication middleware:

// config/lighthouse.php
'route' => [
    // ...
    'middleware' => [
        \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
        \Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains::class,

        // Keep the existing cookie, session, CSRF and Lighthouse
        // authentication middleware below these entries.
    ],
],

Do not add the route-specific entries when the global InitializeTenant wrapper is already active. Verify the effective order with php artisan route:list --path=graphql -v: tenant identification must run before StartSession and AttemptAuthentication. Otherwise, the admin can authenticate while its page tree, shared elements and files are queried from the empty tenant.

Keep central routes in their normal web route groups; skipping tenant initialization does not require removing Laravel's session or CSRF middleware. Explicitly block the admin, API, JSON:API, GraphQL, broadcasting, MCP, sitemap, search, and page endpoints 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. Apply the same rule to your custom tenant-only route groups and verify the PagibleAI JSON REST API and PagibleAI GraphQL API separately.

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

Migrate and create tenants

All tenants share one database, so run php artisan migrate once. This creates the Stancl tenants and domains tables together with the PagibleAI CMS tables. Do not run tenant-database migrations.

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. Create a migration with php artisan make:migration add_tenant_id_to_users_table, then add a required, indexed tenant_id. Do not use an empty-string default, because a missing tenant context must never become a shared account namespace. If users already exist, add the column as nullable, backfill trusted tenant IDs, and make it required in a follow-up migration.

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.');
            }

            $tenantId = (string) $tenantId;

            if ($user->tenant_id !== null && (string) $user->tenant_id !== $tenantId) {
                throw new LogicException('The user tenant does not match the active tenant.');
            }

            $user->tenant_id = $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 tenant users and assign CMS editor roles

Create each user inside Stancl's tenant context and assign the PagibleAI cmsperms role in the same write. The role authorizes CMS editors and remains separate from frontend Access values. Use a generated temporary secret and deliver it through a secure one-time channel, or replace this step with your password-reset invitation flow:

use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

$tenant = Tenant::findOrFail('acme');
$temporaryPassword = Str::password(32);

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

Configure frontend page access

Frontend page restrictions are independent from the cmsperms used by CMS editors. Configure exactly one frontend value catalog during application boot. The supported adapters expose their package's permission or ability names through Laravel Gate and activate the current tenant whenever Access observes a tenant transition. Use Authorization and Permissions for CMS editor roles and keep frontend access values separate:

use Aimeos\Cms\Access;
use Aimeos\Cms\Tenancy;

public function boot(): void
{
    Tenancy::stancl();

    Access::spatie();
    // Or: Access::bouncer();
    // Or: Access::laratrust();
    // Or: Access::using(
    //     fn() => app(FrontendPermissions::class)->names()
    // );
}

Choose only one option. Spatie requires spatie/laravel-permission 6.2.0 or newer with teams enabled and its teams migration installed. Bouncer requires silber/bouncer 1.0.2 or newer and uses its built-in scope. Laratrust requires santigarcor/laratrust 8.3.0 or newer with teams enabled and its teams migration installed. Keep the provider's catalog frontend-specific instead of reusing backend editor permission names.

No access rows mean a public page. An empty access value permits any authenticated user accepted by Tenancy::allows() for the current tenant. Named values permit a current-tenant user when Laravel Gate grants any one of them. Named restriction writes are rejected when their values are missing from the configured catalog; Access::using(fn() => []) deliberately enables authentication-only restrictions.

Keep queued reindexing tenant-aware

When an access rule changes and an external Scout engine is configured, PagibleAI schedules reindexing after the surrounding database transaction commits. If Scout queues synchronization, the compact job carries the expected tenant ID, installs that PagibleAI context while loading the models, and restores the previous context afterwards. Run a queue worker in production when scout.queue is true. If you change access inside $tenant->run(), commit before that tenant context ends so the after-commit callback can dispatch safely. PagibleAI's database search engine does not need these queued reindex jobs.

Verify production readiness

Before production, connect every application replica to the same durable database and shared public and private file storage. Use a shared cache when application cache entries must stay coherent across replicas; PagibleAI's own page keys remain tenant-aware. Build an immutable release, run central migrations once per release, and run a queue worker when scout.queue is true. Back up the database and both file disks together so versioned content and managed binaries stay aligned.

Check these boundaries before launch and after every tenancy or middleware change:

  1. Create two tenants with different pages and users, then request page, admin, search, sitemap, JSON:API, GraphQL and MCP routes on both hosts. Every route must resolve the expected tenant before its first CMS query.
  2. Attempt cross-tenant authentication and request tenant-only routes from a central host; both must fail.
  3. Warm the same page path for both tenants and confirm their cached HTML never crosses hosts.
  4. If you queue external search synchronization, change page access and confirm the worker indexes only the payload tenant after commit.
  5. In a long-lived CLI or worker test, switch between both tenants and end tenancy; assert Tenancy::value() and the Access catalog at each transition.

Start with a runnable application test for the database boundary:

<?php

namespace Tests\Feature;

use Aimeos\Cms\Tenancy as CmsTenancy;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;

class TenantIsolationTest extends TestCase
{
    use RefreshDatabase;

    public function testUsersCannotCrossTenants(): void
    {
        $alpha = Tenant::create(['id' => 'alpha']);
        $beta = Tenant::create(['id' => 'beta']);

        $alpha->run(function (): void {
            $this->assertSame('alpha', CmsTenancy::value());

            User::create([
                'name' => 'Alpha Admin',
                'email' => 'admin@alpha.test',
                'password' => Hash::make('test-only-password'),
                'cmsperms' => ['admin'],
            ]);
        });

        $beta->run(function (): void {
            $this->assertSame('beta', CmsTenancy::value());
            $this->assertNull(User::where('email', 'admin@alpha.test')->first());
        });
    }
}

Run it with php artisan test --filter=TenantIsolationTest, then extend the same suite with host routing, central-domain denial, cache, API and queued-search assertions from the checklist.

How it works

  1. The prepended middleware asks Stancl to identify the tenant before any PagibleAI route or cache lookup.
  2. Stancl's initialization event replaces PagibleAI's scoped Tenancy instance; Access observes the new key, clears request-local results, and activates the configured permission-package scope.
  3. PagibleAI model and User scopes constrain queries, while new CMS, access and user records receive the active tenant.
  4. CMS editor permissions and frontend page restrictions are evaluated independently inside that tenant context.
  5. A queued search job installs its explicit PagibleAI tenant while synchronizing models, then restores the previous service context.
  6. When Stancl ends tenancy, PagibleAI returns to the empty central context and Access refreshes when it is next used.

This lifecycle prevents new CMS queries and access decisions from reusing the previous tenant in Octane, queue workers, or CLI processes that switch contexts.

Verified references

The implementation details in this guide were checked against these sources:

Review the linked implementations when you change the supported PagibleAI or Stancl series.

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. PagibleAI editor permission checks also require the authenticated user's tenant to match.

Frontend restrictions are unavailable

Configure exactly one Access adapter or Access::using() callback during application boot. An empty callback result still enables authentication-only restrictions; named values must exist in the configured catalog.

Access values or package roles come from the previous tenant

Call Tenancy::stancl() once and configure one package adapter with its team or scope support enabled. Access detects tenant changes and refreshes its request-local catalog and grants; do not reset the tenancy callback or cache catalog results outside the service.

An after-commit reindex loses its tenant context

Commit the surrounding transaction before $tenant->run() ends. In Stancl mode, the after-commit callback must dispatch while that tenant is still active. The queued PagibleAI job then installs its explicit payload tenant while it synchronizes models.

Artisan commands show no CMS data

Run the command inside $tenant->run(...). In Stancl mode, Stancl must own the lifecycle; setting only PagibleAI's callback or calling PagibleAI's generic tenant switch is insufficient.

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.