mcpField
L
laravel-multi-tenancy

laravel-multi-tenancy

github.com/leeoveryDevelopment1560 installsUpdated 2w ago
Repository

About laravel-multi-tenancy

A guide for Laravel developers implementing multi-tenant applications, with patterns for organizing central and tenant contexts. It covers database isolation, automatic query scoping, tenant identification, and preserving context in queued jobs.

Key features

  1. 01Separate central and tenant actions
  2. 02Use a separate database per tenant
  3. 03Scope queries to the current tenant
  4. 04Identify tenants by domain, subdomain, or header
  5. 05Preserve tenant context in queued jobs

Use cases

  1. 01Build SaaS apps requiring strict data separation
  2. 02Organize routes, models, and actions by context
  3. 03Run operations across multiple tenants
  4. 04Test central and tenant contexts

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/leeovery/agentic-skills --skill laravel-multi-tenancy

After installing, type/laravel-multi-tenancyin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

references/
ManagesTenants.php
RefreshDatabaseWithTenant.php
tenancy-testing.md
TenancyPest.php
TenancyTestCase.php
TenantTestCase.php

Details

From the project README

Laravel Multi-Tenancy

Multi-tenancy separates application logic into central (non-tenant) and tenanted (tenant-specific) contexts.

Related guides:

Philosophy

Multi-tenancy provides:

  • Clear separation between central and tenant contexts
  • Database isolation with separate databases per tenant
  • Automatic scoping of queries to current tenant
  • Context awareness through helper classes
  • Queue integration with tenant context preservation

When to Use

Use multi-tenancy when:

  • Building SaaS applications with complete data isolation
  • Each customer needs their own database
  • Compliance requires strict data separation

Don't use when:

  • Simple user segmentation is sufficient (use user_id scoping)
  • All customers share the same schema
  • Application complexity doesn't justify the overhead

Directory Structure

app/
├── Actions/
│   ├── Central/          # Non-tenant actions
│   │   ├── Tenant/
│   │   │   ├── CreateTenantAction.php
│   │   │   └── DeleteTenantAction.php
│   │   └── User/
│   │       └── CreateCentralUserAction.php
│   └── Tenanted/         # Tenant-specific actions
│       ├── Order/
│       │   └── CreateOrderAction.php
│       └── Customer/
│           └── CreateCustomerAction.php
├── Data/
│   ├── Central/          # Central DTOs
│   └── Tenanted/         # Tenant DTOs
├── Http/
│   ├── Central/          # Central routes (tenant management)
│   ├── Web/              # Tenant application routes
│   └── Api/              # Public API (tenant-scoped)
├── Models/               # All models in standard location
│   ├── Tenant.php        # Central model
│   ├── Order.php         # Tenanted model
│   └── Customer.php
└── Services/
    └── Tenancy/
        ├── Landlord.php
        └── Facades/
            └── Landlord.php

Central Actions

Central actions manage tenants and cross-tenant operations.

<?php

declare(strict_types=1);

namespace App\Actions\Central\Tenant;

use App\Data\Central\CreateTenantData;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;

class CreateTenantAction
{
    public function __construct(
        private readonly CreateTenantDatabaseAction $createDatabase,
    ) {}

    public function __invoke(CreateTenantData $data): Tenant
    {
        return DB::transaction(function () use ($data): Tenant {
            $this->guard($data);
            $tenant = $this->createTenant($data);
            ($this->createDatabase)($tenant);
            return $tenant;
        });
    }

    private function guard(CreateTenantData $data): void
    {
        throw_if(
            Tenant::where('domain', $data->domain)->exists(),
            TenantDomainAlreadyExistsException::forDomain($data->domain)
        );
    }

    private function createTenant(CreateTenantData $data): Tenant
    {
        return Tenant::create([
            'id' => $data->tenantId,
            'name' => $data->name,
            'domain' => $data->domain,
        ]);
    }
}

Tenanted Actions

Tenanted actions operate within a specific tenant's context. All queries automatically scoped.

<?php

declare(strict_types=1);

namespace App\Actions\Tenanted\Order;

use App\Data\Tenanted\CreateOrderData;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class CreateOrderAction
{
    public function __invoke(User $user, CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($user, $data): Order {
            // Automatically scoped to current tenant
            $order = $user->orders()->create([
                'status' => $data->status,
                'total' => $data->total,
            ]);

            $this->createOrderItems($order, $data->items);
            return $order;
        });
    }

    private function createOrderItems(Order $order, array $items): void
    {
        foreach ($items as $item) {
            $order->items()->create([
                'product_id' => $item->productId,
                'quantity' => $item->quantity,
                'price' => $item->price,
            ]);
        }
    }
}

Landlord Helper

Wrap Stancl Tenancy in a Landlord service class for a cleaner API:

<?php

