はじめに
個人で運営している中古バイク横断検索サービス「MotoHub」で、中古バイク市場のデータを外部提供するAPIを4本公開しました。
この記事は、**「すでに画面で動いている集計ロジックを、壊さずにAPI化する」**という、地味だけど個人開発でよくある作業の設計と、その過程でハマった罠をまとめたものです。同じことをやる人の参考になれば。
スタック:Laravel 12 / PHP 8.3 / MySQL / Redis / Meilisearch
公開した4本:
| エンドポイント | 内容 |
|---|---|
GET /api/v1/rankings/listings |
排気量クラス別の流通台数ランキング |
GET /api/v1/rankings/price-trends |
相場推移(値下がり/高騰)ランキング |
GET /api/v1/models/{id}/stats |
車種別の市場データ(地域/年式/走行距離/価格帯) |
GET /api/v1/market/stats |
市場全体のトレンド(地域/年式/走行距離) |
ドキュメントは /data に置いています。
設計の方針:「画面のロジックを正にして、APIはそれを包むだけ」
4本のうち3本は、すでにWebページでユーザーにグラフやランキングをUI表示していたものでした。つまり集計ロジックは既に存在している。ここで取れる選択は2つ:
- APIのために集計を書き直す
- 既存の集計を唯一の正として、APIはそれを呼んで整形するだけにする
迷わず2です。理由は単純で、1にすると「画面の数字」と「APIの数字」が二重管理になり、いつか必ずズレるから。「MotoHub調べ」として外部に出すデータが、サイトを見た数字と違ったら信用を失います。
具体的には、コントローラに private で埋まっていた集計メソッドを Service クラスに抽出し、ページもAPIも同じServiceを呼ぶ形にしました。
// Before: コントローラ内に集計が埋まっている
class RankingController {
private function getModelStats(int $id): array { /* 重い集計 */ }
}
// After: Service に抽出。ページもAPIも同じものを呼ぶ
class ModelStatsService {
public function getStats(int $id): array {
return Cache::remember("model_stats_ranking_v6_{$id}", 604800, fn () => $this->compute($id));
}
private function compute(int $id): array { /* 移動しただけ・ロジックは不変 */ }
}
ポイントは、抽出時にロジックを一行も変えないこと。キャッシュキーもTTLもそのまま移植する。こうすると「既存ページの挙動は完全に同一」を保証でき、APIは安全に相乗りできます。リファクタの差分が +342 / −117 で、117行の削除が「コントローラからの移動」だけ、という状態が理想です。
ハマった罠①:PHPは連想配列の「数値文字列キー」を勝手にintにする
排気量クラスを、こう定数で持っていました。
public const RANGES = [
'125' => [101, 125],
'250' => [226, 250],
'400' => [348, 400],
'large' => [751, null],
];
クラス一覧をループしてランキングを作る処理で、最初こうしていました。
foreach (self::RANGES as $class => $range) {
$this->classRanking($class, ...); // classRanking(string $class)
}
これが本番で TypeError: Argument #1 ($class) must be of type string, int given で落ちました。
原因は、PHPが連想配列の「整数として解釈できる文字列キー」を自動的に int に正規化する仕様です。'125' は 125(int) になり、'large' だけ string のまま。つまり large は通り、125/250/400 で型エラー。
教訓:キーに意味を持たせた配列をループするとき、数値っぽいキーは int 化される。 修正は「キーでなく値の配列でループ」。
foreach (['125', '250', '400', 'large'] as $class) { // string のまま保持
$this->classRanking($class, ...);
}
classRanking(string $class) の型宣言を緩める誘惑に駆られますが、それは間違い。large という非数値キーがある以上、呼び出し側で string を保証するのが正解です。
ハマった罠②:「クラスの境界」は実データを見ないと決められない
「250ccクラス」を素直に 126〜250cc で集計すると、150/155/160ccのスクーターが混ざります。"250ccバイク"として見せたいのは実質 226〜250cc。
さらに6区分に拡張したとき、各帯の下限で実車種の分布を確認する必要がありました。たとえば「400ccクラス」の下限を 376 にするか 348 にするかで、GB350(348cc)が入るかどうかが変わる。キリのいい数字(350, 400…)で機械的に切ると、入れたい車種が落ちたり、意図しない車種が混ざったりします。
教訓:ドメイン特有の「区切り」は、定数の見た目の綺麗さではなく、実データの分布で決める。 そして公開後は、各クラスの上位数件を必ず目視して、変な車種が混ざっていないか確認する。
ハマった罠③:「車種ごとなら平気」な集計が、市場全体にすると牙をむく
4本目の「市場全体トレンド」で、年式別ランキングを作ったら、model_year = 5010 という明らかなゴミデータが1位に来ました。
面白いのは、同じ集計ロジックを車種別ページでは何ヶ月も使っていたのに、表面化していなかったこと。車種別は「1車種に絞り込み+上位10件」なので、ゴミ年式が紛れていても上位に出てこなかった。市場全体に広げて初めて、データ品質の穴が見えたわけです。
対策は健全な年式範囲(1900〜当年+1)で絞る。車種別の挙動は変えず、市場全体クエリにだけ制約を足すことで、既存を壊さず修正しました。
教訓:スコープを広げると、今まで隠れていたデータ品質の問題が顕在化する。 「車種ごとで動いてたから市場全体でも大丈夫」は成り立たない。
おまけ:公開APIにした瞬間、パフォーマンスが「自分の問題」でなくなる
市場全体の年式・走行距離集計は、cappedSold(成約判定のためのウィンドウ関数 ROW_NUMBER())が約14万行を舐めるため、コールドで約8秒かかりました。
画面なら1時間キャッシュで隠せます(ウォーム時2ms)。でも外部公開APIは、知らない人がキャッシュの切れた瞬間に叩く。そのリクエストは8秒待たされるし、同時に来ればDBに負荷が乗る。「自分しか踏まないキャッシュミス」と「他人が踏むキャッシュミス」は重みが違う。
当座は1時間キャッシュで凌ぎつつ、スケジューラでの定期ウォーム、あるいは日次の事前集計テーブルを検討中です。whereIn で capped ID を持ち回る案も考えましたが、対象が約10万行で placeholder 上限を超えるため不採用。このあたりは続編で書くかもしれません。
教訓:公開APIにする=パフォーマンスがUXではなくインフラの問題になる。 キャッシュは「隠す」だけで「速くする」わけではないので、重いクエリは結局どこかで根本対処が要る。
まとめ
- 既存の集計をServiceに抽出して唯一の正にし、ページもAPIも同じものを呼ぶ。これで数字のズレと二重管理を防ぐ
- 抽出時はロジックを変えない。差分が「移動だけ」になっている状態を目指す
- PHPの数値文字列キーの int 化、ドメインの境界定義、スコープ拡大によるデータ品質の顕在化 ―― この3つは個人開発でも普通に踏む
- 公開APIにするとパフォーマンスの意味が変わる。キャッシュは万能ではない
データAPIは /data で公開しています。中古バイクのデータを使ってみたい方はどうぞ。