Laravel×Vue.jsでSPAを構築する:API分離・認証・ルーティング・デプロイまでの実務パターン

Laravel×Vue.jsでSPAを構築する:API分離・認証・ルーティング・デプロイまでの実務パターン

Laravelをバックエンド(API)として、Vue.jsをフロント(SPA)として構築すると、画面遷移の高速化・UIのリッチ化・フロント/バックの責務分離が進めやすい。一方で、ルーティングの衝突、認証(Cookie/トークン)、CORS/CSRF、ビルド成果物の配信、404の扱いを雑にすると、開発環境では動くのに本番で崩れる。この記事は、最小構成から実務で詰まりやすいポイントとエラー発生条件、サンプルコードまでをまとめる。

構成の決め方:同一ドメインSPAか、API分離(別ドメイン)か

同一ドメイン(LaravelがVueのビルド成果物も配信):
・CORSが不要になりやすい
・Cookieベース認証が簡単
・デプロイが一体化しやすい
API分離(フロント別ホスト):
・フロントを静的ホスティングに載せやすい
・CORS/CSRF設計が必須
・環境変数/URL管理が重要
最初は同一ドメイン構成に寄せると、エラー切り分けが楽。

ディレクトリ構成:Laravel(API)とVue(SPA)の置き方

よくあるパターン:
・Laravelプロジェクト内にVue(Vite)を同居(resources/js)
・フロント別リポジトリでAPI叩く
この記事のサンプルは「同居」を前提にしつつ、API分離でも使える設計に寄せる。

Laravel側:APIの土台(ルーティング・コントローラ・レスポンス形式)

SPAは「画面のデータはAPIから取る」が基本。レスポンス形式(JSONの形)を揃えると、フロント実装が安定する。

// routes/api.php(例)
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\TodoController;

Route::get('/health', fn () => ['ok' => true]);

Route::get('/todos', [TodoController::class, 'index']);
Route::post('/todos', [TodoController::class, 'store']);
Route::patch('/todos/{todo}', [TodoController::class, 'update']);
Route::delete('/todos/{todo}', [TodoController::class, 'destroy']);

// app/Http/Controllers/Api/TodoController.php(例)
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Models\Todo;

class TodoController extends Controller
{
    public function index(): JsonResponse
    {
        return response()->json([
            'data' => Todo::query()->latest()->get()
        ]);
    }

    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:200'],
        ]);

        $todo = Todo::create([
            'title' => $validated['title'],
            'done' => false,
        ]);

        return response()->json(['data' => $todo], 201);
    }

    public function update(Request $request, Todo $todo): JsonResponse
    {
        $validated = $request->validate([
            'title' => ['sometimes', 'string', 'max:200'],
            'done'  => ['sometimes', 'boolean'],
        ]);

        $todo->update($validated);

        return response()->json(['data' => $todo]);
    }

    public function destroy(Todo $todo): JsonResponse
    {
        $todo->delete();
        return response()->json([], 204);
    }
}

エラー発生条件:
・バリデーションエラー(422)がフロントで未処理 → 画面が黙って失敗
・暗黙ルートモデルバインドの対象が存在しない → 404(フロントで「見つからない」表示が必要)
・JSON形式がエンドポイントごとにバラバラ → フロントの型/分岐が増殖

SPAのルーティング:Laravelの404とVue Routerの404を衝突させない

SPAはURL直打ち(/app/todos など)でサーバーが返すHTMLが必要。Vue Routerが担当するURLでも、Laravel側は「SPAのindex.html(Blade)」を返す必要がある。

// routes/web.php(例)
// /app 配下はすべてSPAへ(APIは /api に寄せる)
use Illuminate\Support\Facades\Route;

Route::view('/app/{any?}', 'spa')
    ->where('any', '.*');

<!-- resources/views/spa.blade.php(例:最小) -->
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  @vite('resources/js/app.js')
</head>
<body>
  <div id="app"></div>
</body>
</html>

エラー発生条件:
・このキャッチオールが無い → 本番でURL直打ちするとLaravelの404
・APIも同じ配下に混ぜる → /app/api/… がSPAに吸われて壊れる(APIは /api に統一)

Vue側:Vue Router+AxiosでAPIを叩く最小サンプル

Vueは「ルート(画面)」「APIクライアント」「状態」を分けると崩れにくい。

// resources/js/app.js(例)
import { createApp } from 'vue';
import App from './components/App.vue';
import router from './router';

createApp(App).use(router).mount('#app');

