#11 FilamentPHP応用

カスタムフォームコンポーネントを作る——AbstractFormFieldを継承した独自フィールド

カスタムフォームフィールドとは

Filamentが提供する標準フィールド(TextInput・Select・DatePicker など)では対応できない独自のUIコントロールが必要になることがあります。そういった場合にカスタムフォームフィールドを作成します。

作成したフィールドは標準フィールドとまったく同じAPI(->label()->required()->disabled() など)で使えるため、チーム内で再利用しやすくなります。

生成コマンドとファイル構成

php artisan make:filament-form-field RatingInput

生成されるファイル:

app/Forms/Components/RatingInput.php
resources/views/forms/components/rating-input.blade.php

Fieldクラスの実装

namespace App\Forms\Components;

use Filament\Forms\Components\Field;

class RatingInput extends Field
{
    protected string $view = 'forms.components.rating-input';

    // 最大星数(デフォルト5)
    protected int $maxRating = 5;

    // 色のテーマ
    protected string $color = 'warning';

    public function maxRating(int $max): static
    {
        $this->maxRating = $max;
        return $this;
    }

    public function color(string $color): static
    {
        $this->color = $color;
        return $this;
    }

    public function getMaxRating(): int
    {
        return $this->maxRating;
    }

    public function getColor(): string
    {
        return $this->color;
    }
}

staticを返り値の型として使うことでメソッドチェーンが正しく機能します。PHP 8.2以降ではreadonlyプロパティも活用できますが、Fluent APIとの兼ね合いで通常のプロパティを使うのが一般的です。

Bladeテンプレートの実装

@php
    $state = $getState() ?? 0;
    $maxRating = $getMaxRating();
    $color = $getColor();
    $statePath = $getStatePath();
    $isDisabled = $isDisabled();
@endphp

<x-dynamic-component
    :component="$getFieldWrapperView()"
    :field="$field"
>
    <div
        x-data="{ rating: @js((int) $state) }"
        class="flex items-center gap-1"
    >
        @foreach(range(1, $maxRating) as $star)
            <button
                type="button"
                x-on:click="{{ $isDisabled ? '' : "rating = {$star}; \$wire.set('{$statePath}', {$star})" }}"
                x-bind:class="rating >= {{ $star }}
                    ? 'text-{{ $color }}-400'
                    : 'text-gray-300 dark:text-gray-600'"
                {{ $isDisabled ? 'disabled' : '' }}
                class="text-2xl transition-colors duration-150 hover:scale-110
                    {{ $isDisabled ? 'cursor-not-allowed' : 'cursor-pointer' }}"
                aria-label="{{ $star }}星"
            >

            </button>
        @endforeach

        <span class="ml-2 text-sm text-gray-500 dark:text-gray-400">
            <span x-text="rating"></span> / {{ $maxRating }}
        </span>
    </div>
</x-dynamic-component>

使用例

use App\Forms\Components\RatingInput;

public static function form(Form $form): Form
{
    return $form
        ->schema([
            TextInput::make('title')
                ->label('商品名')
                ->required(),

            RatingInput::make('rating')
                ->label('評価')
                ->maxRating(5)
                ->color('warning')
                ->default(3)
                ->required(),

            RatingInput::make('difficulty')
                ->label('難易度')
                ->maxRating(3)
                ->color('danger'),
        ]);
}

デフォルト値とキャスト

フィールドのデフォルト値はモデル側のキャストと組み合わせると整合性が保てます。

// app/Models/Review.php
protected $casts = [
    'rating' => 'integer',
];
RatingInput::make('rating')
    ->default(0)
    ->rules(['integer', 'min:0', 'max:5'])

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

コツ: $getStatePath()で取得したパスを$wire.set()に渡すことで、ネストしたフォーム(RepeaterやWizard内)でも正しく動作します。パスをハードコードしないようにしてください。

注意点: $isDisabled()のチェックを忘れると、ReadOnlyモードやビュー画面でも値が変更できてしまいます。

ハマりポイント: Alpine.jsのx-data内で初期値をBladeの@js()ヘルパーで渡す際、型変換が必要な場合があります。(int)(float)など明示的にキャストしてから渡しましょう。

InfoList対応

同じフィールドをInfoList(詳細ページ)でも表示できるよう、対応するEntryクラスも別途作成することを検討してください(エピソード15で解説)。

まとめ

Fieldクラスを継承してBladeテンプレートを用意するだけで、標準フィールドと同じAPIを持つカスタムフィールドが作れます。Fluent APIパターンで設定を受け取り、$getXxx()メソッドでBladeに渡す構造を守れば、複雑なUIコントロールもFilamentのエコシステムに自然に組み込めます。