laravel-multi-tenancy

Multi-tenant application architecture patterns. Use when working with multi-tenant systems, tenant isolation, or when user mentions multi-tenancy, tenants, tenant scoping, tenant isolation, multi-tenant.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "laravel-multi-tenancy" with this command: npx skills add leeovery/claude-laravel/leeovery-claude-laravel-laravel-multi-tenancy

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
└── Support/
    └── TenantContext.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,
            ]);
        }
    }
}

Tenant Context Helper

<?php

declare(strict_types=1);

namespace App\Support;

use App\Models\Tenant;
use Stancl\Tenancy\Facades\Tenancy;

class TenantContext
{
    public static function current(): ?Tenant
    {
        return Tenancy::tenant();
    }

    public static function id(): ?string
    {
        return Tenancy::tenant()?->getTenantKey();
    }

    public static function isActive(): bool
    {
        return Tenancy::tenant() !== null;
    }

    public static function run(Tenant $tenant, callable $callback): mixed
    {
        return tenancy()->runForMultiple([$tenant], $callback);
    }

    public static function runCentral(callable $callback): mixed
    {
        return tenancy()->runForMultiple([], $callback);
    }
}

Usage:

use App\Support\TenantContext;

$tenant = TenantContext::current();
$tenantId = TenantContext::id();

if (TenantContext::isActive()) {
    // Tenant-specific logic
}

TenantContext::run($tenant, function () {
    Order::create([...]);
});

TenantContext::runCentral(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(TenantContext::current(), $orderData);

Common Patterns

Running Code in Multiple Tenants

$tenants = Tenant::all();

foreach ($tenants as $tenant) {
    TenantContext::run($tenant, function () use ($tenant) {
        Order::where('status', 'pending')->update(['processed' => true]);
    });
}

Accessing Central Data from Tenant Context

TenantContext::runCentral(function () {
    $allTenants = Tenant::all();
});

Conditional Logic Based on Tenant

if (TenantContext::isActive()) {
    $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

Summary

Multi-tenancy provides:

  1. Clear separation - Central vs Tenanted namespaces
  2. Database isolation - Each tenant has dedicated database
  3. Automatic scoping - Queries automatically tenant-scoped
  4. Context helpers - Easy access to tenant context
  5. Queue integration - Jobs preserve tenant context

Best practices:

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

laravel-testing

No summary provided by upstream source.

Repository SourceNeeds Review
General

laravel-quality

No summary provided by upstream source.

Repository SourceNeeds Review
General

laravel-architecture

No summary provided by upstream source.

Repository SourceNeeds Review
General

laravel-actions

No summary provided by upstream source.

Repository SourceNeeds Review