LaravelブログをCloudflare環境にデプロイしたら踏んだ地雷まとめ💥 — JSON-LDの@がBladeに食われた話ほか
はじめに
バイク中古・新車一括検索プラットフォーム「MotoHub」を個人開発しています。
設計編、実装編に続いて、今回はデプロイ編です。ローカルでは完璧に動いていたブログ機能を本番(さくらVPS + Docker + Cloudflare)にデプロイしたら、想定外のエラーが連発しました。
踏んだ地雷を全部書きます。同じ構成の人は参考にしてください。
環境
ローカル:
├── WSL2 (Ubuntu)
├── Docker Desktop
└── Claude Code(CLI)で開発
本番:
├── さくらVPS 4GB
├── Docker (docker-compose)
│ ├── Nginx
│ └── PHP-FPM(Laravel 12)
├── MySQL 8
├── Cloudflare(CDN + HTTPS + WAF + キャッシュ)
└── GitHub(デプロイはgit pull方式)
地雷① JSON-LDの @context がBladeディレクティブとして解釈される
症状: 記事詳細ページで500エラー(ParseError)
syntax error, unexpected end of file, expecting "elseif" or "else" or "endif"
resources/views/blog/show.blade.php:194
原因: JSON-LD構造化データの中に書いた @context、@type、@id がBladeのディレクティブとして解釈されていました。
<!-- これがBladeディレクティブと衝突する -->
<script type="application/ld+json">
{
"@context": "https://schema.org", ← Bladeが @context を探す
"@type": "BlogPosting", ← Bladeが @type を探す
}
</script>
解決策: @@ でエスケープ。
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BlogPosting",
"@@id": "{{ $post->url }}"
}
</script>
Blade内で @@ は @ 1文字に変換されるので、出力されるHTMLは正しいJSON-LDになります。
教訓: Bladeテンプレート内でJSON-LDを書く場合、@ で始まるプロパティは全て @@ にエスケープする。これはLaravelの公式ドキュメントにも書いてありますが、JSON-LDを書く時に忘れがちです。
地雷② $this->authorize() が動かない
症状: 記事の編集ページ(/admin/blog/posts/1/edit)で500エラー
Call to undefined method App\Http\Controllers\Admin\BlogPostController::authorize()
原因: Laravel 11以降、Controller 基底クラスに AuthorizesRequests トレイトが自動で含まれなくなりました。
解決策: コントローラに明示的にトレイトを追加。
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class BlogPostController extends Controller
{
use AuthorizesRequests; // ← これが必要
public function edit(int $id)
{
$post = BlogPost::findOrFail($id);
$this->authorize('update', $post); // ← これが動くようになる
}
}
教訓: Laravel 11+で $this->authorize() を使うなら、AuthorizesRequests トレイトを忘れずに。Laravel 10以前の記事やチュートリアルを参考にしていると、この変更に気づかない。
地雷③ 本番の管理画面で403 Forbidden
症状: ブログの管理画面(/admin/blog/posts)にアクセスすると403。ローカルでは動く。
原因: Gate manage-blog の認可チェックで弾かれていた。本番のユーザーに role='admin' が設定されていなかった。
マイグレーションでは is_admin=true のユーザーを自動的に role='admin' に更新する処理を入れていたが、ユーザーID=1を前提にしていた。本番の管理者ユーザーはID=2だった。
// マイグレーション(ID=1を前提にしていた)
DB::table('users')->where('id', 1)->update(['role' => 'admin']);
// 本番の管理者はID=2 → role が null のまま
解決策: tinkerで手動修正。
docker compose exec app php artisan tinker \
--execute="App\Models\User::where('id', 2)->update(['role' => 'admin']);"
教訓: マイグレーションでユーザーデータを更新する場合、IDをハードコードしない。is_admin=true のような条件で更新するべき。
// こう書くべきだった
DB::table('users')->where('is_admin', true)->update(['role' => 'admin']);
地雷④ 画像アップロードでPermission denied
症状: 記事を画像付きで保存すると500エラー。
Unable to write to directory: /var/www/storage/app/public/blog/images/2026/03
原因: Docker内のPHP-FPMプロセス(www-data)が storage/app/public/blog/images/ ディレクトリに書き込めない。
解決策: ディレクトリ作成+権限設定。
docker compose exec app mkdir -p storage/app/public/blog/images
docker compose exec app chmod -R 775 storage/app/public/blog/images
教訓: Docker環境ではホスト側とコンテナ側でUID/GIDが一致しないことがある。特に storage/ 配下に新しいサブディレクトリを作る場合は権限を明示的に設定する。
地雷⑤ composer dump-autoload を忘れて「blog namespace not found」
症状: サーバーで php artisan blog:generate-sitemap を実行すると:
There are no commands defined in the "blog" namespace.
ファイルは存在するのにArtisanコマンドが認識されない。
原因: composer dump-autoload を実行していなかった。新しいPHPクラスを追加した後、Composerのオートローダーを更新しないとクラスが見つからない。
# これだけで解決
docker compose exec app composer dump-autoload
教訓: git pull で新しいPHPファイルを追加したら、composer dump-autoload を忘れない。特にArtisanコマンド・ServiceProvider・Observerなどの「自動登録される系」のクラスで発生しやすい。
地雷⑥ git stashの地獄
これが一番やばかった。
症状: 本番でナビゲーションバーにgitのコンフリクトマーカーが表示される。
<<<<<<< Updated upstream ======= ブログ >>>>>>> Stashed changes
原因の経緯:
- サーバーで
composer require league/commonmarkを実行 →composer.jsonとcomposer.lockが変更される - その後
git pullしようとすると「ローカルの変更が上書きされる」エラー -
git stashしてgit pull→git stash popでstash復元 - stash復元時にナビゲーションのBladeファイルでコンフリクト発生
- コンフリクトマーカーがそのままコミット・デプロイされてしまった
解決策:
# ローカルでコンフリクトマーカーを検索
grep -rn "<<<<<<" backend/resources/views/components/
# 該当ファイルを修正してcommit & push
教訓:
- 本番サーバーで
composer requireしない。ローカルで実行してcomposer.jsonとcomposer.lockをcommitしてからデプロイする -
git stash popした後は必ずgrep -rn "<<<<<<" .でコンフリクトが残っていないか確認する - 本番サーバーでのgit操作は
git pullのみ。stash/merge/checkoutは極力避ける
地雷⑦ ローカルでpush忘れに気づかない
症状: サーバーで git pull しても何も来ない。php artisan blog:generate-sitemap も動かない。
原因: ローカルで一度もpushしていなかった。Claude Code(CLI)はファイルの作成・編集はしてくれるが、git pushは自動ではやらない。
さらに悪いことに、Claude Codeが feature/parts-price-compare ブランチで作業していたため、main ブランチには何もない状態だった。
解決策:
# ブランチの確認
git branch # ← feature/parts-price-compare にいた
# mainに切り替えてマージ
git checkout main
git merge feature/parts-price-compare
git push
教訓: Claude Codeを使う場合、作業後に以下を確認する習慣をつける:
-
git status— 未コミットのファイルがないか -
git branch— どのブランチにいるか -
git log --oneline -3— コミットされているか -
git push— リモートに反映されているか
デプロイチェックリスト(最終版)
上記の地雷を踏まえて作った、MotoHubブログのデプロイチェックリストです。
# === ローカル ===
# 1. ブランチ確認
git branch # mainにいること
# 2. コンフリクトマーカー確認
grep -rn "<<<<<<" backend/
# 3. commit & push
git add -A
git status # 差分確認
git commit -m "your message"
git push
# === サーバー ===
# 4. pull
cd /var/www/motohub
git pull
# 5. Composerオートローダー更新(新PHPファイルがある場合)
docker compose exec app composer dump-autoload
# 6. マイグレーション(DBスキーマ変更がある場合)
docker compose exec app php artisan migrate --force
# 7. キャッシュクリア
docker compose exec app php artisan config:cache
docker compose exec app php artisan view:clear
docker compose exec app php artisan route:clear
# 8. サイトマップ再生成
docker compose exec app php artisan blog:generate-sitemap
# 9. Cloudflareキャッシュパージ
# Cloudflareダッシュボードから Purge Everything
# または: docker compose exec app php artisan blog:purge-cache --all
まとめ
| 地雷 | 原因 | 教訓 |
|---|---|---|
| JSON-LDの@がBladeに食われる | Bladeが @context をディレクティブと認識 |
@@ でエスケープ |
| authorize()が動かない | Laravel 11+でトレイトが自動追加されなくなった | AuthorizesRequestsを明示追加 |
| 本番で403 Forbidden | ユーザーのroleが未設定 | IDハードコードしない |
| 画像Permission denied | Docker内の権限問題 | mkdir + chmod 775 |
| コマンドが見つからない | composer dump-autoload忘れ | 新PHPファイル追加後は必須 |
| コンフリクトマーカー混入 | git stash pop後の確認漏れ | grep "<<<<<<" で必ず確認 |
| push忘れ | Claude Codeはpushしない | 作業後に必ずpush確認 |
個人開発はCI/CDパイプラインを組むほどでもないけど、手動デプロイだからこそチェックリストが命です。
前回の記事:管理画面はmarked.js、公開画面はleague/commonmark — MarkdownのハイブリッドレンダリングをLaravelで実装した話
MotoHub: https://motohub.jp
MotoHubブログ: https://motohub.jp/blog
