はじめに
個人で運営している中古バイク横断検索サービス「MotoHub」で、車種別の流通台数ランキングを返すデータAPIを公開しました。
この記事では、その設計・実装と、地味にハマったポイントを技術中心にまとめます。
スタック:Laravel 12 / PHP 8.3 / MySQL / Redis / Meilisearch
何を作ったか
排気量クラス別(125 / 250 / 400 / 大型)に「中古市場で今どの車種が何台流通しているか」のランキングを返すREST APIです。
GET /api/v1/rankings/listings?class=250
レスポンス(イメージ):
{
"class": "250",
"updated_at": "2026-06-27T09:00:00+09:00",
"rankings": [
{ "rank": 1, "model": "レブル250", "count": 1000 },
{ "rank": 2, "model": "...", "count": 0 }
],
"source": "MotoHub"
}
メディアやYouTuberが「MotoHub調べ」として記事・動画の裏付けに使えるようにするのが目的です。
設計のポイント
1. APIキーはSHA-256でハッシュ保存(平文は発行時のみ)
キーはDBに平文で持たず、SHA-256ハッシュで保存します。発行時に一度だけ平文を表示し、以降は再表示しない方針。照合はリクエストのキーをハッシュ化して突き合わせます。
// ※実装イメージ。実際のコードに合わせて差し替えてください
$plain = Str::random(48);
ApiKey::create([
'name' => $name,
'key_hash' => hash('sha256', $plain),
]);
// 平文 $plain はこの場でだけ表示し、保存しない
// 照合
$key = ApiKey::where('key_hash', hash('sha256', $request->apiKey()))->first();
2. キー単位のスロットリング
レート制限はIPではなくキー単位で 10req/min・100req/day。Laravelのrate limiterをキー単位で適用しています。
3. 集計は1時間キャッシュ(画像生成コマンドと共有)
ランキング集計を毎回DBに投げると重いので、集計ロジックを ClassRankingService に集約し、結果を1時間キャッシュ。
ポイントは、同じサービスをランキング画像生成コマンド(motohub:gen-class-ranking)からも呼んでいること。APIと画像で集計ロジックを一本化することで、出口が増えても数字がブレません。
4. 例外時はgracefulに(スタックトレースを出さない)
データ取得に失敗してもstack traceは返さず、503で簡潔なエラーに丸めます。外部公開APIなので、内部情報を漏らさないことを優先。
5. ヘッダ・クエリの両対応
X-API-Key ヘッダと ?api_key= クエリの両方を受け付けます。ブラウザのURLで叩いて動作確認できるよう、あえてクエリも許可しています(相手に使い方を説明するときも楽)。
ハマったところ:排気量クラスの「境界」
一番ハマったのが、クラスの定義でした。
「250ccクラス」を素直に 126〜250cc で集計すると、150 / 155 / 160cc のスクーター等が混入します。でも「250ccバイク」として見せたいのは実質 226〜250cc。デフォルト範囲のまま出したら、ランキングに意図しない車種が並んでしまいました。
結局、クラスごとに排気量の下限・上限を明示的に持たせて解決。「クラス=キリのいい数字の範囲」と思い込むと事故ります。
※125 / 400クラスの境界は、使う場面が来たらデータを見ながら調整中。現状250は 226〜250 が正確です。
おわりに
- 集計ロジックをサービスに集約してAPI・画像・コマンドで共有しておくと、出口が増えても破綻しにくい
- 個人開発でも、外部公開する以上は認証・レート制限・例外処理を最初から入れておくと後がラク
- ドメイン特有の「区切り」(今回は排気量クラス)は、実データを見て定義しないとズレる
データ系の方への提供も始めています。興味があれば MotoHub まで。