#16 FilamentPHP応用

ポリモーフィックリレーションのフォーム——MorphToフィールドの実装

ポリモーフィックリレーションとは

ポリモーフィック(多態的)リレーションは、1つのモデルが複数の異なるモデルに属せる仕組みです。例えばCommentモデルがPostにもVideoにも属せる場合、テーブルにはcommentable_idcommentable_typeという2カラムが生成されます。

// コメントモデルの例
class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

MorphToSelectフィールドの基本

Filamentはポリモーフィックリレーションのためのフォームフィールドを提供しています。

use Filament\Forms\Components\MorphToSelect;

MorphToSelect::make('commentable')
    ->label('コメント対象')
    ->types([
        MorphToSelect\Type::make(Post::class)
            ->titleAttribute('title')
            ->label('記事'),

        MorphToSelect\Type::make(Video::class)
            ->titleAttribute('title')
            ->label('動画'),
    ])
    ->searchable()
    ->required()

これで「タイプ選択(記事/動画)」→「IDの選択」という2段階のセレクトボックスが自動生成されます。

カスタム検索クエリの設定

関連モデルが多い場合や特定の条件で絞り込みたい場合は、->getOptionLabelFromRecordUsing()->modifyOptionsQueryUsing()でカスタマイズします。

MorphToSelect::make('commentable')
    ->types([
        MorphToSelect\Type::make(Post::class)
            ->label('記事')
            ->titleAttribute('title')
            ->modifyOptionsQueryUsing(
                fn (Builder $query) => $query
                    ->where('status', 'published')
                    ->orderByDesc('published_at')
            )
            ->getOptionLabelFromRecordUsing(
                fn (Post $record): string =>
                    "[{$record->category->name}] {$record->title}"
            ),

        MorphToSelect\Type::make(Video::class)
            ->label('動画')
            ->titleAttribute('title')
            ->modifyOptionsQueryUsing(
                fn (Builder $query) => $query->where('published', true)
            ),
    ])
    ->preload()
    ->searchable()

タイプ別フィールドの動的切り替え

コメント対象のタイプによって追加フィールドを切り替えたい場合は->live()->visible()を組み合わせます。

use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Get;

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Select::make('commentable_type')
                ->label('対象タイプ')
                ->options([
                    Post::class  => '記事',
                    Video::class => '動画',
                    Product::class => '商品',
                ])
                ->live()        // 変更時に後続フィールドを再描画
                ->afterStateUpdated(fn (callable $set) => $set('commentable_id', null))
                ->required(),

            Select::make('commentable_id')
                ->label('対象レコード')
                ->options(function (Get $get): array {
                    $type = $get('commentable_type');
                    if (! $type || ! class_exists($type)) {
                        return [];
                    }
                    return $type::query()
                        ->limit(100)
                        ->pluck('title', 'id')
                        ->toArray();
                })
                ->searchable()
                ->live()
                ->required(),

            // 記事選択時のみ表示されるフィールド
            Select::make('section')
                ->label('コメント対象セクション')
                ->options(['intro' => 'イントロ', 'body' => '本文', 'conclusion' => '結論'])
                ->visible(fn (Get $get): bool => $get('commentable_type') === Post::class),

            // 動画選択時のみタイムスタンプを表示
            TextInput::make('timestamp')
                ->label('タイムスタンプ(秒)')
                ->numeric()
                ->visible(fn (Get $get): bool => $get('commentable_type') === Video::class),

            \Filament\Forms\Components\Textarea::make('body')
                ->label('コメント本文')
                ->required()
                ->rows(4),
        ]);
}

morphMapを使った型名の管理

MorphMapを登録することで、commentable_typeに完全クラス名(App\Models\Post)ではなく短縮名(post)を保存できます。

// app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Relations\Relation;

public function boot(): void
{
    Relation::morphMap([
        'post'    => \App\Models\Post::class,
        'video'   => \App\Models\Video::class,
        'product' => \App\Models\Product::class,
    ]);
}

MorphMapを使うと既存データの移行や将来的なリファクタリング時にクラス名変更の影響を受けません。FilamentのMorphToSelectもMorphMapを自動的に考慮します。

InfoListでのポリモーフィックリレーション表示

詳細ページでも対象レコードをリンク付きで表示できます。

use Filament\Infolists\Components\TextEntry;

TextEntry::make('commentable_type')
    ->label('対象タイプ')
    ->formatStateUsing(fn (string $state): string => match($state) {
        Post::class    => '記事',
        Video::class   => '動画',
        default        => $state,
    }),

TextEntry::make('commentable.title')
    ->label('対象タイトル')
    ->url(function (Comment $record): ?string {
        return match(true) {
            $record->commentable instanceof Post =>
                PostResource::getUrl('edit', ['record' => $record->commentable]),
            $record->commentable instanceof Video =>
                VideoResource::getUrl('edit', ['record' => $record->commentable]),
            default => null,
        };
    }),

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

コツ: MorphToSelectを使うとFilamentが自動的にcommentable_typecommentable_idの両方を設定してくれます。手動でSelectを2つ並べるよりも、MorphToSelectを使う方がシンプルに実装できます。

注意点: ポリモーフィックリレーションでN+1問題が起きやすいのは詳細ページやテーブル表示時です。with(['commentable'])でEager Loadingを設定しましょう。

ハマりポイント: MorphMapを使っている場合と使っていない場合でDBに保存される値が異なります。既存データがある場合に途中からMorphMapを導入すると整合性が崩れます。新規プロジェクトでは最初からMorphMapを設定することを強く推奨します。

まとめ

FilamentのMorphToSelectフィールドを使えばポリモーフィックリレーションのフォームが直感的に実装できます。->live()->visible()でタイプ別の動的フィールド切り替えも実現でき、複雑なデータ構造を持つアプリケーションでも使いやすい管理画面が構築できます。