laravel-multi-tenancy について
Laravel のマルチテナントアプリを実装する開発者向けのガイドで、中央コンテキストとテナントコンテキストの構成パターンを示します。データベース分離、クエリの自動スコープ、テナント識別、キュー内のコンテキスト保持を扱います。
主な機能
- 01中央とテナントのアクションを分離
- 02テナントごとにデータベースを使用
- 03現在のテナントにクエリを限定
- 04ドメイン、サブドメイン、ヘッダーで識別
- 05キュージョブでテナントコンテキストを保持
活用シーン
- 01厳格なデータ分離が必要な SaaS の構築
- 02コンテキスト別のルート、モデル、アクション整理
- 03複数テナントにまたがる処理
- 04中央とテナントのコンテキストのテスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ leeovery/ agentic-skills --skill laravel-multi-tenancyインストール後、Claude Code で/laravel-multi-tenancyを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/ManagesTenants.phpRefreshDatabaseWithTenant.phptenancy-testing.mdTenancyPest.phpTenancyTestCase.phpTenantTestCase.php詳細
Laravel マルチテナンシー
マルチテナンシーでは、アプリケーションロジックをセントラル(非テナント)とテナント(テナント固有)のコンテキストに分離します。
関連ガイド:
- tenancy-testing.md - マルチテナント機能のテスト
- Actions - セントラルとテナントのアクションの整理
- Models - モデルの整理
- structure.md - ディレクトリの整理
基本方針
マルチテナンシーには、次の利点があります。
- セントラルとテナントのコンテキストを明確に分離できます
- テナントごとに個別のデータベースを用意してデータを分離できます
- クエリを現在のテナントに自動的にスコープ設定できます
- ヘルパークラスを通じてコンテキストを認識できます
- テナントのコンテキストを保持してキューと連携できます
使用する場合
次の場合はマルチテナンシーを使用します。
- データを完全に分離した 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/ に配置します。セントラルかテナントかは、サブディレクトリではなくトレイトやインターフェースで区別します。
セントラルモデル
<?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 トレイト
- TenantTestCase のセットアップ
- マルチテナンシー向け Pest の設定
- テストディレクトリの構造
ベストプラクティス
- ディレクトリ構造を使って、セントラルとテナントのアクション/DTO を分けます
- Laravel の規約に従い、モデルは
app/Models/に配置します - テナントへのアクセスには、必ず Landlord ヘルパーを使用します
- セントラルとテナントの両方のコンテキストを個別にテストします
- キュージョブでテナントのコンテキストを保持します
コメント
Markdown 対応