#29 FilamentPHP応用

テーブルの高度なフィルタリング——カスタムFilterとQueryBuilderの組み合わせ

Filamentのフィルタリングシステム

Filamentは複数のフィルタタイプを提供しています。

フィルタタイプ用途
Filterカスタムフォームを持つ自由なフィルタ
SelectFilterドロップダウンで値を選択
TernaryFilterはい/いいえ/どちらでも
DateRangeFilter日付範囲の絞り込み
QueryBuilder複雑な条件を自由に組み合わせ

SelectFilterの応用

use Filament\Tables\Filters\SelectFilter;

->filters([
    SelectFilter::make('status')
        ->label('ステータス')
        ->options([
            'draft'     => '下書き',
            'published' => '公開中',
            'archived'  => 'アーカイブ',
        ])
        ->multiple()         // 複数選択可
        ->searchable(),      // 選択肢を検索可能に

    SelectFilter::make('category')
        ->label('カテゴリ')
        ->relationship('category', 'name')  // リレーションから選択肢を生成
        ->multiple()
        ->preload(),

    SelectFilter::make('author')
        ->label('著者')
        ->relationship('author', 'name')
        ->searchable()
        ->multiple()
        ->getOptionLabelFromRecordUsing(
            fn (User $record): string => "{$record->name} ({$record->email})"
        ),
])

カスタムFilterの実装

Filter::make()で完全カスタムのフィルタを作ります。

use Filament\Tables\Filters\Filter;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;

Filter::make('published_date_range')
    ->label('公開期間')
    ->form([
        DatePicker::make('published_from')
            ->label('開始日'),
        DatePicker::make('published_until')
            ->label('終了日'),
    ])
    ->query(function (Builder $query, array $data): Builder {
        return $query
            ->when(
                $data['published_from'],
                fn ($q, $date) => $q->whereDate('published_at', '>=', $date)
            )
            ->when(
                $data['published_until'],
                fn ($q, $date) => $q->whereDate('published_at', '<=', $date)
            );
    })
    ->indicateUsing(function (array $data): array {
        // アクティブフィルタのバッジ表示
        $indicators = [];
        if ($data['published_from']) {
            $indicators[] = Indicator::make('公開開始: ' . $data['published_from'])
                ->removeField('published_from');
        }
        if ($data['published_until']) {
            $indicators[] = Indicator::make('公開終了: ' . $data['published_until'])
                ->removeField('published_until');
        }
        return $indicators;
    }),

TernaryFilterの使い方

use Filament\Tables\Filters\TernaryFilter;

TernaryFilter::make('is_featured')
    ->label('おすすめ記事')
    ->nullable()           // null = すべて、true = おすすめのみ、false = 通常のみ
    ->trueLabel('おすすめのみ')
    ->falseLabel('通常のみ')
    ->queries(
        true:  fn (Builder $query) => $query->where('is_featured', true),
        false: fn (Builder $query) => $query->where('is_featured', false),
        blank: fn (Builder $query) => $query,  // nullの場合(すべて表示)
    ),

TernaryFilter::make('has_thumbnail')
    ->label('サムネイル')
    ->trueLabel('あり')
    ->falseLabel('なし')
    ->queries(
        true:  fn (Builder $query) => $query->whereNotNull('thumbnail'),
        false: fn (Builder $query) => $query->whereNull('thumbnail'),
        blank: fn (Builder $query) => $query,
    ),

QueryBuilderフィルタ

FilamentのQueryBuilderはエンドユーザーが自由に条件を組み合わせられる高機能フィルタです。

use Filament\Tables\Filters\QueryBuilder;
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\NumberConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\DateConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\SelectConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\BooleanConstraint;

