#17 FilamentPHP応用

複数テナントとチーム管理——multitenancyとスコープの設計

マルチテナンシー応用の全体像

エピソード7でマルチテナンシーの基本を解説しました。このエピソードでは実際のSaaSアプリケーションで必要になる応用パターンを取り上げます。

  • チームメンバーへのロール割り当て
  • テナントスコープの細かな制御
  • スーパー管理者専用パネルの分離
  • メンバー招待フロー

チームロールの管理

チームメンバーには複数のロールを持たせることが一般的です。

// database/migrations/xxxx_create_team_user_table.php
Schema::create('team_user', function (Blueprint $table) {
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('role')->default('member'); // owner, admin, member, viewer
    $table->timestamp('joined_at')->nullable();
    $table->primary(['team_id', 'user_id']);
});
// app/Models/Team.php
class Team extends Model
{
    public function members(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withPivot('role', 'joined_at')
            ->withTimestamps();
    }

    public function owners(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->wherePivot('role', 'owner');
    }

    public function currentUserRole(): ?string
    {
        return $this->members()
            ->where('users.id', auth()->id())
            ->value('role');
    }
}

テナントメンバー管理Resource

チームメンバーを管理するResourceを作成します。

namespace App\Filament\Resources;

use App\Models\TeamMember;
use Filament\Resources\Resource;

class TeamMemberResource extends Resource
{
    protected static ?string $model = TeamMember::class;
    protected static ?string $navigationIcon = 'heroicon-o-users';
    protected static ?string $navigationLabel = 'メンバー管理';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Select::make('user_id')
                    ->label('ユーザー')
                    ->relationship('user', 'name')
                    ->searchable()
                    ->required(),

                Select::make('role')
                    ->label('ロール')
                    ->options([
                        'owner'  => 'オーナー',
                        'admin'  => '管理者',
                        'member' => 'メンバー',
                        'viewer' => '閲覧のみ',
                    ])
                    ->default('member')
                    ->required(),
            ]);
    }

    // テナントスコープの明示的な設定
    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            ->where('team_id', Filament::getTenant()->id);
    }
}

ロールに基づくResource表示制御

ロールによってResourceへのアクセスを制御します。

class PostResource extends Resource
{
    public static function canCreate(): bool
    {
        $role = Filament::getTenant()?->currentUserRole();
        return in_array($role, ['owner', 'admin', 'member']);
    }

    public static function canEdit(Model $record): bool
    {
        $role = Filament::getTenant()?->currentUserRole();
        if (in_array($role, ['owner', 'admin'])) {
            return true;
        }
        // memberは自分の記事のみ編集可
        return $role === 'member' && $record->user_id === auth()->id();
    }

    public static function canDelete(Model $record): bool
    {
        $role = Filament::getTenant()?->currentUserRole();
        return in_array($role, ['owner', 'admin']);
    }
}

スーパー管理者パネルの分離

一般ユーザー向けパネルとは別に、スーパー管理者専用のパネルを分離するのがSaaSの典型的な設計です。

// app/Providers/Filament/SuperAdminPanelProvider.php

class SuperAdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->id('super-admin')
            ->path('super-admin')
            ->login()
            ->authGuard('web')
            ->middleware([
                SuperAdminMiddleware::class,
            ])
            ->discoverResources(
                in: app_path('Filament/SuperAdmin/Resources'),
                for: 'App\\Filament\\SuperAdmin\\Resources'
            )
            ->resources([
                // 全テナントを管理できるResourceなど
                TenantResource::class,
                GlobalSettingResource::class,
            ]);
    }
}
// app/Http/Middleware/SuperAdminMiddleware.php

class SuperAdminMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! auth()->user()?->is_super_admin) {
            abort(403, 'スーパー管理者のみアクセスできます。');
        }

        return $next($request);
    }
}

メンバー招待フロー

メールアドレスでチームメンバーを招待する機能を実装します。

// app/Filament/Pages/InviteMember.php

class InviteMember extends Page implements HasForms
{
    use InteractsWithForms;

    protected static string $view = 'filament.pages.invite-member';
    protected static ?string $navigationLabel = 'メンバーを招待';

    public ?array $data = [];

    public function form(Form $form): Form
    {
        return $form
            ->schema([
                TextInput::make('email')
                    ->label('メールアドレス')
                    ->email()
                    ->required(),

                Select::make('role')
                    ->label('付与するロール')
                    ->options([
                        'admin'  => '管理者',
                        'member' => 'メンバー',
                        'viewer' => '閲覧のみ',
                    ])
                    ->default('member')
                    ->required(),
            ])
            ->statePath('data');
    }

    public function invite(): void
    {
        $data = $this->form->getState();
        $team = Filament::getTenant();

        // 既存ユーザーかチェック
        $user = User::where('email', $data['email'])->first();

        if ($user) {
            // 既存ユーザーは即座に追加
            $team->members()->syncWithoutDetaching([
                $user->id => ['role' => $data['role'], 'joined_at' => now()],
            ]);
        } else {
            // 新規ユーザーへの招待メールを送信
            TeamInvitation::create([
                'team_id' => $team->id,
                'email'   => $data['email'],
                'role'    => $data['role'],
                'token'   => Str::random(64),
            ]);
            // Mail::to($data['email'])->send(new TeamInvitationMail($invitation));
        }

        Notification::make()
            ->title('招待を送信しました')
            ->success()
            ->send();

        $this->form->fill();
    }
}

テナントスコープの細かな制御

特定のResourceだけテナントスコープを無効にする方法は基本編で触れましたが、スコープを部分的にカスタマイズすることもできます。

class PostResource extends Resource
{
    // テナントリレーション名を明示(デフォルトは「team」)
    public static function getTenantRelationshipName(): string
    {
        return 'team';
    }

    // テナント所有権の自動割り当て(createTime)
    public static function getEloquentQuery(): Builder
    {
        $query = parent::getEloquentQuery();

        // テナントスコープに加えて追加の条件
        return $query->whereNull('archived_at');
    }
}

コツ・注意点・ハマりポイント

コツ: Filament::getTenant()はミドルウェアで解決されたテナントインスタンスを返します。モデルのメソッド内からではなく、FilamentのResourceやPage内から参照するように設計すると、テストが書きやすくなります。

注意点: スーパー管理者パネルを分離する場合、マイグレーションやシーダーでis_super_adminフラグ等を使いますが、このフラグを誰でも変更できるような穴がないか必ずセキュリティレビューしてください。

ハマりポイント: マルチパネル構成の場合、ルートキャッシュとセッション管理がパネルごとに独立しています。ログイン状態の共有が必要な場合は同一のauthGuardを使うか、SSOを検討してください。

まとめ

FilamentのマルチテナンシーはSaaSアプリケーションの管理画面構築に十分な機能を提供しています。ロール管理・スーパー管理者パネルの分離・招待フローを組み合わせることで、本格的なチーム管理機能を持つ管理画面が実現できます。