mcpField
L
laravel-multi-tenancy

laravel-multi-tenancy

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

关于 laravel-multi-tenancy

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/agentic-skills --skill laravel-multi-tenancy

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

包含的文件

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

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

详细介绍

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
└── Services/
    └── Tenancy/
        ├── Landlord.php
        └── Facades/
            └── Landlord.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,
            ]);
        }
    }
}

Landlord 辅助类

将 Stancl Tenancy 封装在 Landlord 服务类中,以获得更简洁的 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);
    }
}

用法:

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

租户识别中间件

基于域名
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(Landlord::tenant(), $orderData);

常见模式

在多个租户中运行代码
// 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]);
    });
}
在租户上下文中访问中央数据
Landlord::runAsCentral(function () {
    $allTenants = Tenant::all();
});
根据租户执行条件逻辑
if (Landlord::tenant() !== null) {
    $orders = Order::all(); // Scoped to tenant
} else {
    $tenants = Tenant::all(); // Central
}

测试

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

包括:

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

最佳实践

  • 使用目录结构区分中央和租户操作/DTO
  • 按照 Laravel 约定,将模型放在 app/Models/
  • 始终使用 Landlord 辅助类访问租户
  • 分别测试中央和租户上下文
  • 在队列任务中保留租户上下文

评论

支持 Markdown