#24 FilamentPHP応用

リピーターとネストされたフォーム——Repeater/Nested Fieldsの設計

Repeaterコンポーネントとは

Repeaterは同じフォームフィールドのセットを動的に追加・削除・並び替えできるコンポーネントです。ECサイトの注文明細・FAQのQ&A・商品のバリエーションなど、「同じ構造のデータが複数ある」場面で使います。

基本的なRepeaterの実装

use Filament\Forms\Components\Repeater;

Repeater::make('social_links')
    ->label('SNSリンク')
    ->schema([
        Select::make('platform')
            ->label('プラットフォーム')
            ->options([
                'twitter'   => 'X (Twitter)',
                'instagram' => 'Instagram',
                'facebook'  => 'Facebook',
                'linkedin'  => 'LinkedIn',
                'github'    => 'GitHub',
            ])
            ->required(),

        TextInput::make('url')
            ->label('URL')
            ->url()
            ->required(),

        Toggle::make('is_primary')
            ->label('メインアカウント'),
    ])
    ->columns(3)
    ->defaultItems(1)        // 初期アイテム数
    ->minItems(0)
    ->maxItems(5)
    ->addActionLabel('SNSリンクを追加')
    ->reorderable()          // ドラッグで並び替え可能
    ->collapsible()          // 折りたたみ可能
    ->itemLabel(fn (array $state): ?string =>
        $state['platform'] ? "[{$state['platform']}] {$state['url']}" : null
    )

Eloquentリレーションとの連携

Repeaterをリレーション(HasMany)に直接紐付けることができます。

// 注文と注文明細のリレーション
// Order hasMany OrderItem

Repeater::make('orderItems')
    ->label('注文明細')
    ->relationship('orderItems')     // HasManyリレーション名を指定
    ->schema([
        Select::make('product_id')
            ->label('商品')
            ->options(Product::pluck('name', 'id'))
            ->live()
            ->afterStateUpdated(function (Get $get, Set $set): void {
                $product = Product::find($get('product_id'));
                if ($product) {
                    $set('unit_price', $product->price);
                    $set('name', $product->name);
                }
            })
            ->required(),

        TextInput::make('name')
            ->label('商品名')
            ->required(),

        TextInput::make('unit_price')
            ->label('単価')
            ->numeric()
            ->prefix('¥')
            ->required(),

        TextInput::make('quantity')
            ->label('数量')
            ->numeric()
            ->default(1)
            ->minValue(1)
            ->required(),
    ])
    ->columns(4)

Repeaterに->relationship()を指定すると、フォーム保存時に自動的にリレーション先のレコードを作成・更新・削除してくれます。

折りたたみとラベル表示

複数アイテムがある場合にUIを整理するための設定です。

Repeater::make('faq_items')
    ->label('FAQ')
    ->relationship('faqItems')
    ->schema([
        TextInput::make('question')
            ->label('質問')
            ->required(),

        RichEditor::make('answer')
            ->label('回答')
            ->required()
            ->toolbarButtons(['bold', 'italic', 'link', 'bulletList']),
    ])
    ->collapsible()
    ->collapsed(false)       // デフォルトで展開
    ->itemLabel(fn (array $state): ?string =>
        empty($state['question']) ? null : Str::limit($state['question'], 50)
    )
    ->addActionLabel('Q&Aを追加')
    ->reorderableWithDragAndDrop()
    ->orderColumn('sort_order')  // 並び順カラム

ネストされたRepeater

Repeaterの中にRepeaterを入れることもできます(商品のオプショングループとその値など)。

Repeater::make('option_groups')
    ->label('オプショングループ')
    ->schema([
        TextInput::make('name')
            ->label('グループ名(例: サイズ・カラー)')
            ->required(),

        Repeater::make('options')
            ->label('オプション値')
            ->schema([
                TextInput::make('value')
                    ->label('値(例: S, M, L)')
                    ->required(),

                TextInput::make('price_adjustment')
                    ->label('価格調整')
                    ->numeric()
                    ->prefix('¥')
                    ->default(0),
            ])
            ->columns(2)
            ->minItems(1)
            ->addActionLabel('値を追加'),
    ])
    ->addActionLabel('オプショングループを追加')
    ->maxItems(3)

並び替え順の保存

Repeaterのアイテムを並び替え可能にする場合、順序をDBに保存する必要があります。

// マイグレーション
Schema::table('faq_items', function (Blueprint $table) {
    $table->integer('sort_order')->default(0);
});

// Repeaterの設定
Repeater::make('faqItems')
    ->relationship('faqItems')
    ->schema([...])
    ->reorderable()
    ->orderColumn('sort_order')

// モデルのスコープ
class FaqItem extends Model
{
    protected static function booted(): void
    {
        static::creating(function (FaqItem $item): void {
            $item->sort_order = FaqItem::where('post_id', $item->post_id)->max('sort_order') + 1;
        });
    }
}

Grid・Section内でのRepeater使用

RepeaterはcolumnSpanFull()でグリッドレイアウトをまたいで全幅表示にするのが一般的です。

Section::make('注文明細')
    ->schema([
        Repeater::make('orderItems')
            ->relationship('orderItems')
            ->schema([...])
            ->columnSpanFull(),

        Placeholder::make('total')
            ->label('合計金額')
            ->content(fn (Get $get): string => '¥' . number_format(
                collect($get('orderItems') ?? [])->sum(fn ($item) =>
                    ($item['unit_price'] ?? 0) * ($item['quantity'] ?? 0)
                )
            )),
    ])

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

コツ: ->itemLabel()にクロージャを渡すと各アイテムのヘッダーに動的なラベルを表示できます。Repeaterを折りたたんだ状態でもどのアイテムか分かるようになるため、UXが大きく向上します。

注意点: Repeaterと->relationship()を使う場合、リレーション先モデルの$fillableが正しく設定されていないとエラーになります。Repeaterのフィールド名とモデルの$fillableの名前を一致させてください。

ハマりポイント: ネストされたRepeater内での$get()$set()は、デフォルトで最も近い親のRepeaterアイテムを基準とした相対パスになります。外側のRepeaterの値を参照したい場合は'../../outer_field'のような相対パスを使います。また、ネストが深くなるとパフォーマンスに影響するため、3階層以上のネストは避けることを推奨します。

まとめ

Repeaterコンポーネントは->relationship()と組み合わせることでHasManyリレーションの管理を完全に自動化できます。->orderColumn()による並び替え・折りたたみ・ネストを活用することで、複雑なデータ構造を持つフォームも整理されたUIで実装できます。