#21 FilamentPHP応用

動的フォームフィールド——Select選択内容で他フィールドが変わるリアクティブフォーム

リアクティブフォームとは

ユーザーの入力に応じてフォームの構成が変化する「リアクティブフォーム」は、管理画面のUXを大きく向上させます。例えば、「都道府県を選択すると市区町村の選択肢が絞られる」「カテゴリに応じて追加フィールドが表示される」といったパターンです。

FilamentはLivewireをベースにしているため、->live()メソッド1つで動的フォームが実現できます。

->live() の基本

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

Select::make('country')
    ->label('国')
    ->options([
        'JP' => '日本',
        'US' => 'アメリカ',
        'GB' => 'イギリス',
    ])
    ->live()  // 変更のたびにLivewireがフォームを再描画

->live()を付けると、フィールドの値が変わるたびにサーバーに通信してフォーム全体を再レンダリングします。デフォルトはonChange(即座)ですが、->live(debounce: 500)でデバウンスも設定できます。

->afterStateUpdated() で値変更時の処理

値が変わったときに他のフィールドの値をリセットしたい場合:

Select::make('country')
    ->options([...])
    ->live()
    ->afterStateUpdated(function (callable $set): void {
        $set('prefecture', null);  // 都道府県をリセット
        $set('city', null);        // 市区町村もリセット
    })

連鎖するSelectフィールド(都道府県→市区町村)

use Filament\Forms\Get;
use Filament\Forms\Set;

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Select::make('prefecture')
                ->label('都道府県')
                ->options(Prefecture::all()->pluck('name', 'id'))
                ->searchable()
                ->live()
                ->afterStateUpdated(fn (Set $set) => $set('city_id', null))
                ->required(),

            Select::make('city_id')
                ->label('市区町村')
                ->options(function (Get $get): array {
                    $prefectureId = $get('prefecture');
                    if (! $prefectureId) {
                        return [];
                    }
                    return City::where('prefecture_id', $prefectureId)
                        ->pluck('name', 'id')
                        ->toArray();
                })
                ->searchable()
                ->live()
                ->required()
                ->disabled(fn (Get $get): bool => ! $get('prefecture')),
        ]);
}

$get()で他フィールドの現在値を取得し、$set()で他フィールドの値を変更できます。これがリアクティブフォームの核心です。

カテゴリによるフィールドの動的表示

カテゴリ選択によって追加フィールドを表示/非表示にする例:

Select::make('product_type')
    ->label('商品タイプ')
    ->options([
        'physical'  => '物理商品',
        'digital'   => 'デジタル商品',
        'service'   => 'サービス',
    ])
    ->live()
    ->required(),

// 物理商品のみ表示
TextInput::make('weight')
    ->label('重量(g)')
    ->numeric()
    ->visible(fn (Get $get): bool => $get('product_type') === 'physical'),

TextInput::make('dimensions')
    ->label('サイズ(cm)')
    ->visible(fn (Get $get): bool => $get('product_type') === 'physical'),

// デジタル商品のみ表示
TextInput::make('download_url')
    ->label('ダウンロードURL')
    ->url()
    ->visible(fn (Get $get): bool => $get('product_type') === 'digital'),

// サービスのみ表示
Select::make('service_duration')
    ->label('提供期間')
    ->options([
        '1month' => '1ヶ月',
        '3months' => '3ヶ月',
        '1year' => '1年',
    ])
    ->visible(fn (Get $get): bool => $get('product_type') === 'service'),

->required() の動的変更

フィールドの必須/任意も動的に切り替えられます。

Toggle::make('has_expiry')
    ->label('有効期限あり')
    ->live(),

DatePicker::make('expires_at')
    ->label('有効期限')
    ->required(fn (Get $get): bool => (bool) $get('has_expiry'))
    ->visible(fn (Get $get): bool => (bool) $get('has_expiry')),

価格計算フォームの実例

入力値から合計を自動計算して表示するフォームです。

TextInput::make('unit_price')
    ->label('単価')
    ->numeric()
    ->prefix('¥')
    ->live(debounce: 300)
    ->afterStateUpdated(function (Get $get, Set $set): void {
        $qty   = (float) ($get('quantity') ?? 0);
        $price = (float) ($get('unit_price') ?? 0);
        $set('subtotal', $qty * $price);
    }),

TextInput::make('quantity')
    ->label('数量')
    ->numeric()
    ->default(1)
    ->live(debounce: 300)
    ->afterStateUpdated(function (Get $get, Set $set): void {
        $qty   = (float) ($get('quantity') ?? 0);
        $price = (float) ($get('unit_price') ?? 0);
        $set('subtotal', $qty * $price);
    }),

Placeholder::make('subtotal')
    ->label('小計')
    ->content(fn (Get $get): string =>
        '¥' . number_format((float)($get('subtotal') ?? 0))
    ),

Repeater内でのリアクティブフォーム

Repeater内のフィールドでも$get()$set()は機能しますが、相対パスを考慮する必要があります。

Repeater::make('order_items')
    ->schema([
        Select::make('product_id')
            ->label('商品')
            ->options(Product::pluck('name', 'id'))
            ->live()
            ->afterStateUpdated(function (Get $get, Set $set): void {
                // Repeater内の同じ行の 'unit_price' を更新
                $product = Product::find($get('product_id'));
                $set('unit_price', $product?->price ?? 0);
            }),

        TextInput::make('unit_price')
            ->label('単価')
            ->numeric()
            ->readOnly(),

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

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

コツ: ->live()はフィールドの変更ごとにLivewireのネットワークリクエストが発生します。テキスト入力に->live()を付ける場合は必ずdebounceを設定してください。->live(debounce: 500)で500ms後にリクエストが発生します。

注意点: ->visible()はHTMLの表示/非表示を制御しますが、非表示フィールドの値もフォームデータに含まれる場合があります。送信時に不要なデータを除外するには->dehydrated(fn (Get $get) => ...)を使います。

ハマりポイント: Repeater内の$get()はデフォルトで同じ行のフィールドを参照します。Repeaterの外のフィールドを参照したい場合は$get('../../parent_field')のような相対パスか、$get(fn (Get $get) => ...)コールバック形式を使う必要があります。

まとめ

->live()->afterStateUpdated()$get()$set()を組み合わせることで、Filamentのフォームは非常に高いインタラクティビティを実現できます。連鎖Select・条件付きフィールド表示・リアルタイム計算など、複雑なフォームUIもLivewireベースで直感的に実装できます。