#30 FilamentPHP応用

Filamentのパフォーマンス最適化——N+1問題・クエリ最適化・キャッシュ戦略

パフォーマンス問題の発見から始める

最適化の前にまずボトルネックを特定します。感覚的な最適化は時間の無駄になりがちです。

# Laravel Telescopeをインストール(開発環境)
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

Telescopeで確認すべきポイント:

  • Queriesタブ: 同じクエリが繰り返されていないか(N+1問題)
  • Requestsタブ: レスポンスタイムが遅いページ
  • Modelsタブ: 大量のモデルインスタンスが生成されていないか

N+1問題の体系的な解消

問題の発見

// N+1が起きているパターン
// テーブルに100件あると、category.nameで100回クエリが発行される
TextColumn::make('category.name')

Telescopeで確認すると同じSQLが繰り返し実行されます:

SELECT * FROM categories WHERE id = 1
SELECT * FROM categories WHERE id = 3
SELECT * FROM categories WHERE id = 1  -- 同じクエリが重複
...

getEloquentQuery()でEager Loading

// app/Filament/Resources/PostResource.php

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()
        ->with([
            'category',
            'author:id,name,email',    // 必要なカラムのみ取得
            'tags:id,name',
        ])
        ->withCount([
            'comments',
            'views',
            'likes',
        ]);
}

->with(['author:id,name'])のように取得カラムを絞ると、SELECT * の代わりに必要なカラムだけ取得できます。

Eager Loadの制約付きロード

->with([
    'comments' => fn ($query) => $query
        ->where('approved', true)
        ->latest()
        ->limit(3),  // 各投稿の最新3件のコメントのみ
])

インデックス設計

FilamentのテーブルはソートやSearchableカラムを使う際にDBのインデックスが効率に直結します。

// マイグレーション例
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->string('status')->index();              // フィルタ用
    $table->foreignId('category_id')->index();      // リレーション検索用
    $table->foreignId('author_id')->index();        // リレーション検索用
    $table->timestamp('published_at')->nullable()->index(); // ソート用
    $table->timestamps();

    // 複合インデックス(status + published_atで絞り込む場合)
    $table->index(['status', 'published_at']);

    // 全文検索インデックス(MySQLのみ)
    $table->fullText(['title', 'body']);
});

全文検索を使う場合:

TextColumn::make('title')
    ->searchable(query: fn (Builder $query, string $search): Builder =>
        $query->whereFullText(['title', 'body'], $search)
    )

Livewireのレンダリング最適化

Filamentはすべてのコンポーネントがサーバーサイドレンダリングです。不要な再レンダリングを減らすことが重要です。

->lazy()でウィジェットを遅延ロード

class ExpensiveChartWidget extends ChartWidget
{
    protected static bool $isLazy = true;  // ページ表示後に非同期ロード

    protected function getData(): array
    {
        // 重いクエリ(ページ初期表示には影響しない)
        return [...];
    }
}

Livewireのワイヤーモデルを最適化

// ❌ 毎タイプでサーバーリクエストが発生
TextInput::make('search')->live()

// ✅ 500ms入力が止まってからサーバーリクエスト
TextInput::make('search')->live(debounce: 500)

キャッシュ戦略

クエリ結果のキャッシュ

class PostStatsWidget extends StatsOverviewWidget
{
    protected static ?string $pollingInterval = '60s';

    protected function getStats(): array
    {
        // Redisやファイルキャッシュで10分間保持
        return cache()->remember(
            key: 'post_stats_' . Filament::getTenant()?->id,
            ttl: now()->addMinutes(10),
            callback: function (): array {
                return [
                    'total'     => Post::count(),
                    'published' => Post::where('status', 'published')->count(),
                    'views'     => Post::sum('views_count'),
                ];
            }
        );
    }
}

Selectフィールドの選択肢キャッシュ

Select::make('category_id')
    ->label('カテゴリ')
    ->options(function (): array {
        return cache()->remember('categories_for_select', 3600, fn (): array =>
            Category::orderBy('name')
                ->pluck('name', 'id')
                ->toArray()
        );
    })
    ->searchable()

カテゴリが更新されたらキャッシュをクリア:

// app/Models/Category.php
protected static function booted(): void
{
    static::saved(fn () => cache()->forget('categories_for_select'));
    static::deleted(fn () => cache()->forget('categories_for_select'));
}

ページネーション設定の最適化

public function table(Table $table): Table
{
    return $table
        // 適切なデフォルトページ数(大きすぎると重い)
        ->paginated([10, 25, 50])
        ->defaultPaginationPageOption(25)

        // 大量データにはカーソルページネーション
        // ->paginationType('cursor')

        // 件数が少ない場合はページネーション無効化
        // ->paginated(false)
    ;
}

Filament本番最適化コマンド

# 本番デプロイ時に必ず実行
php artisan filament:optimize

# 内部的に以下を実行している
php artisan filament:cache-components  # コンポーネントのキャッシュ生成
php artisan icons:cache                # アイコンキャッシュ

# Laravelの標準最適化も忘れずに
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

デプロイスクリプトの例:

#!/bin/bash
set -e

composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan filament:optimize
php artisan migrate --force
php artisan queue:restart

データベース接続の最適化

// config/database.php
'mysql' => [
    // ...
    'options' => [
        PDO::ATTR_PERSISTENT => true,  // 接続の永続化(注意: サーバーの設定に依存)
    ],
    'sticky' => true,  // 書き込み後は同じ接続を使う(レプリケーション環境)
],

Octaneとの組み合わせ

Laravel Octane(Swoole/RoadRunner)を使うと、プロセスを常駐させることでリクエストごとの起動コストをゼロにできます。

composer require laravel/octane
php artisan octane:install
php artisan octane:start --workers=4 --max-requests=250

Filamentのサービスプロバイダがシングルトンで登録されているコンポーネントをOctane環境で適切に動作させるには、config/octane.phpflush設定を確認してください。

パフォーマンスモニタリング

// config/logging.php でスローSQLをログに記録
DB::listen(function ($query) {
    if ($query->time > 100) {  // 100ms以上のクエリをログに
        Log::warning('Slow query detected', [
            'sql'     => $query->sql,
            'time'    => $query->time . 'ms',
            'bindings' => $query->bindings,
        ]);
    }
});

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

コツ: php artisan filament:optimizeはCI/CDパイプラインのデプロイステップに必ず含めてください。このコマンドを忘れると、アイコン検索やコンポーネント解決に不要なオーバーヘッドが発生します。

注意点: キャッシュはパフォーマンスを改善しますが、データの整合性を崩す可能性があります。特にマルチテナント環境ではテナントIDをキャッシュキーに含めることを徹底してください。テナントAのデータがテナントBに表示されてしまうバグは致命的です。

ハマりポイント: ->with()でEager Loadingを設定しても、->state(fn ($record) => $record->relation->something)のようにクロージャ内でリレーションを追加アクセスするとN+1が再発します。Eager Loadしたデータを正しく参照しているか、Telescopeで必ず確認してください。

まとめ

Filamentアプリのパフォーマンス最適化は「計測→特定→修正」のサイクルで行います。TelescopeでN+1問題を発見し、getEloquentQuery()でEager Loadingを追加し、インデックスを適切に設定することが基本です。php artisan filament:optimizeの本番コマンドとキャッシュ戦略を組み合わせることで、大規模なFilamentアプリケーションでも高いレスポンスを維持できます。