Bladeの条件分岐が本番で効かない → 原因は「データの経路」だった。3時間デバッグした記録😇
はじめに
バイクポータルサイト「MotoHub」を個人開発しています。
ニュースページのサムネイルに、メーカーロゴが object-cover でトリミングされて「kaw」しか見えない問題を直しました。
修正は Str::contains($url, 'manufacturers/') の1行。
でもその1行に辿り着くまで3時間かかりました。ローカルでは動くのに本番では動かない。OPcache? Cloudflare? Nginxキャッシュ? 全部疑って全部ハズレ。
原因は自分の条件分岐の前提が間違っていた。
これはキャッシュの話じゃなくて、フォールバック画像の「データの経路」を把握していなかった話です。
環境
さくらVPS 4GB
├── Docker Compose
│ ├── Nginx(webコンテナ)
│ ├── PHP-FPM 8.3 + OPcache(appコンテナ)
│ └── MySQL 8
├── Cloudflare CDN(HTML含むフルキャッシュ)
└── デプロイ: git pull → artisan view:clear → config:cache → Cloudflare Purge Everything
問題:ロゴが見切れて何のメーカーか分からない
ニュース一覧ページで、サムネイルにメーカーロゴが使われるケースがあります。
例えばカワサキのニュースで、ニュース記事自体にサムネイル画像がない場合、フォールバックでカワサキのロゴが表示される。
でも object-cover でトリミングされるので、ロゴの中央部分だけが切り取られて「kaw」しか見えない。
これを object-contain にすればロゴ全体が表示される。簡単。
最初の修正:条件分岐で出し分け
サムネイルが「ニュース記事の画像」なら object-cover、「メーカーロゴにフォールバック」なら object-contain。
ニュース一覧のBladeテンプレートには、サムネイルのフォールバックチェーンが書いてある。
@php
$featThumb = $item->thumbnail_url
?: ($item->bikeModel ? $item->bikeModel->image_url : null)
?: ($item->manufacturer && $item->manufacturer->local_logo_path
? asset('storage/' . ltrim($item->manufacturer->local_logo_path, '/'))
: null)
?: ($item->manufacturer ? $item->manufacturer->logo_url : null);
// ロゴにフォールバックしたかどうかを判定
$isFeatLogo = !$item->thumbnail_url
&& !($item->bikeModel && $item->bikeModel->image_url)
&& $featThumb;
@endphp
<img src="{{ $featThumb }}"
class="w-full h-full {{ $isFeatLogo ? 'object-contain p-2' : 'object-cover' }}"
>
ロジック的には完璧。ローカルでもちゃんと動いた。
push → pull → view:clear → config:cache → Cloudflare Purge Everything。
本番:変わらない。
デバッグ地獄の始まり
ここから3時間の旅が始まります。
試したこと①:Cloudflareのキャッシュ?
# Cloudflare → Purge Everything
# → シークレットウィンドウで確認
# → 変わらない
試したこと②:ブラウザキャッシュ?
# Ctrl + Shift + R(スーパーリロード)
# → 変わらない
試したこと③:OPcache?
docker compose exec app php -r "opcache_reset(); echo 'OK';"
# OK
# → 変わらない
docker compose restart app
# → 変わらない
試したこと④:Viewキャッシュが残ってる?
docker compose exec app rm -rf storage/framework/views/*.php
docker compose exec app php artisan view:clear
# → 変わらない
試したこと⑤:コンテナ内のファイルは更新されてる?
docker compose exec app grep "object-contain p-2" resources/views/news/index.blade.php
<img src="{{ $featThumb }}" class="w-full h-full {{ $isFeatLogo ? 'object-contain p-2' : 'object-cover' }}"
ファイルは更新されている。 コードは正しい。デプロイも成功している。
じゃあなぜ object-cover のままなのか?
転機:実際のHTMLを見る
Cloudflareを通さず、サーバーから直接HTMLを取得。
curl -skL -H "Host: motohub.jp" https://localhost/news | grep "object-" | head -10
<img src="https://motohub.jp/storage/manufacturers/2/0.png"
class="w-full h-full object-cover group-hover:scale-105 ..."
object-cover のまま。 $isFeatLogo が false になっている。
Cloudflareのせいじゃない。OPcacheのせいでもない。条件分岐自体が false を返している。
原因究明:tinkerで実データを確認
docker compose exec app php artisan tinker --execute="
\$news = \App\Models\BikeNews::with(['bikeModel','manufacturer'])->latest()->take(5)->get();
foreach(\$news as \$n) {
echo \$n->id . ': thumb=' . (\$n->thumbnail_url ?: 'NULL') . PHP_EOL;
}
"
結果:
1051: thumb=NULL
1049: thumb=https://motohub.jp/storage/listings/goobike/32/830032/0.jpg
1050: thumb=https://motohub.jp/storage/listings/goobike/32/830032/0.jpg
1048: thumb=https://motohub.jp/storage/manufacturers/4/0.png ← !!!
1047: thumb=NULL
ID 1048の thumbnail_url に、メーカーロゴのURL manufacturers/4/0.png が直接入っている。
真の原因
条件分岐を振り返る。
$isFeatLogo = !$item->thumbnail_url // ← thumbnail_urlが空ならtrue
&& !($item->bikeModel && $item->bikeModel->image_url)
&& $featThumb;
ID 1048は $item->thumbnail_url に https://motohub.jp/storage/manufacturers/4/0.png が入っている。
つまり !$item->thumbnail_url は false。
$isFeatLogo は 常にfalse。
条件分岐が一生通らない。
なぜ thumbnail_url にロゴのURLが入っていたのか
ニュースのクローリング時に、RSSフィードの画像がなかった場合に、MotoHub側でメーカーロゴのURLを thumbnail_url に保存していた。つまりフォールバック処理がDB保存の段階で既に行われていて、Bladeテンプレートのフォールバックチェーンは通らなかった。
期待していた流れ:
thumbnail_url = NULL → bikeModel.image_url = NULL → manufacturer.logo_url ← ここでフォールバック
実際の流れ:
thumbnail_url = "https://...manufacturers/4/0.png" ← 既にロゴのURLが入っている
→ フォールバック不要 → $isFeatLogo = false
修正:URLの「中身」で判定する
「thumbnail_urlが空かどうか」ではなく、「URLに manufacturers/ が含まれているかどうか」で判定する。
@php
$isFeatLogo = $featThumb && Str::contains($featThumb, 'manufacturers/');
@endphp
これだけ。 thumbnail_urlにロゴが直接入っていても、URLの文字列でロゴかどうかを判定できる。
教訓
教訓①:「本番で動かない」= まず自分のロジックを疑う
キャッシュを疑うのは間違いじゃない。でも3時間のうち2時間をキャッシュ系の調査に使ってしまった。
正しいデバッグ順序:
1. curl で実際のHTMLを確認(何が出力されているか)
2. tinker で実データを確認(条件分岐に渡っているデータは何か)
3. 条件の前提が正しいか確認 ← ここが本丸
4. キャッシュを疑うのはその後
教訓②:フォールバックの「データの経路」を全部把握する
今回の問題は、フォールバックが2段階で起きていたこと。
段階1(DB保存時): RSSに画像なし → メーカーロゴURLをthumbnail_urlに保存
段階2(Blade表示時): thumbnail_url → bikeModel.image_url → manufacturer.logo_url
段階1で既にロゴURLが入っているので、段階2のフォールバックは通らない。自分のコードの中に2つの経路があることに気づいていなかった。
教訓③:「nullかどうか」より「中身が何か」で判定する
// ❌ Bad: データの経路によっては thumbnail_url にロゴが入る
$isLogo = !$item->thumbnail_url;
// ✅ Good: 中身を見て判定する
$isLogo = Str::contains($thumb, 'manufacturers/');
nullチェックは「データがどこから来たか」に依存する。URLの文字列チェックは「データの中身」を見ている。後者の方が堅牢。
デバッグ3時間の時系列
| 時刻 | やったこと | 結果 |
|---|---|---|
| 21:00 | コード修正、push/pull | ローカルOK、本番変わらず |
| 21:10 | Cloudflare Purge | 変わらず |
| 21:20 | シークレットウィンドウ | 変わらず |
| 21:30 | OPcacheリセット | 変わらず |
| 21:45 | docker compose restart app | 変わらず |
| 22:00 | Viewキャッシュ強制削除 | 変わらず |
| 22:15 | Nginxキャッシュ削除試行 | Nginxはsystemctlで管理されてなかった |
| 22:30 | curl で直接HTML取得 | object-coverのまま |
| 22:45 | コンテナ内ファイル確認 | コードは正しい |
| 23:00 | tinkerで実データ確認 | thumbnail_urlにロゴURL発見 |
| 23:10 | Str::containsで判定に変更 | 解決 |
22:30のcurlの時点で「キャッシュの問題じゃない」と気づくべきだった。 そこからtinkerまで30分かかったのは反省。
まとめ
| 問題 | 原因 | 解決 |
|---|---|---|
| ロゴが見切れる | object-cover でトリミング | ロゴの場合はobject-contain |
| 条件分岐が効かない | thumbnail_urlにロゴURL直入り | URLの文字列で判定に変更 |
| 本番に反映されない(と思った) | 実はキャッシュの問題じゃなかった | tinkerでデータを見て原因判明 |
「本番で動かない」の9割はキャッシュじゃない。自分のロジックの前提が間違っている。
…とはいえ、残りの1割は本当にキャッシュなので、docker compose restart app と Cloudflare Purge Everything は覚えておいて損はないです。
前回の記事:「売り切れデータ」を捨てずに全部残したら、GooBikeに真似できない武器になった💎
🏍 MotoHub: https://motohub.jp
X: https://x.com/motohub_jp
GitHub: https://github.com/ausssxi/MotoHub


