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.
Multi-Tenancy SaaS Setup with stancl/tenancy
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 configurationapp/Models/Tenant.php— the Tenant model- A migration for the
tenantsanddomainstables 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 isolation —
Auth::attempt()only finds users belonging to the current tenant - Automatic assignment — new users created during a tenant request get the correct
tenant_id - Query scoping —
User::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:
- A request arrives at
acme.yourapp.com - stancl's
InitializeTenancyByDomainmiddleware in thewebgroup identifies the tenant - The
TenancyInitializedevent fires Tenancy::stancl()sets PagibleAI's callback to read fromtenant()->getTenantKey()- Every Eloquent query on CMS models automatically adds
WHERE tenant_id = 'acme' - All CMS content created during this request gets
tenant_id = 'acme' - 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.