【実務完全ガイド】Laravelのパフォーマンス最適化と可観測性――キャッシュ・DB設計・ジョブ分割・Octane・メトリクス・アクセシブルな読み込み表示
この記事で学べること(要点)
- キャッシュ(Config/Route/View/Query)とHTTPキャッシュの設計
- EloquentのN+1解消、インデックス設計、集約・ページネーション最適化
- ジョブ分割とスロットリング、重複排除、冪等処理
- Octane(Swoole/RoadRunner)やOPcache、静的アセットの最適配信
- Horizon・Telescope・ログ整形・メトリクス(遅延・エラー率)での監視
- アクセシブルなローディング表示、プログレッシブエンハンスメント、色に依存しない進捗表現
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:速度と安定性を段階的に高めたい方
- テックリード/アーキテクト:可観測性を備えた運用に強い設計を標準化したい方
- SRE/QA:指標に基づくパフォーマンス検証と劣化検知を回したい方
- デザイナー/ライター:読み込み中のアクセシブルな表示や骨組みUI(スケルトン)を整えたい方
1. 基本方針:まずは“無駄を減らす”、次に“仕組みで速くする”
最適化の順序を誤ると、複雑さだけが増します。
- 測る:遅いリクエスト/クエリ/ジョブを特定。
- 減らす:N+1・不要なクエリ・ムダなレンダリング。
- キャッシュ:データ・テンプレート・HTTP。
- 非同期化:重い処理はジョブへ。
- 基盤強化:OPcache・Octane・CDN。
- 監視:劣化を検知し、ロールバック可能な運用に。
以降は、施策を安全に積み上げるための具体策を示します。
2. キャッシュ設計:ヒット面積を広げ、無効化を制御
2.1 まずはビルトインキャッシュ
php artisan config:cache
php artisan route:cache
php artisan view:cache
- 構成・ルート・Blade のビルド済みキャッシュで起動コストを削減。
- 本番は OPcache 有効化が前提。コンテナ再起動やデプロイで自動反映される運用に。
2.2 データキャッシュ(Repository層の例)
class CategoryRepo {
public function all(): Collection {
return Cache::remember('categories:all', now()->addHours(6), function () {
return Category::query()->orderBy('rank')->get(['id','name','slug']);
});
}
}
- キー命名:
リソース:条件:version
を意識。 - 局所性:一覧や辞書を対象に。ユーザー固有は短命か別レイヤへ。
2.3 クエリキャッシュ(短命)
$top = Cache::remember("posts:top:{$page}", 60, fn() =>
Post::with('author')
->published()
->orderByDesc('score')
->paginate(20)
);
- ページング+
with()
を含む重い一覧を短命キャッシュ。 - 無効化はイベント駆動(作成/更新/削除時に
Cache::forget
)。
2.4 HTTPキャッシュ(ETag/Last-Modified)
$etag = sha1($post->updated_at.$post->id);
if (request()->header('If-None-Match') === $etag) {
return response()->noContent(304);
}
return response()->view('post.show', compact('post'))
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=120');
- CDNと組み合わせて帯域を削減。
- 認証済みページは
private
と短命の戦略。
3. Eloquent 最適化:N+1解消・インデックス・集約
3.1 N+1 の検出と撲滅
// 例:著者とタグをまとめて取得
$posts = Post::with(['author:id,name','tags:id,name'])
->latest()->paginate(20);
with()
で関連一括取得、列は最小に。- 集約表示はカウンタキャッシュや事前集計テーブルを検討。
3.2 インデックス設計
- WHERE/ORDER BY/JOIN に現れる列に複合インデックス。
created_at DESC
の並びは(created_at, id)
などで安定化。- LIKE 検索は前方一致で(
column LIKE 'abc%'
)、全文検索系は別ストアも検討。
3.3 検索・フィルタのホワイトリスト
$sort = $req->enum('sort', ['-created_at','created_at','-score','score']) ?? '-created_at';
$dir = str_starts_with($sort,'-') ? 'desc' : 'asc';
$col = ltrim($sort,'-');
$query->orderBy($col, $dir);
- 任意列の
orderBy
を禁止し、列挙制御で安全に最適化。
3.4 集約の分離
- ダッシュボードの重い集計は定期ジョブで素材化し、画面は軽量参照。
- リアルタイム性が不要な指標は非同期更新へ。
4. ビュー最適化:Bladeとフロントの軽量化
4.1 条件分岐とコンポーネント
- 大量ループ内での
@include
を削減し、コレクションの塊でレンダリング。 - 再計算の多いヘルパはViewModelやAccessorで事前整形。
4.2 画像・静的アセット
<img>
に幅・高さを付与し、CLS を抑える。<picture>
とsrcset
、loading="lazy"
で転送量を削減。- CSS/JS はHTTP/2 まとめ、不要コードをビルド時にツリーシェイク。
4.3 読み込み中の表示(アクセシブル)
<div role="status" aria-live="polite" class="mb-2">
データを読み込み中です…
</div>
<ul aria-busy="true" aria-describedby="loading-desc">
<li class="skeleton h-6 w-full"></li>
<li class="skeleton h-6 w-5/6 mt-2"></li>
</ul>
<p id="loading-desc" class="sr-only">読み込みが完了するとリストが表示されます。</p>
- 骨組みUIはテキスト説明も併記。
- 完了後は
aria-busy="false"
へ更新し、リスト先頭へフォーカス移動で文脈を回復。
5. 非同期化:ジョブ分割・重複排除・スロットリング
5.1 重い処理はジョブへ
class ExportOrders implements ShouldQueue {
public $tries = 3;
public $timeout = 1800; // 30分
public function handle(ExportService $svc) { $svc->run(); }
}
- 冪等性(同じ入力で同じ出力)を保ち、再試行で壊れないように。
5.2 重複排除(ユニークジョブ)
$lock = Cache::lock("export:{$userId}", 600);
if ($lock->get()) {
ExportOrders::dispatch($userId)->onQueue('exports');
// ジョブ終了時に $lock->release();
}
- 同一ユーザーの同種処理を直列化し、資源を守る。
5.3 レート制限ミドルウェア
public function middleware(): array {
return [ new \Illuminate\Queue\Middleware\RateLimited('exports') ];
}
- 外部APIやメール送信でスロットリングを付与。
5.4 フロントのフォールバック
- リアルタイム通知がなくてもポーリングで状況を把握。
- 読み込み中は
role="status"
、失敗時は短文の原因と次の手段を提示。
6. Octane/OPcache/プロセス最適化
6.1 OPcache 基本
- 本番で
opcache.enable=1
、opcache.validate_timestamps=0
(イミュータブルビルド時) - デプロイ時にプロセス再起動でキャッシュ更新。
6.2 Laravel Octane の狙いどころ
- 同期I/Oのオーバーヘッドを削減。
- セッション/キャッシュ/キュー等は外部ストアで共有。
- ステートフルなシングルトンやリクエスト間の汚染に注意し、
Octane::tick()
などで周期的に再初期化。
6.3 画像/静的配信はCDNへ
- アプリから配らないものは原則CDN。
- 署名付きURLで期限付きアクセスを提供。
7. 可観測性:ログ・トレース・メトリクス
7.1 構造化ログ
Log::info('order.created', [
'order_id' => $order->id,
'user_id' => $order->user_id,
'amount' => $order->amount,
'request_id' => request()->header('X-Request-Id')
]);
- 検索キー(ユーザーID、注文ID、リクエストID)を必ず記録。
- PII はマスク、トークンは記録禁止。
7.2 遅延・エラー率・スループット
- パーセンタイル(p50/p95/p99)で体感を把握。
- 「遅い=悪」ではなく、目標値(SLO)を決めて逸脱を検知。
- 指標はデプロイごとに比較し、劣化時はロールバック判断。
7.3 Telescope/Horizon の活用
- Telescope:リクエスト/クエリ/例外/ジョブの可視化。
- Horizon:キューの待ち行列・失敗ジョブ・処理時間のダッシュボード。
- 本番ではアクセス制限し、保全目的で短期間のみ有効化。
8. ページネーションと段階読み込み
8.1 simplePaginate
と無限スクロール
$items = Item::orderByDesc('id')->simplePaginate(50);
- 総件数の集計コストを回避。
- 無限スクロール時は手動でも到達可能な導線(「さらに読み込む」ボタン)を併置。
8.2 アクセシブルな読み込み
<button id="more" class="btn" aria-controls="list" aria-describedby="load-hint">
さらに読み込む
</button>
<p id="load-hint" class="sr-only">追加の結果を下部に読み込みます。</p>
<div id="alist" role="feed" aria-busy="false"></div>
- 自動読み込みができない環境でも確実に操作可能。
9. 失敗を無害化:タイムアウト・再試行・サーキットブレーカー
- HTTPクライアントは接続/応答タイムアウトを明示。
- 一時的障害は指数バックオフで再試行。
- 恒久的障害はサーキットブレーカーで一時遮断し、UI には代替文言を出す。
- 失敗の影響範囲をスコープで限定(トランザクション/部分更新)。
10. 国際化とタイムゾーンのパフォーマンス
- Carbon のロケール設定はブート時に一度だけ。
- 多言語の文言はJSON翻訳か名前付きキーでキャッシュヒットしやすく。
- 通貨・日付整形はサーバ側で集約し、クライアント側の計算を最小化。
11. セキュリティと速度のバランス
- レート制限は重要操作ほど厳格に。
- CSP/HSTS/Referrer-Policy はヘッダーで一括適用、動的 nonce を使う場合はテンプレートの最小化でオーバーヘッドを抑制。
- 署名付きURLや権限チェックはキャッシュ層を迂回する経路を意識。
12. UX:遅い・失敗・オフライン時の案内
12.1 遅延時の案内
<div id="status" role="status" aria-live="polite">
3秒以上応答がありません。通信環境をご確認ください。
</div>
- 時間経過に応じて短文を出し、次の手段(再読み込み、簡易版へ)を提示。
12.2 失敗時の案内
- 「もう一度試す」ボタンを最前に配置。
- 問い合わせに使えるリクエストIDを表示。
- 色に依存せず、アイコン+文言で状態を示す。
13. 実装サンプル:ボトルネックの測定から改善まで
13.1 ミドルウェアで応答時間を計測
class RequestTimer {
public function handle($req, Closure $next) {
$start = microtime(true);
$res = $next($req);
$ms = (int)((microtime(true) - $start) * 1000);
Log::info('http.timing', [
'path' => $req->path(),
'status' => $res->getStatusCode(),
'ms' => $ms,
'request_id' => $req->headers->get('X-Request-Id'),
]);
return $res->headers->set('Server-Timing', "app;dur={$ms}");
}
}
- ブラウザのNetworkで
Server-Timing
を確認し、遅い画面を特定。
13.2 N+1 の可視化(開発時)
DB::listen(function ($query) {
if (str_contains($query->sql, 'select') && $query->time > 30) {
logger()->debug('slow.query', ['sql' => $query->sql, 'ms' => $query->time]);
}
});
- しきい値を超えるクエリをログへ。
13.3 クエリの抜本改善
withCount
やselectRaw
で集約をDB側に寄せる。- 履歴や統計は別テーブルで累積(ライトヘビーを避ける)。
14. チェックリスト(配布用)
測定
- [ ] p50/p95/p99、エラー率、スループット
- [ ] slow query/slow request ログ
- [ ] 直近デプロイと比較するダッシュボード
削減
- [ ] N+1 撲滅(
with()
/列絞り込み) - [ ] 適切なインデックス
- [ ] 不要な再計算/テンプレート分岐の整理
キャッシュ
- [ ] Config/Route/View/OPcache
- [ ] データ短命キャッシュと無効化戦略
- [ ] HTTP ETag/Last-Modified/CDN
非同期
- [ ] ジョブ化・再試行・タイムアウト
- [ ] ユニークジョブ/レート制限
- [ ] フォールバック(ポーリング/軽量版)
基盤
- [ ] Octane 適用範囲の検討
- [ ] 静的アセットのCDN配信
- [ ] 画像の
srcset
/遅延読込/幅高さ指定
可観測性
- [ ] 構造化ログとリクエストID
- [ ] Horizon/Telescope の安全運用
- [ ] アラート閾値とロールバック手順
アクセシビリティ
- [ ] ローディングの文言と
role="status"
- [ ] 色以外の状態表現、フォーカス復帰
- [ ] 失敗時の次アクション提示
15. よくある落とし穴と回避策
- キャッシュ前にN+1が残る → 先に削減してからキャッシュ。
- 無効化戦略なしの長命キャッシュ → イベント駆動で忘れず破棄。
- 任意ソート/検索を直渡し → ホワイトリストで最適パスに固定。
- 非同期化だけで安堵 → 再試行/冪等がないと障害時に積もる。
- 読み込み中が無言 → 短文の案内と操作の余地を提供。
- 計測なしの最適化 → 効果が不明、測定→改善→再測定のサイクルへ。
16. まとめ
- 測る→減らす→キャッシュ→非同期→基盤強化→監視の順で前進。
- Eloquentは
with()
とインデックス、集約の外出しで根本から軽く。 - ジョブは重複排除・レート制限・冪等で壊れにくく。
- OPcache/Octane/CDN で土台の速度を底上げ。
- ローディング・失敗・再試行の導線をアクセシブルに。
- 指標をダッシュボード化し、劣化を早期に検知して戻せる運用を。
パフォーマンスは単発の改善ではなく、計測と改善の習慣です。この記事のテンプレートを土台に、チーム全体で“速くてわかりやすい”Laravelを育てましょう。
参考リンク
- Laravel 公式
- パフォーマンス/HTTP
- データベース
- アクセシビリティ/UX