パフォーマンスと本番運用——スケールするFilamentアプリ
本番運用に向けた最適化
Filamentで構築した管理画面は、開発時には問題なくても本番環境で大量データを扱うと急激にパフォーマンスが低下することがあります。このエピソードでは、N+1問題の解消からモニタリングまで、本番運用に必要な知識を体系的に解説します。
N+1問題の解消
管理画面で最もよく起きるパフォーマンス問題がN+1クエリです。テーブルに100件のレコードがある場合、リレーション先を都度クエリすると合計101回のSQLが発行されます。
問題が起きているケース
// ❌ 悪い例:category を都度クエリ
TextColumn::make('category.name') // 100件 × 1クエリ = 100クエリ
->relationship() でEager Loadingを有効にする
// ✅ 良い例:with() で一括取得
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['category', 'author', 'tags']);
}
または、Resourceレベルで明示的に指定:
TextColumn::make('category.name')
->sortable()
->searchable(query: fn (Builder $query, string $search): Builder =>
$query->whereHas('category', fn ($q) =>
$q->where('name', 'like', "%{$search}%")
)
),
->counts() でカウントのN+1を解消
// ❌ 悪い例
TextColumn::make('comments_count')
->state(fn (Post $record) => $record->comments()->count())
// ✅ 良い例:withCount でまとめて取得
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->withCount('comments');
}
TextColumn::make('comments_count')
->sortable(),
->preload() で選択肢の事前読み込み
Selectフィールドのデフォルトは入力時にAjaxで選択肢を取得します。選択肢が少量(数十件以内)の場合は->preload()でページ読み込み時に一括取得する方が高速です。
Select::make('category_id')
->label('カテゴリ')
->relationship('category', 'name')
->preload() // ページロード時に全カテゴリを一括取得
->searchable(),
Select::make('status')
->options(['draft' => '下書き', 'published' => '公開中'])
// 静的なoptionsは自動的にpreload相当
選択肢が数百件を超える場合は->preload()を使わず、->searchable()で検索しながら絞り込む方がよいでしょう。
->lazy() でウィジェットの遅延読み込み
ダッシュボードの複数ウィジェットが同時に重いクエリを実行するとページ初期表示が遅くなります。->lazy()を使うとウィジェットをページ表示後に非同期で読み込みます。
// ウィジェット側の設定
class RevenueChartWidget extends ChartWidget
{
protected static bool $isLazy = true; // デフォルトで遅延読み込み
protected function getData(): array
{
// 重いクエリ
return [...];
}
}
Resourceのウィジェット一覧:
public static function getWidgets(): array
{
return [
PostStatsWidget::class, // $isLazy = true の場合は遅延読み込み
];
}
php artisan filament:optimize の効果
本番デプロイ時に以下のコマンドを実行してください。
php artisan filament:optimize
このコマンドは以下を実行します。
- Filamentコンポーネントのキャッシュを生成
- ルートキャッシュを最適化
- アイコンキャッシュを生成
デプロイスクリプトに組み込む例:
#!/bin/bash
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan filament:optimize
php artisan queue:restart
キャッシュをクリアしたい場合:
php artisan filament:optimize-clear
テーブルのページネーション設定
大量データのテーブルでは1ページあたりの件数を適切に設定します。
public function table(Table $table): Table
{
return $table
->paginated([10, 25, 50, 100]) // ユーザーが選択できる件数
->defaultPaginationPageOption(25) // デフォルトは25件
->paginationPageOptions([25, 50]);
}
カーソルページネーション
数十万件以上のデータでOFFSETベースのページネーションは遅くなります(OFFSET 90000 LIMIT 25は全件スキャンが発生)。カーソルページネーションを使うと最後に取得したレコードのIDを起点にクエリするため、大量データでも高速です。
public function table(Table $table): Table
{
return $table
->query(Post::query()->orderBy('id'))
->paginationType('cursor')
ただし、カーソルページネーションでは任意のページへのジャンプができないため、用途に応じて使い分けてください。
Livewireコンポーネントのキャッシュ最適化
クエリの結果をキャッシュする
ウィジェットや重い集計クエリはキャッシュで繰り返しクエリを避けます。
class PostStatsWidget extends StatsOverviewWidget
{
protected function getStats(): array
{
$stats = cache()->remember('post_stats', now()->addMinutes(10), function () {
return [
'total' => Post::count(),
'published' => Post::where('status', 'published')->count(),
'draft' => Post::where('status', 'draft')->count(),
];
});
return [
Stat::make('総記事数', number_format($stats['total'])),
Stat::make('公開中', number_format($stats['published']))->color('success'),
Stat::make('下書き', number_format($stats['draft']))->color('warning'),
];
}
}
セキュリティ強化
CSRF対策
FilamentはLivewireを使っているため、CSRF保護はフレームワーク側で自動的に処理されます。ただしVerifyCsrfTokenミドルウェアが有効になっていることを確認してください。
XSS対策
RichEditorで入力されたHTMLを表示する際はサニタイズが必要です。
// ❌ 危険
{!! $post->body !!}
// ✅ 安全(HTMLPurifierなどを使う)
{!! clean($post->body) !!}
stevebauman/purifyパッケージの使用を推奨します。
composer require stevebauman/purify
認可ポリシーの徹底
FilamentリソースはデフォルトでLaravelのPolicyを参照します。Policyを必ず実装してください。
php artisan make:policy PostPolicy --model=Post
class PostPolicy
{
public function viewAny(User $user): bool
{
return $user->hasPermissionTo('posts.view');
}
public function create(User $user): bool
{
return $user->hasPermissionTo('posts.create');
}
public function update(User $user, Post $post): bool
{
return $user->hasPermissionTo('posts.edit') || $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->hasPermissionTo('posts.delete');
}
}
ブルートフォース対策
ログインページへのブルートフォース攻撃を防ぐため、レートリミットを設定します。
// app/Providers/Filament/AdminPanelProvider.php
->login()
->loginRateLimit(5) // 5回失敗でロック
ログとモニタリング:Laravel Telescope連携
Laravel Telescopeを使うと、クエリ・リクエスト・ジョブ・例外をリアルタイムで確認できます。
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
開発環境のみで有効化:
// app/Providers/TelescopeServiceProvider.php
public function register(): void
{
Telescope::night(); // ダークモード
$this->hideSensitiveRequestDetails();
Telescope::filter(function (IncomingEntry $entry) {
if ($this->app->environment('local')) {
return true;
}
return $entry->isReportableException()
|| $entry->isFailedRequest()
|| $entry->isFailedJob()
|| $entry->isScheduledTask()
|| $entry->hasMonitoredTag();
});
}
Filamentのクエリ最適化の確認フロー:
- Telescope → Requestsで対象リクエストを選択
- Queriesタブで発行クエリ数と所要時間を確認
- 同じクエリが繰り返されていたらN+1問題
- 遅いクエリ(>100ms)はインデックスの追加を検討
エラーモニタリング
本番環境ではsentry/sentry-laravelなどのエラー追跡サービスを使います。
composer require sentry/sentry-laravel
php artisan sentry:publish
SENTRY_LARAVEL_DSN=https://xxxx@sentry.io/yyyy
まとめと応用編シリーズ総括
このエピソードでは本番運用に必要なパフォーマンスチューニングとセキュリティを解説しました。
FilamentPHP応用シリーズで学んだこと:
| エピソード | テーマ |
|---|---|
| 1 | ActionシステムとカスタムAction |
| 2 | モーダルとスライドオーバー |
| 3 | カスタムフォームコンポーネント |
| 4 | 通知とリアルタイム更新 |
| 5 | CSVインポート・エクスポート |
| 6 | 高度なテーブル機能 |
| 7 | マルチテナンシー |
| 8 | プラグインの作り方 |
| 9 | Pestでのテスト |
| 10 | 本番運用とパフォーマンス |
FilamentPHPは「速く作れる」だけでなく、適切に使えば「スケールするアプリ」も構築できます。Actionシステム・テスト・セキュリティの3つを軸に、メンテナブルな管理画面を構築してください。