Laravel(PHP)のアプリをPrometheusで監視するには、アプリ側でリクエスト数や応答時間を数えて /metrics で公開し、Prometheusがそれを定期的に取りに来て、Grafanaがグラフにする、という3段の構成を組みます。通常のPHP-FPMではリクエスト間でアプリの変数が残らないため、計測値をRedisなどの共有ストレージに貯めます。Octaneのような常駐構成でも、複数ワーカーの値をまとめるには共有ストレージが要ります。この記事では、Laravel 13.38.0・PHP 8.5.8・promphp/prometheus_client_php v2.15.1・Prometheus 3.15.1の検証環境で実際に動かした設定をもとに、実装・保護・可視化・アラートまでを手順で説明します。
まとめ
- Laravelの計測は
promphp/prometheus_client_php(2026-10時点でv2.15.1・PHP ^8.2)をミドルウェアから呼び、カウンタとヒストグラムを記録する。 - 保存先はRedis(Predis/phpredis)かAPCuにする。InMemoryを選ぶと通常のPHP-FPMではリクエスト間で値が残らず、カウンタが毎回1に戻る。spatie/laravel-prometheusは既定(
cache未指定)でInMemoryを使う。 - ラベルにはURLでなくルート定義(
users/{id})を入れる。IDやメールアドレスを入れると時系列が際限なく増える。 /metricsはwebミドルウェアグループの外に置き、トークンかIPで保護する。webグループに置くと、スクレイプのたびにセッションの行が1件増える。- キューの長さやHorizonの状態のように「読むたびに数え直す値」は spatie/laravel-prometheus のコレクタが手早い。既定の公開パスは
/prometheusで、IP制限は空=全許可。 - Prometheusはアプリのログを集めない。ログはLokiなど別系統に送る。
Laravel・Prometheus・Grafanaの役割分担と全体構成
3つは競合する製品ではなく、監視の流れの別々の段を受け持ちます。「LaravelとPrometheusのどちらを使うか」という比較は成り立たず、組み合わせて使います。
| 段 | 担当 | やること | 検証した版 |
|---|---|---|---|
| 計測 | Laravel+promphp | 値を数えて /metrics で出す |
Laravel 13.38.0/v2.15.1 |
| 一時保存 | Redis | プロセスをまたいで値を貯める | Predis 3.x |
| 収集・保存 | Prometheus | 定期的に取得し時系列で保存 | 3.15.1 |
| 可視化・通知 | Grafana | PromQLでグラフ化・アラート | 13.2.4(2026-10-13) |
Prometheusそのものの仕組みやexporterの設計、保持期間の見積りは「Prometheusで監視を構築する手順|exporter設計・PromQL・保持期間の見積り」で扱っています。この記事はLaravel側の計測と、そのデータをGrafanaで見るまでに絞ります。カウンタ・ゲージ・ヒストグラムの違いから確認したい場合は「メトリクス監視とは|4種のメトリック型・収集方式・アラート閾値の設計と導入判断を実装目線で解説」が先です。
PHPで計測値を貯めるストレージの選び方
PHP-FPMはリクエストが終わるとメモリを捨て、しかも複数のワーカープロセスで並行に処理します。そのため、Goのようにプロセス内の変数で数え続ける方式が使えません。promphpのREADME(v2.15.1)はストレージとしてRedis・Predis・APC・APCng・InMemoryを挙げており、ソースにはPDO(MySQL・PostgreSQL・SQLite)のアダプタもあります。
InMemoryが使えないことは簡単に確かめられます。次のスクリプトを php -S で起動して3回アクセスすると、3回とも app_hits_total 1 が返りました。
<?php
require __DIR__.'/vendor/autoload.php';
$registry = new Prometheus\CollectorRegistry(new Prometheus\Storage\InMemory());
$registry->getOrRegisterCounter('app', 'hits_total', 'hits')->inc();
echo (new Prometheus\RenderTextFormat())->render($registry->getMetricFamilySamples());
// php -S で起動して3回アクセスした結果は、3回とも
// app_hits_total 1
| アダプタ | 必要なもの | 向く構成 |
|---|---|---|
| Predis | predis/predis(拡張不要) | 拡張を入れられない環境 |
| Redis/RedisNg | phpredis拡張 | 本番の標準 |
| APC/APCng | APCu拡張 | 1台構成でRedisを置かない |
| PDO | MySQL・PostgreSQL・SQLite | Redisが無くDBだけある |
| InMemory | なし | テストと、読むたびに数え直す値 |
判断はRedisの有無でほぼ決まります。キャッシュやキューでRedisをすでに使っているならRedis系を選び、プレフィックスを分けて同居させます。Redis自体の仕組みは「Redisとは:仕組みとデータ型・Redis 8.10系の新機能とライセンス、Dockerで動かす手順【2026年10月】」を参照してください。APCuはサーバーごとに値が分かれるので、Prometheusはサーバーを1台ずつ取りに行く必要があります。
複数台のアプリサーバーが1つのRedisを共有する構成には落とし穴があります。どのサーバーの /metrics を読んでも同じ合計値が返るので、Prometheusに全台をターゲットとして登録すると台数分だけ重複して数えます。共有Redisならロードバランサ経由で1つのターゲットとして取るか、サーバーごとにプレフィックスを変えてサーバー単位で取るか、どちらかに統一します。
promphpによるLaravelのメトリクス実装
パッケージの導入とRedis接続の登録
Packagistの最新安定版はv2.15.1(2026-05-28公開・PHP ^8.2)です。phpredis拡張が無い環境を想定して、Predisと一緒に入れます。
composer require promphp/prometheus_client_php predis/predis
CollectorRegistry をシングルトンとしてコンテナに登録します。prefix を付けておくと、キャッシュやキューと同じRedisに同居させてもキーがぶつかりません。接続先は config/services.php(次節で追加)から読みます。検証環境では php artisan config:cache を実行した状態でも、.env に書いたRedisのポートへ接続できました。
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Prometheus\CollectorRegistry;
use Prometheus\Storage\Predis;
class PrometheusServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CollectorRegistry::class, function () {
// env()を直接呼ぶと config:cache 後に .env が読まれない
$storage = new Predis(
config('services.prometheus.redis'),
['prefix' => 'laravel_metrics:']
);
return new CollectorRegistry($storage);
});
}
}
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\PrometheusServiceProvider::class,
];
ミドルウェアによるリクエスト数・応答時間の記録
全リクエストを数えるので、グローバルミドルウェアとして追加します。ラベルに $request->path() を使うと users/1・users/2 が別々の時系列になり、IDの数だけ増え続けます。Prometheusの命名ガイドも、ユーザーIDやメールアドレスのような値をラベルに入れないよう注意しています。ルート定義($request->route()->uri())を使えば、IDが何種類あっても1本にまとまります。
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Prometheus\CollectorRegistry;
use Symfony\Component\HttpFoundation\Response;
class RecordRequestMetrics
{
public function __construct(private CollectorRegistry $registry) {}
public function handle(Request $request, Closure $next): Response
{
if ($request->is('metrics')) {
return $next($request);
}
$start = hrtime(true);
$response = $next($request);
// URLそのものでなくルート定義(users/{id})をラベルにする
$route = $request->route()?->uri() ?? 'unmatched';
$labels = [$request->method(), $route, (string) $response->getStatusCode()];
$this->registry->getOrRegisterCounter(
'app', 'http_requests_total', 'HTTP requests',
['method', 'route', 'status']
)->inc($labels);
$this->registry->getOrRegisterHistogram(
'app', 'http_request_duration_seconds', 'HTTP request duration',
['method', 'route', 'status'],
[0.05, 0.1, 0.25, 0.5, 1, 2.5]
)->observe((hrtime(true) - $start) / 1e9, $labels);
return $response;
}
}
最初の if は /metrics 自身を数えないためのものです。ルート側で withoutMiddleware() を指定しても、グローバルミドルウェアは外れませんでした。検証環境では、指定したまま /metrics へのアクセスが route="metrics" として記録されています。
/metricsのwebグループからの分離とトークン認証
routes/web.php に /metrics を書くと、webミドルウェアグループのセッション開始とCookie暗号化が走ります。Laravel 13の雛形は SESSION_DRIVER=database なので、Cookieを持たないPrometheusが来るたびに sessions テーブルの行が1件増えます。検証環境では5回のスクレイプで5件から10件に増えました。15秒間隔なら1日5,760件です。
bootstrap/app.php の then: に書いたルートはどのミドルウェアグループにも入らないので、ここに置きます。中身は社内の監視基盤しか読まない前提でも、ルート名やエラー件数が見えるため、Bearerトークンで保護します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Prometheus\CollectorRegistry;
use Prometheus\RenderTextFormat;
class MetricsController
{
public function __invoke(Request $request, CollectorRegistry $registry)
{
$token = config('services.prometheus.token');
abort_unless($token && hash_equals($token, (string) $request->bearerToken()), 403);
$renderer = new RenderTextFormat();
return response($renderer->render($registry->getMetricFamilySamples()))
->header('Content-Type', RenderTextFormat::MIME_TYPE);
}
}
// bootstrap/app.php
use App\Http\Controllers\MetricsController;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function () {
// webグループの外に置き、セッションとCookieを通さない
Route::get('/metrics', MetricsController::class);
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(\App\Http\Middleware\RecordRequestMetrics::class);
})
// withExceptions() 以降は雛形のまま
// config/services.php に追加
'prometheus' => [
'token' => env('PROMETHEUS_SCRAPE_TOKEN'),
'redis' => [
'host' => env('PROMETHEUS_REDIS_HOST', '127.0.0.1'),
'port' => (int) env('PROMETHEUS_REDIS_PORT', 6379),
],
],
この構成で /users/1〜/users/3、300ミリ秒待つ /slow、存在しないURLを順に開くと、/metrics は次のように返しました(抜粋)。トークン無しでは403、トークン付きでは200で、Set-Cookieヘッダは付かず、セッションの行も増えませんでした。
app_http_requests_total{method="GET",route="slow",status="200"} 1
app_http_requests_total{method="GET",route="unmatched",status="404"} 1
app_http_requests_total{method="GET",route="users/{id}",status="200"} 3
app_http_request_duration_seconds_bucket{method="GET",route="slow",status="200",le="0.25"} 0
app_http_request_duration_seconds_bucket{method="GET",route="slow",status="200",le="0.5"} 1
app_http_request_duration_seconds_sum{method="GET",route="slow",status="200"} 0.366463852
存在しないURLは route="unmatched" にまとまります。ここをURLのまま記録すると、脆弱性スキャナが送ってくる無数のパスがそのまま時系列になります。
spatie/laravel-prometheusを使う場合の設定
spatie/laravel-prometheusは、promphpを内部で使うLaravel向けのラッパーです。2026-10時点の最新は1.7.1(2026-09-28)で、PHP ^8.4とLaravel 12・13が対象です。ゲージをクロージャで登録しておくと、Prometheusが読みに来た時点で値を計算して返す設計で、キューの待ち件数やHorizonの状態を出すコレクタが同梱されています。
composer require spatie/laravel-prometheus
php artisan prometheus:install
使う前に、既定値を3つ確認してください。いずれもパッケージのソースで確かめた値です。
- 公開パスは
/prometheus。Prometheus側のmetrics_pathをこれに合わせる。 allowed_ipsが空の既定では全IPから読める。許可リストに無いIPは403になる。cacheがnullの既定ではInMemoryを使う。読むたびに計算するゲージなら問題ないが、リクエストごとにカウンタを増やす使い方では値が貯まらない。redisなどのキャッシュストアを指定する。
// config/prometheus.php(抜粋)
'urls' => [
'default' => 'prometheus',
],
'allowed_ips' => [
// 空のままだと全IPから読める
'10.0.0.5',
],
'cache' => 'redis', // null のままだと InMemory
ヒストグラムの型は用意されていないため、応答時間の分布を取るなら前章のpromphp直書きのほうが向きます。両方を入れる場合は注意が必要です。spatieは CollectorRegistry をコンテナに登録しており、自前のサービスプロバイダでも登録すると後から登録した側が勝ちます。検証環境では自前の登録が勝ち、/prometheus もRedisの中身をそのまま返しました。どちらのレジストリを使うかを1つに決めてから組み込んでください。
PrometheusでLaravelをスクレイプする設定
Prometheusの設定リファレンスでは、metrics_path の既定は /metrics、scrape_interval の既定は1分です。この例では15秒にしています。必要な検知時間とスクレイプの負荷で決める値で、1分で足りる監視もあります。トークンはファイルから読ませ、設定ファイルに直書きしません。Laravel側の PROMETHEUS_SCRAPE_TOKEN と同じ値を /etc/prometheus/laravel_token に保存し、Prometheusの実行ユーザーだけが読める権限にします。
global:
scrape_interval: 15s
rule_files:
- laravel_rules.yml
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: laravel
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/laravel_token
static_configs:
- targets: ["app.internal:80"]
promtool check config prometheus.yml
# SUCCESS: prometheus.yml is valid prometheus config file syntax
curl -s 'http://localhost:9090/api/v1/targets' # job="laravel" の health が "up" になれば成功
検証環境ではPrometheus 3.15.1を起動して数秒後に up{job="laravel"} が1になりました。0のままなら、ターゲット一覧の lastError を見ます。403ならトークン、404ならパス(spatieなら /prometheus)の食い違いです。
GrafanaによるLaravelメトリクスの可視化
Prometheusデータソースの追加
GrafanaのPrometheusデータソースは最初から組み込まれており、プラグインの追加は不要です。画面の「Connections」から追加してもよいですが、コンテナで動かすならプロビジョニングのYAMLに書いておくと作り直しで消えません。timeInterval をscrape_intervalと揃え、パネルのクエリで範囲を [$__rate_interval] にすると、ズームしても rate() の範囲が短すぎて空になることがありません。次節の例は固定の [5m] で、15秒間隔のスクレイプならこれで足ります。
# provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
jsonData:
httpMethod: POST
timeInterval: 15s # scrape_interval と合わせる
ダッシュボードに置くPromQL
Laravelのダッシュボードには、まず次の4枚を置きます。どれもpromphpの章で定義したメトリクス名をそのまま使えます。上から順に、ルート別のリクエスト数/秒、5xxの割合、ルート別の95%応答時間、取得できているか(1=成功)です。5xxが一度も出ていないと分子の系列そのものが存在せず、割合のパネルが0でなく「データなし」になるため、or vector(0) を付けています。
sum by (route) (rate(app_http_requests_total[5m]))
(sum(rate(app_http_requests_total{status=~"5.."}[5m])) or vector(0))
/ sum(rate(app_http_requests_total[5m]))
histogram_quantile(0.95,
sum by (le, route) (rate(app_http_request_duration_seconds_bucket[5m])))
up{job="laravel"}
95%応答時間の値はバケットの境界から補間した推定値です。検証環境では実測0.3〜0.37秒の /slow が 0.4875 と表示されました。全件が0.25〜0.5秒のバケットに入ると、その中を直線で補間するためです。バケットを増やしても補間の誤差は消えません。SLOの判定には分位点でなく割合を使います。閾値が0.5秒なら、その値ちょうどにバケット境界を置き、0.5秒以下の件数(le="0.5")を総件数で割って達成割合を出します。SLIの式の立て方は「SLIとは?SLOとの違いと指標の選び方・PromQLでの測り方」にまとめています。
過去に記録があるルートでも、集計期間中の観測が0件なら95%応答時間は NaN になります。一度も呼ばれていないルートは系列そのものがありません。グラフの空白はスクレイプの失敗でも起きるので、up のパネルと並べて見分けます。
Laravelのエラー率・死活アラートと通知先の設定
通知の仕組みは、Prometheusのアラートルール+Alertmanagerと、Grafana Alertingの2つがあります。ルールをGitで管理してレビューしたいなら前者、ダッシュボードの画面から作って運用担当が自分で閾値を変えたいなら後者が向きます。どちらでも条件式は同じPromQLです。Prometheus側で組む場合は、ルールファイル・prometheus.yml の rule_files と alerting(前章の設定に追記済み)・Alertmanagerの通知先の3つがそろって初めて通知が届きます。ルールファイルは次のとおりです。
groups:
- name: laravel
rules:
- alert: LaravelHigh5xxRatio
expr: |
sum(rate(app_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(app_http_requests_total[5m])) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "5xxの割合が5%を超えています"
- alert: LaravelMetricsDown
expr: up{job="laravel"} == 0
for: 5m
labels:
severity: critical
promtool check config prometheus.yml
# SUCCESS: 1 rule files found
# SUCCESS: 2 rules found
通知先はAlertmanagerの設定に書きます。次はSlackに送る最小構成で、Alertmanager 0.34.1の amtool で構文チェックを通したものです。WebhookのURLは秘密情報なのでファイルから読ませます。
# alertmanager.yml
route:
receiver: slack-ops
group_by: [alertname]
group_wait: 30s
repeat_interval: 4h
receivers:
- name: slack-ops
slack_configs:
- api_url_file: /etc/alertmanager/slack_url
channel: "#ops-alerts"
send_resolved: true
amtool check-config alertmanager.yml
# Checking 'alertmanager.yml' SUCCESS
# 設定を変えたら再読み込み(Prometheus・Alertmanagerとも)
kill -HUP $(pidof prometheus)
kill -HUP $(pidof alertmanager)
for: 10m を付けているのは、デプロイ直後の数件の500で鳴らさないためです。LaravelMetricsDown は、アプリが落ちたときだけでなく、トークンの更新漏れで403が返り続けたときにも鳴ります。
PHP-FPM・キュー・トレースなどアプリ外の指標
ミドルウェアで取れるのはLaravelまで届いたリクエストだけです。FPMのワーカーが埋まって待たされている時間は見えません。ワーカー数や待ち行列は、FPMのステータスページを読むexporterで取ります。定番の hipages/php-fpm_exporter は最新リリースがv2.2.0(2022-05-20)で、リポジトリはアーカイブされていませんが、3年以上リリースが出ていません。採用するなら、使うPHPの版でステータスページが読めるかを自分の環境で確かめてください。
キューの滞留は、spatieのQueue・Horizonコレクタか、ワーカーの側で処理件数を数えて出します。ワーカーの常駐と失敗ジョブの扱いは「Laravel Queueの使い方|ジョブ定義・worker常駐・失敗ジョブ運用を13.38で実測」を参照してください。Octaneでワーカーを常駐させる場合も、ワーカー間で値は共有されないため、保存先はRedisのままにします(「Laravel Octaneとは|ワーカー常駐の仕組みと状態リーク・採用条件」)。
リクエストの中で何が遅いのかまで追うなら、メトリクスではなくトレースが要ります。open-telemetry/opentelemetry-auto-laravel(1.10.0・2026-10-12公開・Laravel 10〜13対象)は、Laravelの処理にフックしてスパンを自動生成するパッケージです。動かすにはPHP拡張 opentelemetry・SDK・エクスポーターと、OTEL_PHP_AUTOLOAD_ENABLED などの設定が別に必要で、次の例はパッケージの追加と送信先の指定だけを示したものです(全体の手順はOpenTelemetryのPHPゼロコード計装の解説)。メトリクスもOTLPで送る構成にするなら、Prometheusは --web.enable-otlp-receiver で起動するとOTLPを直接受け取れます(PrometheusのOpenTelemetryガイド)。
composer require open-telemetry/opentelemetry-auto-laravel
# PrometheusをOTLP受信つきで起動しておく
prometheus --config.file=prometheus.yml --web.enable-otlp-receiver
# アプリ側の送信先(シグナル別の変数は完全なURLを書く)
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://prometheus:9090/api/v1/otlp/v1/metrics
トレースとメトリクスをGrafanaでまとめて見る構成は「Grafana OpenTelemetry連携の構成|OTLP受信から3シグナル統合まで」で扱っています。
LaravelログとPrometheusメトリクスの分離
Prometheusは数値の時系列を保存するもので、アプリのログを取り込む機能はありません。公式FAQもログにはGrafana LokiやOpenSearchを案内しています。Laravelの storage/logs をGrafanaで検索したいなら、ログはLokiに送り、Grafanaにはデータソースを2つ(PrometheusとLoki)登録します。手順は「Grafana Lokiとは?仕組み・Alloyでの導入手順とLogQLの書き方【v3.7】」、Laravel側のチャンネル設計は「Laravelのログ設定|logging.phpのチャンネル設計と保持期間・マスキング」にあります。
Prometheusのログレベルは、Prometheus自身の動作ログの設定です。値は debug・info・warn・error(既定 info)で、Prometheus 3.15.1では設定ファイルの runtime 節に log_level を書きます。起動オプションの --log.level も使えますが、--help に非推奨と表示されます。設定ファイル側なら再読み込みで変えられるので、スクレイプ失敗の原因を追う間だけ debug にできます。形式は --log.format で logfmt(既定)か json を選びます。
Prometheus監視を自前で持つかの判断
自前のPrometheusとGrafanaは、保持期間・冗長化・アップデートを自分で面倒見る前提の選択です。次の条件に当てはまるなら、この記事の構成を入れる意味があります。
- すでにPrometheusでサーバーやDBを監視していて、Laravelをそこに加えたい
- ルート別の応答時間やエラー率でSLOを決め、アラートの閾値をコードで管理したい
- 複数台・コンテナ構成で、APMの従量課金がホスト数に比例して膨らむ
逆に、サーバー1台の小規模アプリで、知りたいのが「どのクエリが遅いか」なら、Prometheusを立てるより開発用の Laravel Telescopeの使い方|インストール・設定・本番運用の判断基準 で足ります。監視基盤を見る担当者がいないチームでは、ダッシュボードを作っても見られずに終わるので、マネージドの監視サービスか、運用を外部に任せる形を先に検討してください。弊社でも保守運用・内製化支援として、監視設計からアラート運用の引き継ぎまで支援しています。
よくある質問
GrafanaでPHPアプリを監視するには何が必要ですか?
GrafanaはPHPのアプリを直接読みません。PHP側でPrometheus形式の値を出すライブラリ(promphp/prometheus_client_php)と、それを集めるPrometheusが必要で、GrafanaはPrometheusをデータソースとして参照します。ログを見たい場合はLokiを追加します。
LaravelとPrometheusの違いは何ですか?
LaravelはWebアプリを作るPHPのフレームワークで、Prometheusはそのアプリや周辺のサーバーから数値を集めて保存する監視ツールです。役割が違うため比較して選ぶものではなく、Laravelに計測コードを入れてPrometheusに読ませる形で組み合わせます。
カウンタが増えずにいつも1のままです。なぜですか?
ストレージがInMemoryになっていると、リクエストが終わるたびに値が消えます。promphpを直接使っているならRedisかAPCuのアダプタに、spatie/laravel-prometheusなら config/prometheus.php の cache に redis などのストアを指定してください。
promphpとspatie/laravel-prometheusはどちらを使うべきですか?
リクエスト数や応答時間のヒストグラムを取るならpromphpを直接使います。キューの件数やHorizonの状態など、読むたびに数え直せば済む値だけならspatieのほうが記述が少なく済みます。両方入れる場合は CollectorRegistry の登録を1つにまとめてください。
Prometheusのログレベルはどう変えますか?
Prometheus 3.15.1では設定ファイルの runtime 節の log_level に debug・info・warn・error のいずれかを書きます。既定は info です。起動オプションの --log.level は非推奨ですが、まだ使えます。これはPrometheus自身のログの設定で、Laravelのログを集める機能ではありません。