declare(strict_types=1);

namespace App\Services\Tenancy;

use App\Models\Tenant;

class Landlord
{
    public static function tenant(): ?Tenant
    {
        return tenant();
    }

    public static function initialize(Tenant|int|string $tenant): void
    {
        tenancy()->initialize($tenant);
    }

    public static function end(): void
    {
        tenancy()->end();
    }

    public static function runAsCentral(callable $callback): mixed
    {
        return tenancy()->central($callback);
    }

    public function tenantId(): ?string
    {
        return tenant()?->getTenantKey();
    }

    public function eachTenant(callable $callback): void
    {
        Tenant::each(function (Tenant $tenant) use ($callback): void {
            $this->runAs($tenant, $callback);
        });
    }

    public function runAs(Tenant|int|string $tenant, callable $callback): mixed
    {
        if (! $tenant instanceof Tenant) {
            $tenant = tenancy()->find($tenant);
        }

        return tenancy()->run($tenant, $callback);
    }
}

Usage:

use App\Services\Tenancy\Landlord;

$tenant = Landlord::tenant();
$tenantId = Landlord::tenantId();

if (Landlord::tenant() !== null) {
    // Tenant-specific logic
}

Landlord::runAs($tenant, function () {
    Order::create([...]);
});

Landlord::runAsCentral(function () {
    Tenant::create([...]);
});

Tenant Identification Middleware

Domain-Based
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;

class IdentifyTenant extends InitializeTenancyByDomain
{
    // Tenant identified by domain (e.g., tenant1.myapp.com)
}
Subdomain-Based
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;

class IdentifyTenant extends InitializeTenancyBySubdomain
{
    // Tenant identified by subdomain
}
Header-Based
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;

class IdentifyTenant extends InitializeTenancyByRequestData
{
    public static string $header = 'X-Tenant';
}

Route Configuration

Tenant Routes
// routes/tenant.php
Route::middleware(['tenant'])->group(function () {
    Route::get('/orders', [OrderController::class, 'index']);
    Route::post('/orders', [OrderController::class, 'store']);
});
Central Routes
// routes/central.php
Route::middleware(['central'])->prefix('central')->group(function () {
    Route::get('/tenants', [TenantController::class, 'index']);
    Route::post('/tenants', [TenantController::class, 'store']);
});
Bootstrap Configuration
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(function () {
        Route::middleware('web')
            ->prefix('central')
            ->name('central.')
            ->group(base_path('routes/central.php'));

        Route::middleware(['web', 'tenant'])
            ->group(base_path('routes/tenant.php'));
    })
    ->create();

Models

All models live in app/Models/. Central vs tenanted distinguished by traits/interfaces, not subdirectories.

Central Model
<?php

declare(strict_types=1);

namespace App\Models;

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

class Tenant extends BaseTenant
{
    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }
}
Tenanted Model
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    // Automatically scoped to current tenant
    // No tenant_id needed in queries

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Queue Jobs with Tenant Context

Jobs must preserve tenant context when queued.

<?php

declare(strict_types=1);

namespace App\Jobs\Tenanted;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Jobs\TenantAwareJob;

class ProcessOrderJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, TenantAwareJob;

    public function __construct(
        public TenantWithDatabase $tenant,
        public OrderData $orderData,
    ) {
        $this->onQueue('orders');
    }

    public function handle(ProcessOrderAction $action): void
    {
        // Runs in tenant context automatically
        $action($this->orderData);
    }
}

Dispatching:

ProcessOrderJob::dispatch(Landlord::tenant(), $orderData);

Common Patterns

Running Code in Multiple Tenants
// Using Landlord's eachTenant helper
resolve(Landlord::class)->eachTenant(function () {
    Order::where('status', 'pending')->update(['processed' => true]);
});

// Or manually for specific tenants
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
    resolve(Landlord::class)->runAs($tenant, function () {
        Order::where('status', 'pending')->update(['processed' => true]);
    });
}
Accessing Central Data from Tenant Context
Landlord::runAsCentral(function () {
    $allTenants = Tenant::all();
});
Conditional Logic Based on Tenant
if (Landlord::tenant() !== null) {
    $orders = Order::all(); // Scoped to tenant
} else {
    $tenants = Tenant::all(); // Central
}

Testing

→ Complete testing guide: tenancy-testing.md

Includes:

  • Testing central and tenanted actions
  • ManagesTenants and RefreshDatabaseWithTenant traits
  • TenantTestCase setup
  • Pest configuration for multi-tenancy
  • Test directory structure

Best Practices

  • Use directory structure to separate central and tenanted actions/DTOs
  • Keep models in app/Models/ following Laravel convention
  • Always use Landlord helper for tenant access
  • Test both central and tenant contexts separately
  • Preserve tenant context in queued jobs

Comments

Markdown supported