【現場完全ガイド】LaravelのマルチテナントSaaS設計――テナント分離(DB/スキーマ/行)、ドメイン/URL、課金・権限、監査、パフォーマンス、アクセシブルな管理画面
この記事で学べること(要点)
- マルチテナントの方式(DB分離/スキーマ分離/行分離)と選び方、移行戦略
- テナント解決(サブドメイン/カスタムドメイン/URLプレフィックス)とミドルウェア実装
- テナント境界を「構造で守る」:グローバルスコープ、ポリシー、ストレージ、キャッシュ、キュー
- 課金(プラン/席数/使用量)、権限(ロール/RBAC)、招待、組織管理の基本設計
- 監査ログ、データ保持、削除/退会、バックアップ/リストア、インシデント対応
- パフォーマンス最適化(インデックス、集計素材化、ジョブ分離)
- 管理画面のアクセシビリティ(キーボード操作、表/フォーム、色非依存、読み上げ、エラー導線)
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:単一アプリをSaaS化し、複数組織のデータを安全に扱いたい方
- テックリード/CTO:分離方式と運用コストのバランスを判断し、標準アーキテクチャを固めたい方
- PM/CS/法務/セキュリティ担当:課金・権限・監査・データ削除など“運用の現実”まで含めて設計したい方
- デザイナー/QA/アクセシビリティ担当:組織管理画面を「だれでも使える」形で整えたい方
アクセシビリティレベル:★★★★★
組織切替、招待、権限設定、課金画面の入力エラー、テーブルの並び替え、通知、ライブ更新、色に依存しない状態表示まで設計・文言・実装を具体化します。
1. はじめに:マルチテナントで一番怖いのは“境界の漏れ”です
マルチテナントSaaSは、1つのアプリで複数の組織(テナント)を扱います。最大のリスクは、テナントAのユーザーがテナントBのデータを見てしまうことです。これはバグでも運用ミスでも起き得ます。だからこそ、境界を「人の注意」ではなく「構造」で守る設計が重要になります。
本記事では、Laravelでマルチテナントを実装する際の現場の型を、方式選定からミドルウェア、データ分離、課金と権限、監査、性能、アクセシビリティまで一気通貫でまとめます。
2. マルチテナント方式:DB分離 / スキーマ分離 / 行分離
2.1 方式の比較(ざっくり)
- DB分離(テナントごとにDB)
- 強い:境界が最も堅牢、バックアップ/削除が簡単、法務要件に強い
- 弱い:DB数が増え、運用が重くなる(接続管理、マイグレーション、監視)
- 向く:エンタープライズ、厳格な分離要求、少〜中テナント
- スキーマ分離(同一DB内でスキーマを分ける)
- 強い:DB分離より軽いが、境界は堅い
- 弱い:DB製品依存、運用の複雑さは残る
- 向く:PostgreSQL などスキーマ運用が得意な環境
- 行分離(同一テーブルに
tenant_id)- 強い:運用が最も軽い、スケールしやすい(アプリは1セット)
- 弱い:実装ミスが境界漏れに直結、インデックス設計が重要
- 向く:スタートアップ/SMB向けSaaS、テナント数が多い
2.2 まずの結論(現場で多い形)
- 最初は行分離で始め、境界を構造で守る(スコープ・ミドルウェア・テスト)。
- 分離要件が厳しい顧客が出たら、エンタープライズ用に DB分離を追加する「二階建て」も現実的です。
3. テナント解決:どの組織のリクエストかを決める
3.1 代表的な方式
- サブドメイン:
acme.example.com - カスタムドメイン:
app.acme.co.jp(DNS設定が必要) - URLプレフィックス:
example.com/t/acme(最も簡単、SEO/共有に明示的)
SaaSとして一般的なのはサブドメインです。カスタムドメインはエンタープライズで要求されやすいので、後から追加できる設計にしておくと安心です。
3.2 ミドルウェアでテナント確定
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
class ResolveTenant
{
public function handle($request, Closure $next)
{
// 例:サブドメインから取得
$host = $request->getHost(); // acme.example.com
$slug = explode('.', $host)[0]; // acme
$tenant = Tenant::where('slug', $slug)->first();
abort_if(!$tenant, 404);
app()->instance('tenant', $tenant);
return $next($request);
}
}
// helper
function tenant(): \App\Models\Tenant {
return app('tenant');
}
3.3 ルートに適用
Route::middleware(['resolve.tenant', 'auth'])->group(function () {
Route::get('/dashboard', DashboardController::class)->name('dashboard');
});
4. 境界を守る:グローバルスコープで“漏れ”を構造的に防ぐ
行分離では、全テーブルに tenant_id を入れて、クエリに必ず条件を付けます。これを人の手で毎回やるのは危険なので、Laravelのグローバルスコープで強制します。
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (app()->bound('tenant')) {
$builder->where($builder->getModel()->getTable().'.tenant_id', tenant()->id);
}
});
static::creating(function ($model) {
if (app()->bound('tenant') && empty($model->tenant_id)) {
$model->tenant_id = tenant()->id;
}
});
}
}
class Project extends Model {
use \App\Models\Concerns\BelongsToTenant;
protected $fillable = ['name','tenant_id'];
}
ポイント
creatingでtenant_idを自動付与し、付け忘れを防ぐ。- 管理者が全テナントを横断する画面(運用者用)では
withoutGlobalScope('tenant')を慎重に使う。 - 横断系は別の接続/別アプリに分けるとさらに安全です。
5. 認可:テナント境界+ロール(RBAC)で二重に守る
5.1 ロール設計例
- Owner(契約/課金/削除まで)
- Admin(設定/メンバー管理)
- Member(通常操作)
- Viewer(閲覧のみ)
5.2 Policyの例(テナント一致+権限)
public function update(User $user, Project $project): bool
{
if ($project->tenant_id !== $user->tenant_id) return false;
return $user->hasRole('admin') || $user->hasRole('owner');
}
- 境界は「スコープ」と「認可」で二重化すると強いです。
- 招待リンク経由の参加は、テナントを明示し、誤参加や取り違えを防ぎます。
6. 課金(Billing):プラン・席数・使用量の現実的な分け方
6.1 課金モデルの基本
- プラン(月額/年額):機能の上限(プロジェクト数、ストレージ容量)
- 席数(シート):利用者数
- 使用量:APIコール/ストレージ/メッセージ数
最初から全部はやらず、
- プラン+席数(またはプランのみ)
から始めるのが現実的です。
6.2 使用量カウントの落とし穴
- リトライや重複で二重計上しない(冪等性キー)
- 集計はリアルタイムでなく、定期ジョブで素材化
- 監査可能なように、イベントログ(usage_events)を残す
7. ストレージ・キャッシュ・セッション:テナント単位で分離する
7.1 ストレージのパス分離
tenants/{tenant_id}/...を基本にして、誤参照を防ぐ- 署名付きURLで配布し、public直置きを避ける
$path = "tenants/".tenant()->id."/uploads/".$filename;
Storage::disk('s3')->put($path, $content);
7.2 キャッシュキーの名前空間
$key = "t:".tenant()->id.":projects:all";
Cache::remember($key, 300, fn()=> Project::orderBy('id')->get());
- キャッシュは漏れやすいので、キーに必ずテナントIDを入れます。
7.3 キュー(Jobs)のテナント伝搬
ジョブには tenant_id を渡し、処理時にテナントコンテキストを復元します。
class RecalcUsage implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function handle()
{
app()->instance('tenant', Tenant::findOrFail($this->tenantId));
// 以降 tenant() が使える
}
}
8. データ削除と保持:退会(解約)を設計に含める
- 論理削除(一定期間復元可能)→ 物理削除(完全消去)
- バックアップにも削除が反映される運用(保持期限)
- 法務/契約上の要件(請求書の保存など)を先に確認
8.1 退会フロー例
- Ownerのみ実行可
- 二段階確認(組織名の再入力)
- 影響範囲の説明(削除されるデータ、保持される請求情報)
- 完了後のサポート導線
アクセシビリティとしては、警告を色だけに頼らず、見出しと箇条書きで明確にします。
9. 監査ログ:誰が何をしたかをテナント単位で残す
9.1 監査ログの基本項目
tenant_idactor_user_idaction(例:member.invited、role.changed)target_type/target_idbefore/after(必要最小限)ip/user_agent/trace_idcreated_at
AuditLog::create([
'tenant_id' => tenant()->id,
'actor_user_id' => auth()->id(),
'action' => 'member.invited',
'target_type' => 'user',
'target_id' => $invitee->id,
'meta' => ['email_masked' => mask_email($invitee->email)],
'trace_id' => request()->header('X-Trace-Id'),
]);
10. パフォーマンス:行分離のボトルネックを潰す
10.1 インデックスの鉄則
- ほぼすべてのテーブルで、
(tenant_id, id)や(tenant_id, created_at)を検討 - よく使う検索条件は
(tenant_id, status, created_at)のように複合化 - ユニーク制約もテナント単位に:
unique(tenant_id, slug)
10.2 集計は素材化
ダッシュボードの集計を都度計算すると重いので、
daily_usageやtenant_statsのような素材テーブルに定期集計- 画面はそれを読むだけ
にすると、テナント数が増えても安定します。
10.3 アーカイブ
古い監査ログやイベントログは、運用要件に合わせて
- 別テーブル
- 別DB
- オブジェクトストレージ
へ移すと、主要テーブルの性能が守れます。
11. 管理画面UX:組織切替・招待・権限の“事故”を防ぐ
11.1 組織切替(テナントスイッチャ)
- 現在の組織名を常に表示
- 切替後はページタイトルにフォーカス
- 組織IDだけでなく、組織名と説明を併記
<nav aria-label="組織の切り替え">
<p>現在の組織:{{ tenant()->name }}</p>
<ul>
@foreach($memberships as $m)
<li>
<a href="{{ $m->tenant->url }}" aria-current="{{ $m->tenant_id===tenant()->id ? 'true':'false' }}">
{{ $m->tenant->name }}
</a>
</li>
@endforeach
</ul>
</nav>
11.2 招待フローのアクセシビリティ
- 入力エラーは
role="alert"でまとめ、該当入力にaria-describedby - 招待メールはプレーンテキスト併用、リンク文言は具体的に
- 期限切れは419/403ではなく、招待専用の説明ページを用意
12. テナント境界のテスト:漏れを“CIで止める”
行分離の恐怖は「ある画面だけtenant条件が抜ける」ことです。これを防ぐには、テストで“漏れ”を再現して落とすのが一番です。
12.1 Featureテスト(別テナントのデータが見えない)
public function test_tenant_isolation()
{
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$u1 = User::factory()->create(['tenant_id'=>$t1->id]);
$p2 = Project::factory()->create(['tenant_id'=>$t2->id]);
$this->actingAs($u1);
app()->instance('tenant', $t1);
$res = $this->get('/projects');
$res->assertOk();
$res->assertDontSee($p2->name);
}
12.2 監査ログのテスト
- 権限変更・招待・課金変更など重要操作で必ず監査ログが残るか確認します。
13. インシデント対応:境界漏れのための最低限Runbook
- 影響範囲(どのテナント/期間/機能)を特定
- ログ(
trace_id、tenant_id、user_id)で検索 - 一時遮断(該当機能OFF、権限制限、メンテページ)
- 修正と再発防止(テスト追加、スコープ強制、レビュー手順)
- ユーザー告知(説明・影響・再発防止・問い合わせ窓口)
SaaSは「隠す」より「早く説明して直す」方が信頼につながります。
14. よくある落とし穴と回避策
- あるクエリだけ
tenant_idを付け忘れる- 回避:グローバルスコープ+creating付与、横断操作は別アプリ
- キャッシュキーにテナントが入っていない
- 回避:キー命名に
t:{tenant_id}を必須化
- 回避:キー命名に
- ジョブがテナントコンテキスト無しで動く
- 回避:ジョブに
tenant_idを渡し、handleで復元
- 回避:ジョブに
- ユニーク制約がテナント単位でない
- 回避:
unique(tenant_id, ...)へ
- 回避:
- 課金がUIと実態でズレる
- 回避:素材テーブルと監査ログ、変更イベントを冪等に
- 組織切替が分かりにくい
- 回避:現在組織を常時表示、切替後に見出しへフォーカス
- 退会/削除が未設計
- 回避:保持/削除/バックアップの方針を先に決める
15. チェックリスト(配布用)
方式/境界
- [ ] 分離方式を選定(行/スキーマ/DB)と移行戦略
- [ ] テナント解決ミドルウェア(サブドメイン/カスタムドメイン/URL)
- [ ] グローバルスコープで
tenant_idを強制 - [ ] Policyでテナント一致+ロールの二重防御
データ/周辺機能
- [ ] ストレージパス分離、署名URL
- [ ] キャッシュキーにテナントID
- [ ] ジョブに
tenant_id伝搬 - [ ] ユニーク制約・インデックスに
tenant_idを含める
運用
- [ ] 監査ログ(重要操作の記録)
- [ ] 退会/削除/保持/バックアップの方針
- [ ] インシデントRunbook
課金/権限
- [ ] プラン/席数/使用量のスコープ
- [ ] 招待フローと期限/失効
- [ ] RBAC(Owner/Admin/Member/Viewer)
アクセシビリティ
- [ ] 組織切替の明示と
aria-current - [ ] フォームエラーの
role="alert"、aria-describedby - [ ] 色だけに依存しない状態表示
- [ ] テーブル/一覧のキーボード操作と見出し構造
テスト
- [ ] クロステナント参照が起きないテスト
- [ ] 監査ログが残るテスト
- [ ] キャッシュ/ジョブのテナント分離テスト
16. まとめ
マルチテナントSaaSは、境界の漏れをいかに防ぐかが核心です。Laravelでは、テナント解決ミドルウェアとグローバルスコープを軸に「構造で守る」設計ができます。そこへRBAC、課金、監査ログ、削除/保持、性能最適化を積み上げると、運用に耐えるSaaSになります。さらに、組織切替や招待、権限設定の画面は、アクセシビリティを意識して作るほど事故が減ります。落ち着いて設計し、テストで漏れを塞ぎ、長く信頼されるSaaSに育てていきましょう。
参考リンク
- Laravel 公式
- マルチテナント実装の定番パッケージ
- セキュリティ/運用
- アクセシビリティ