->filters([
    QueryBuilder::make()
        ->constraints([
            TextConstraint::make('title')
                ->label('タイトル'),

            TextConstraint::make('body')
                ->label('本文')
                ->icon('heroicon-o-document-text'),

            SelectConstraint::make('status')
                ->label('ステータス')
                ->options([
                    'draft'     => '下書き',
                    'published' => '公開中',
                    'archived'  => 'アーカイブ',
                ])
                ->multiple(),

            NumberConstraint::make('views_count')
                ->label('閲覧数')
                ->icon('heroicon-o-eye'),

            DateConstraint::make('published_at')
                ->label('公開日'),

            BooleanConstraint::make('is_featured')
                ->label('おすすめ記事'),
        ])
        ->constraintPickerColumns(2),  // 条件選択UIの列数
])

フィルタのレイアウト設定

use Filament\Tables\Enums\FiltersLayout;

->filtersLayout(FiltersLayout::AboveContent)        // テーブル上部に表示
// FiltersLayout::BelowContent                        // テーブル下部
// FiltersLayout::AboveContentCollapsible             // 折りたたみ可能
// FiltersLayout::Dropdown                            // ドロップダウン(デフォルト)

->filtersTriggerAction(
    fn (Action $action) => $action
        ->label('絞り込み')
        ->icon('heroicon-o-funnel')
)
->filtersFormColumns(3)  // フィルタフォームの列数

フィルタ状態のURLパラメータへの永続化

フィルタの状態をURLに含めることで、特定のフィルタ条件のURLを共有できます。

// ListRecordsページでURLフィルタを有効化
class ListOrders extends ListRecords
{
    // フィルタの状態をURLクエリパラメータに保存
    protected function getTableFiltersFormStateSessionKey(): ?string
    {
        return 'orders_table_filters';  // セッションキー
    }
}

URLでフィルタを指定する例(フィルタ値をURLに含める場合):

// フィルタの初期値をURLパラメータから設定
protected function getDefaultTableFilters(): array
{
    return [
        'status' => request()->query('status', 'pending'),
    ];
}

アクティブフィルタの表示(Indicators)

適用されているフィルタを一目で分かるバッジとして表示し、個別にクリアできるようにします。

Filter::make('price_range')
    ->form([
        TextInput::make('min_price')->numeric()->label('最低価格'),
        TextInput::make('max_price')->numeric()->label('最高価格'),
    ])
    ->query(fn (Builder $query, array $data): Builder =>
        $query
            ->when($data['min_price'], fn ($q, $v) => $q->where('price', '>=', $v))
            ->when($data['max_price'], fn ($q, $v) => $q->where('price', '<=', $v))
    )
    ->indicateUsing(function (array $data): array {
        $indicators = [];
        if (! empty($data['min_price'])) {
            $indicators[] = Indicator::make('最低価格: ¥' . number_format($data['min_price']))
                ->removeField('min_price');
        }
        if (! empty($data['max_price'])) {
            $indicators[] = Indicator::make('最高価格: ¥' . number_format($data['max_price']))
                ->removeField('max_price');
        }
        return $indicators;
    }),

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

コツ: QueryBuilderフィルタはエンドユーザーが自由に条件を組み合わせられる強力な機能です。ただし、技術的でないユーザーには複雑に見えることもあります。一般的な絞り込みはSelectFilterやTernaryFilterで提供し、高度な検索が必要なパワーユーザー向けにQueryBuilderをオプションで提供するのが理想的です。

注意点: カスタムFiltersで複雑なJOINを含むクエリを書く場合、他のフィルタや検索クエリと競合することがあります。->query()内では$queryのコピーを変更するため、基本的には安全ですが、グローバルスコープや既存のJOINと干渉しないか確認してください。

ハマりポイント: ->filtersLayout(FiltersLayout::AboveContent)を使うと、フィルタが常に表示された状態になります。この設定は省スペースが要求される画面には不向きです。デフォルトのDropdownかユーザーが開閉できるAboveContentCollapsibleの使い分けを検討してください。

まとめ

FilamentのフィルタリングはSelectFilter・TernaryFilter・カスタムFilter・QueryBuilderの4種類を用途に応じて使い分けることで、シンプルな絞り込みから複雑な検索条件まで対応できます。->indicateUsing()でアクティブフィルタを視覚的に表示すれば、ユーザーが現在の絞り込み状態を常に把握できます。