// resources/js/router/index.js(例)
import { createRouter, createWebHistory } from 'vue-router';
import TodoPage from '../views/TodoPage.vue';

export default createRouter({
  history: createWebHistory('/app/'),
  routes: [
    { path: '/', redirect: '/todos' },
    { path: '/todos', component: TodoPage },
    { path: '/:pathMatch(.*)*', component: { template: '<div>Not Found</div>' } },
  ],
});

// resources/js/views/TodoPage.vue(例)
<template>
  <div>
    <h3>Todo</h3>

    <form @submit.prevent="createTodo">
      <input v-model="title" placeholder="title" />
      <button type="submit">Add</button>
    </form>

    <ul>
      <li v-for="t in todos" :key="t.id">
        <label>
          <input type="checkbox" :checked="t.done" @change="toggle(t)" />
          {{ t.title }}
        </label>
        <button @click="remove(t)">Delete</button>
      </li>
    </ul>

    <div v-if="error" style="white-space: pre-wrap;">{{ error }}</div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../lib/api';

const todos = ref([]);
const title = ref('');
const error = ref('');

async function load() {
  error.value = '';
  try {
    const res = await api.get('/todos');
    todos.value = res.data.data;
  } catch (e) {
    error.value = e?.response?.data?.message ?? String(e);
  }
}

async function createTodo() {
  error.value = '';
  try {
    await api.post('/todos', { title: title.value });
    title.value = '';
    await load();
  } catch (e) {
    // 422(バリデーション)をそのまま見える化
    error.value = JSON.stringify(e?.response?.data ?? {}, null, 2);
  }
}

async function toggle(t) {
  error.value = '';
  try {
    await api.patch(`/todos/${t.id}`, { done: !t.done });
    await load();
  } catch (e) {
    error.value = e?.response?.data?.message ?? String(e);
  }
}

async function remove(t) {
  error.value = '';
  try {
    await api.delete(`/todos/${t.id}`);
    await load();
  } catch (e) {
    error.value = e?.response?.data?.message ?? String(e);
  }
}

onMounted(load);
</script>

エラー発生条件:
・baseURL が本番とズレる(/api ではなく別ドメイン等) → Network Error / 404
・Vue Routerのbase(/app/)を合わせていない → 画面遷移やリロードで崩れる

認証の考え方:SPAで詰まりやすいCookie/CSRF/CORSの境界

同一ドメイン構成(LaravelがSPA配信):
・Cookieセッション + CSRF の流れが作りやすい
・CORSを意識しなくて済むことが多い
API分離(別ドメイン):
・CORS許可(Access-Control-Allow-Origin等)を適切に設定
・Cookieを跨ぐなら SameSite/secure が絡む
・トークン方式(Bearer)に寄せると設計が単純化する場合がある
エラー発生条件(典型):
・419 Page Expired(CSRF不一致)
・CORS error(ブラウザがブロック)
・401 Unauthorized(認証情報が送られていない)

ビルドと配信:Viteの成果物・キャッシュ・本番デプロイでハマる点

本番は「ビルド成果物が正しく配信される」ことが最重要。
・@vite が参照するmanifestが無い/古い → 画面真っ白
・静的ファイルのキャッシュが強すぎる → 更新したのに反映されない
・.env の API URL をビルド時に埋め込んでいる → 環境差で壊れる
同居構成なら、デプロイ手順で “npm build と PHP側キャッシュ” の順序を固定する。

トラブルシュート:よくある症状→原因の当たり所

・URL直打ちで404:Laravel側のキャッチオール不足、Nginx/Apacheの設定不足
・画面が真っ白:Viteビルド未実行、manifest不整合、JSエラー(Console確認)
・APIだけ404:/api のルーティング、prefix、ベースURL、プロキシ設定
・419/CSRF:トークン取得/送信不足、Cookieドメイン/パス、SameSite
・CORSで止まる:許可Origin/Methods/Headers、プリフライトOPTIONS未対応
まず「Networkタブ」「Console」「Laravelログ」を同時に見ると切り分けが速い。

まとめ:Laravel×Vue SPAを崩さず作るための最小ルール

・APIは /api、SPAは /app などで責務を分離して衝突を避ける
・SPA用キャッチオールをLaravel側に用意し、URL直打ちに耐える
・レスポンスJSONの形を揃え、422/401/404をフロントで明示的に扱う
・認証方式(同一ドメインCookieか、分離トークンか)を先に決める
・本番はビルド成果物とキャッシュ(manifest/静的配信)を最優先で確認する