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.
Multi-Tenancy SaaS Setup with stancl/tenancy
Requirements
- Laravel 11.x, 12.x or 13.x
- PagibleAI CMS installed and configured
- PHP 8.2+
stancl/tenancy3.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
- A request arrives at
acme.yourapp.com. - The prepended middleware identifies
acmebefore any PagibleAI route or cache lookup. Tenancy::stancl()reads the active Stancl tenant key.- PagibleAI's model scopes add
WHERE tenant_id = 'acme', and new CMS records receive the same value. - The User scope limits authentication and editor queries to
acme. - 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.