php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【現場完全ガイド】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'];
}

ポイント

  • creatingtenant_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_id
  • actor_user_id
  • action(例:member.invitedrole.changed
  • target_type / target_id
  • before / after(必要最小限)
  • ip / user_agent / trace_id
  • created_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_usagetenant_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_idtenant_iduser_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に育てていきましょう。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)