mcpField
L
laravel-multi-tenancy

laravel-multi-tenancy

github.com/leeovery开发4393 次安装31 周前更新
代码仓库

关于 laravel-multi-tenancy

面向 Laravel 开发者的多租户项目组织与实现指南,区分中央和租户上下文。涵盖独立数据库、查询自动作用域、租户上下文辅助类及队列处理。

主要功能

  1. 01区分中央与租户操作
  2. 02每个租户使用独立数据库
  3. 03查询自动按租户限定
  4. 04通过辅助类访问租户上下文
  5. 05队列任务保留租户上下文

适用场景

  1. 01为 SaaS 应用隔离客户数据
  2. 02按域名、子域名或请求头识别租户
  3. 03在多个租户上下文中运行代码
  4. 04测试中央与租户功能

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/laravel-multi-tenancy确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

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

详细介绍

Laravel 多租户

多租户将应用逻辑分为中央(非租户)和租户(特定租户)上下文。

相关指南:

理念

多租户提供:

  • 清晰分离中央与租户上下文
  • 数据库隔离,为每个租户使用独立数据库
  • 自动作用域,将查询限定在当前租户
  • 通过辅助类实现上下文感知
  • 与队列集成,保留租户上下文

适用场景

在以下情况下使用多租户:

  • 构建需要完全隔离数据的 SaaS 应用
  • 每位客户都需要自己的数据库
  • 合规要求严格的数据分离

在以下情况下不要使用:

  • 简单的用户分组已足够(使用 user_id 作用域)
  • 所有客户共用相同的架构
  • 应用复杂度不足以证明额外开销合理

目录结构

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

中央操作

中央操作用于管理租户和执行跨租户操作。

<?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,
        ]);
    }
}

租户操作

租户操作在特定租户的上下文中运行。所有查询都会自动限定作用域。

<?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,
            ]);
        }
    }
}

租户上下文辅助工具

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

用法:

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([...]);
});

租户识别中间件

基于域名
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;

class IdentifyTenant extends InitializeTenancyByDomain
{
    // Tenant identified by domain (e.g., tenant1.myapp.com)
}
基于子域名
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;

class IdentifyTenant extends InitializeTenancyBySubdomain
{
    // Tenant identified by subdomain
}
基于标头
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;

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

路由配置

租户路由
// routes/tenant.php
Route::middleware(['tenant'])->group(function () {
    Route::get('/orders', [OrderController::class, 'index']);
    Route::post('/orders', [OrderController::class, 'store']);
});
中央路由
// routes/central.php
Route::middleware(['central'])->prefix('central')->group(function () {
    Route::get('/tenants', [TenantController::class, 'index']);
    Route::post('/tenants', [TenantController::class, 'store']);
});
引导配置
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();

模型

所有模型都放在 app/Models/ 中。中央模型与租户模型通过 trait 或接口区分,而不是通过子目录区分。

中央模型
<?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);
    }
}
租户模型
<?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);
    }
}

带租户上下文的队列任务

排入队列时,任务必须保留租户上下文。

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

分派:

ProcessOrderJob::dispatch(TenantContext::current(), $orderData);

常见模式

在多个租户中运行代码
$tenants = Tenant::all();

foreach ($tenants as $tenant) {
    TenantContext::run($tenant, function () use ($tenant) {
        Order::where('status', 'pending')->update(['processed' => true]);
    });
}
在租户上下文中访问中央数据
TenantContext::runCentral(function () {
    $allTenants = Tenant::all();
});
根据租户执行条件逻辑
if (TenantContext::isActive()) {
    $orders = Order::all(); // Scoped to tenant
} else {
    $tenants = Tenant::all(); // Central
}

测试

→ 完整测试指南:tenancy-testing.md

包括:

  • 测试中央操作和租户操作
  • ManagesTenants 和 RefreshDatabaseWithTenant traits
  • TenantTestCase 设置
  • 多租户的 Pest 配置
  • 测试目录结构

总结

多租户提供:

  1. 清晰分离 - 中央与租户命名空间
  2. 数据库隔离 - 每个租户都有专用数据库
  3. 自动作用域 - 查询会自动限定到租户
  4. 上下文辅助工具 - 轻松访问租户上下文
  5. 队列集成 - 任务会保留租户上下文

最佳实践:

  • 使用目录结构分离中央和租户操作/DTO
  • 遵循 Laravel 惯例,将模型放在 app/Models/ 中
  • 始终使用 TenantContext 辅助工具访问租户
  • 分别测试中央和租户上下文
  • 在排入队列的任务中保留租户上下文

评论

支持 Markdown