#25 FilamentPHP応用

カスタムバリデーションルール——Filamentフォームに独自ルールを組み込む

Filamentフォームのバリデーションの仕組み

FilamentのフォームフィールドはLaravelのバリデーションルールをそのまま受け付けます。->rules()メソッドに配列でルールを渡すか、専用のメソッド(->required()->email()->maxLength())を使います。

TextInput::make('email')
    ->label('メールアドレス')
    ->email()                                    // email ルール
    ->required()                                 // required ルール
    ->maxLength(255)                             // max:255 ルール
    ->rules(['unique:users,email'])              // 追加ルール

Ruleオブジェクトを使った高度なバリデーション

LaravelのRuleオブジェクトを使うと、複雑な条件のバリデーションが書きやすくなります。

use Illuminate\Validation\Rule;

TextInput::make('slug')
    ->label('スラッグ')
    ->rules([
        'required',
        'regex:/^[a-z0-9-]+$/',
        Rule::unique('posts', 'slug')->ignore($this->record?->id),
    ])

Rule::unique()->ignore()で編集時に自分自身のIDを除外できます。

クロージャによるカスタムバリデーション

->rules()にクロージャを含む配列を渡すことで、カスタムロジックのバリデーションが作れます。

TextInput::make('phone')
    ->label('電話番号')
    ->rules([
        'required',
        function (string $attribute, mixed $value, \Closure $fail): void {
            // 日本の電話番号形式チェック(ハイフンあり/なし両対応)
            $normalized = preg_replace('/[^0-9]/', '', $value);
            if (! preg_match('/^(0[0-9]{9,10})$/', $normalized)) {
                $fail('有効な日本の電話番号を入力してください。');
            }
        },
    ])

->validationMessages() でエラーメッセージをカスタマイズ

TextInput::make('title')
    ->label('タイトル')
    ->required()
    ->maxLength(255)
    ->validationMessages([
        'required' => 'タイトルは必須です。',
        'max'      => 'タイトルは255文字以内で入力してください。',
    ])

フォーム間の依存バリデーション

他のフィールドの値に依存するバリデーションには$get()クロージャを使います。

use Filament\Forms\Get;

DatePicker::make('start_date')
    ->label('開始日')
    ->required(),

DatePicker::make('end_date')
    ->label('終了日')
    ->required()
    ->rules([
        fn (Get $get): \Closure => function (
            string $attribute,
            mixed $value,
            \Closure $fail
        ) use ($get): void {
            $startDate = $get('start_date');
            if ($startDate && $value && $value < $startDate) {
                $fail('終了日は開始日より後の日付を入力してください。');
            }
        },
    ]),

Ruleクラスを実装したカスタムRuleオブジェクト

再利用可能な独自ルールはLaravelのRuleオブジェクトとして作成します。

php artisan make:rule JapaneseZipCode
// app/Rules/JapaneseZipCode.php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class JapaneseZipCode implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // 郵便番号: 123-4567 または 1234567 の形式
        $normalized = str_replace('-', '', $value);

        if (! preg_match('/^\d{7}$/', $normalized)) {
            $fail('有効な郵便番号(例: 123-4567)を入力してください。');
            return;
        }

        // オプション: 実在する郵便番号かどうかAPIで確認
        // $response = Http::get("https://zipcloud.ibsnet.co.jp/api/search?zipcode={$normalized}");
        // if (! $response->json('results')) {
        //     $fail('存在しない郵便番号です。');
        // }
    }
}
TextInput::make('zip_code')
    ->label('郵便番号')
    ->required()
    ->rules([new JapaneseZipCode()])
    ->mask('999-9999')  // 入力マスク

リアルタイムバリデーション(->live() との組み合わせ)

フィールドを->live()にすると入力のたびにサーバーサイドバリデーションが実行されます。

TextInput::make('username')
    ->label('ユーザー名')
    ->required()
    ->minLength(3)
    ->maxLength(20)
    ->alphaNum()
    ->rules([
        Rule::unique('users', 'username')->ignore(auth()->id()),
    ])
    ->live(debounce: 500)    // 500ms後にバリデーション実行
    ->helperText('英数字3〜20文字で入力してください。')

->live()を使うと入力中にリアルタイムでエラーメッセージが表示されます。

フォームレベルのバリデーション(->afterValidation()

個々のフィールドではなく、フォーム全体が通過した後に実行する追加バリデーションです。

// CreatePost.php または EditPost.php のページクラス内

protected function afterValidate(): void
{
    $data = $this->form->getState();

    // ビジネスルールのバリデーション
    if ($data['status'] === 'published' && empty($data['published_at'])) {
        throw ValidationException::withMessages([
            'published_at' => '公開状態にする場合は公開日時を設定してください。',
        ]);
    }

    if ($data['is_featured'] && Post::where('is_featured', true)->count() >= 5) {
        throw ValidationException::withMessages([
            'is_featured' => 'おすすめ記事は最大5件までです。他の記事のおすすめ設定を解除してください。',
        ]);
    }
}

Repeater内フィールドのバリデーション

Repeater内のフィールドにもカスタムバリデーションを適用できます。

Repeater::make('order_items')
    ->schema([
        Select::make('product_id')
            ->label('商品')
            ->options(Product::pluck('name', 'id'))
            ->required(),

        TextInput::make('quantity')
            ->label('数量')
            ->numeric()
            ->minValue(1)
            ->rules([
                fn (Get $get): \Closure => function (
                    string $attribute,
                    mixed $value,
                    \Closure $fail
                ) use ($get): void {
                    $productId = $get('product_id');
                    $product = Product::find($productId);
                    if ($product && $value > $product->stock_quantity) {
                        $fail("在庫数({$product->stock_quantity}個)を超えています。");
                    }
                },
            ]),
    ])

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

コツ: よく使うバリデーションルール(郵便番号・電話番号・法人番号など)はapp/Rules/に置いてプロジェクト全体で再利用しましょう。コンストラクタで設定値を受け取れるようにしておくと柔軟性が上がります。

注意点: ->live()でリアルタイムバリデーションを有効にすると、Rule::unique()などのDBクエリが入力のたびに実行されます。本番環境ではdebounceを必ず設定し、パフォーマンスへの影響を最小化してください。

ハマりポイント: Filamentのフォームはフロントエンドとサーバーサイドの2段階でバリデーションします。->minLength()などのHTML5バリデーションがブラウザで先にエラーを出すため、クライアントサイドのバリデーションだけを頼りにしないよう注意が必要です。必ずサーバーサイドのバリデーションも実装してください。

まとめ

FilamentフォームのバリデーションはLaravelのバリデーションシステムとシームレスに統合されています。クロージャバリデーション・カスタムRuleオブジェクト・フォームレベルのafterValidate()を組み合わせることで、複雑なビジネスルールも正確に表現できます。