シングルページアプリケーション(SPA)は、画面全体を読み直す代わりに、必要なデータをAPIから取得して表示を更新するWebアプリケーションです。
Laravelをデータ提供と認証を担うバックエンド、Vue.jsを操作画面を担うフロントエンドに分けると、両者の責務を整理しやすくなります。
ただし、SPAにすれば自動的に使いやすくなるわけではありません。
認証方式、APIの応答形式、エラー処理、キーボード操作、動的更新の通知までを一つの設計として決める必要があります。
レンダリング方式を選ぶ段階にいる場合は、先にSPA、SSR、SSGの違いと使い分けを確認してください。
構築前に決める責務と境界
APIは、フロントエンドからの要求を受け取り、認証と認可を確認し、データを一定の形式で返す接点です。
画面とAPIの役割を分けても、データ項目やエラー形式の約束が曖昧なら変更のたびに双方を直すことになります。
| 層 | 主な責務 | 先に決めること |
|---|---|---|
| Laravel API | 認証、認可、入力検証、データ保存、JSON応答 | URL、HTTPメソッド、応答形式、エラー形式 |
| Vue.js | 画面表示、入力、状態管理、画面遷移 | コンポーネント境界、読み込み中、空表示、失敗時の表示 |
| 運用基盤 | 配信、監視、ログ、障害対応 | 環境変数、CORS、Cookie、HTTPS、監視方法 |
Vue.jsを採用するか迷っている場合は、Vue.jsとReactの役割や向いている開発ケースも判断材料になります。
Laravel APIの応答形式を固定する
Eloquentモデルをそのまま返すと、モデル側の変更が意図せずAPIの応答に表れることがあります。
LaravelのAPI Resourceを介して公開項目を定義すると、フロントエンドが依存する形式をコード上で確認できます。
API Resourceを定義する
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'created_at' => $this->created_at?->toISOString(),
];
}
}
一覧はコレクションとして返し、件数が増える前提ならページネーションも組み込みます。
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
public function index(): AnonymousResourceCollection
{
return PostResource::collection(
Post::query()->latest()->paginate(20)
);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:100'],
'body' => ['required', 'string'],
]);
$post = Post::create($data);
return (new PostResource($post))
->response()
->setStatusCode(201);
}
Post::create()を使う場合は、モデル側の一括代入設定も対象項目に合わせます。
入力検証はデータの形式を確認する処理であり、利用者がその操作を許可されているかを判断する認可とは別です。
更新や削除を追加するときは、Policyなどで対象データへの権限も確認します。
利用する操作だけをルートに公開する
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class)
->only(['index', 'store']);
});
コントローラーに未実装の操作まで公開しないため、最初は必要なメソッドだけに絞ります。
API Resourceの詳しい仕様はLaravel公式のAPI Resourcesで確認できます。
Sanctumの認証方式を先に選ぶ
Sanctumには、Laravelが提供するCookieベースのセッション認証と、Bearerトークンを使うAPIトークン認証があります。
この二つは用途が異なるため、一つのVue.js SPAで無条件に併用するものではありません。
| 利用者 | 選択しやすい方式 | 主な扱い |
|---|---|---|
| 自社のブラウザ向けSPA | Cookieベースのセッション認証 | CSRF保護、Cookie、statefulドメインを設定する |
| モバイルアプリや第三者クライアント | APIトークン認証 | Bearerトークンの発行、権限、失効を管理する |
Laravel公式は、自社のファーストパーティSPAにAPIトークンを使わず、SanctumのSPA認証を使う構成を案内しています。
ブラウザで扱う認証情報をlocalStorageへ保存する例は避けます。
同じオリジンで実行されるJavaScriptから読み取れるため、XSSが起きた場合に認証情報が漏れる範囲を広げるからです。
現在のLaravelでSanctumを準備する
php artisan install:api
php artisan migrate
コマンドや設定ファイルの位置はLaravelの世代で異なるため、既存プロジェクトでは採用バージョンの公式ドキュメントと差分を確認します。
ファーストパーティSPAからCookie認証を受ける構成では、bootstrap/app.phpでstateful APIミドルウェアを有効にします。
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
SPAとAPIは同じトップレベルドメインを共有する必要があり、別サブドメインで動かす場合はSanctumのstatefulドメイン、CORS、セッションCookieの設定を揃えます。
設定項目と手順はLaravel公式のSanctumを基準にしてください。
Vue 3とAxiosからCookie認証を使う
Axiosはアプリケーション専用のインスタンスを作り、APIのURLと認証に必要な設定を一か所にまとめます。
// src/lib/api.js
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_BACKEND_URL,
withCredentials: true,
withXSRFToken: true,
headers: {
Accept: 'application/json',
},
});
export async function login(credentials) {
await api.get('/sanctum/csrf-cookie');
await api.post('/login', credentials);
}
最初のリクエストでCSRF保護用Cookieを受け取り、その後にログインします。
withCredentialsを指定するだけでは構成は完成しません。
Laravel側のCORSとセッション設定、フロントエンドのURL、Cookieを送る範囲が一致していることも確認します。
ログイン後のAPI呼び出しではBearerトークンを手作業で付けず、同じAxiosインスタンスを使います。
const { data } = await api.get('/api/posts');
const posts = data.data;
動的更新を見える情報と読み上げの両方で伝える
SPAでは通信が完了してもページ全体が切り替わらないため、利用者が結果に気付けないことがあります。
成功件数やエラーを画面に表示し、必要な短い変化だけをライブリージョンで通知します。
<script setup>
import { onMounted, ref } from 'vue';
import { api } from '../lib/api';
const posts = ref([]);
const statusMessage = ref('');
const errorMessage = ref('');
async function loadPosts() {
statusMessage.value = '';
errorMessage.value = '';
try {
const { data } = await api.get('/api/posts');
posts.value = data.data;
statusMessage.value = `${posts.value.length}件の投稿を読み込みました`;
} catch {
errorMessage.value = '投稿を取得できませんでした。時間を置いて再試行してください。';
}
}
onMounted(loadPosts);
</script>
<template>
<section aria-labelledby='post-list-title'>
<h2 id='post-list-title'>投稿一覧</h2>
<p v-if='errorMessage' role='alert'>{{ errorMessage }}</p>
<ul v-else>
<li v-for='post in posts' :key='post.id'>
{{ post.title }}
</li>
</ul>
<p class='sr-only' role='status' aria-live='polite' aria-atomic='true'>
{{ statusMessage }}
</p>
</section>
</template>
sr-onlyは視覚的に隠しつつ支援技術から読めるCSSとして実装し、display: noneにはしません。
一覧全体を読み上げ直すのではなく、「何件読み込んだか」「保存できたか」のように操作結果だけを通知すると、読み上げが長くなりにくくなります。
モーダルはフォーカスの開始と終了まで設計する
role='dialog'とaria-modal='true'を付けるだけでは、キーボード操作は完成しません。
モーダルを開いたら内部へフォーカスを移し、TabキーとShift+Tabキーの移動を内部に保ち、Escapeキーと画面上の閉じるボタンで終了できるようにします。
閉じた後は、原則としてモーダルを開いた要素へフォーカスを戻します。
独自実装の量を減らせる要件なら、ブラウザがモーダル動作を扱うHTMLの<dialog>要素も候補です。
独自コンポーネントを作る場合は、少なくとも次をテストします。
- モーダルに見える見出しがあり、
aria-labelledbyで関連付けられている - 開いた直後のフォーカス位置が内容と操作に合っている
- TabキーとShift+Tabキーで背後の画面へ移動しない
- Escapeキーと閉じるボタンのどちらでも閉じられる
- 閉じた後のフォーカスが元の操作位置へ戻る
実装の考え方はW3CのHTML dialog要素を使うモーダル技法と、キーボード操作とフォーカス表示の実務で確認できます。
画面遷移後の位置を利用者に伝える
Vue Routerで画面を切り替えても、キーボードフォーカスが自動的に新しい内容の先頭へ移るとは限りません。
ルート変更時にはページタイトルを更新し、内容の先頭や主見出しなど、次に読むべき位置へフォーカスを移す設計を検討します。
見出しの順序を飛ばさず、各画面にmain領域と内容を特定できる見出しを置くことも、現在地を把握する助けになります。
Vueでの実装例はVue公式のアクセシビリティガイド、ルーティングの役割はVue公式のルーティングガイドを参照してください。
公開前の確認項目
| 確認領域 | 確認する内容 | 失敗時に起きること |
|---|---|---|
| 認証 | 自社SPAはCookie、外部クライアントはAPIトークンという選択が用途に合うか | 不要なトークン管理や認証情報の露出が増える |
| 認可 | 閲覧、作成、更新、削除をサーバー側で判定しているか | ログイン済みの利用者が権限外の操作を行える |
| 入力と応答 | 検証エラーとJSON形式が画面側の想定と一致するか | 画面が失敗理由を表示できない |
| CORSとCookie | 許可オリジン、資格情報、セッションドメインが一致するか | ログインできない、または不要な送信先を許可する |
| 通信 | 本番環境をHTTPSで提供し、秘密情報をクライアント用環境変数へ入れていないか | 通信や配布済みコードから情報が漏れる |
| 負荷対策 | ページネーション、キャッシュ、レート制限を用途に合わせたか | データ増加や集中アクセスで応答が悪化する |
| 画面状態 | 読み込み中、空、成功、入力エラー、通信エラーを区別して表示するか | 利用者が待つべきか再操作すべきか判断できない |
| アクセシビリティ | キーボードだけの操作、フォーカス表示、読み上げ通知を実機で確認したか | 操作できても結果や現在地を把握できない |
レート制限は短時間の集中を抑える仕組みであり、日次や月次の利用上限とは役割が違います。
設計を分ける理由はLaravelのレート制限とクォータの使い分けで詳しく解説しています。
テストで確認する境界
バックエンドのFeature Testでは、正常系だけでなく、未認証、権限不足、入力不備、存在しないデータ、レート制限時の応答を確認します。
フロントエンドでは、通信成功だけでなく、読み込み中、0件、検証エラー、セッション切れ、通信失敗を個別の状態として確認します。
そのうえで、キーボードだけで主要操作を完了できるか、フォーカスが見えるか、動的な結果が読み上げで伝わるかを手動でも試します。
実装判断の結論
Laravel APIとVue.jsを連携する作業は、HTTPリクエストを送れるところで終わりません。
API Resourceで応答を固定し、利用者に合うSanctum認証を選び、画面の成功と失敗を視覚と読み上げの両方で伝えるところまでが一つの実装です。
最初に責務と境界を決めておけば、機能追加時にも変更箇所とテスト範囲を追いやすくなります。
参照した公式資料
- Laravel Sanctum
- Laravel Eloquent API Resources
- Vue.js Routing
- Vue.js Accessibility
- W3C Technique H102
- OWASP Session Management Cheat Sheet
この記事に関連する株式会社greedenの取り組み
SPAは画面だけでなく、API、認証、権限、運用まで一つの設計として整える必要があります。株式会社greedenは、LaravelとVue.jsを含むWebシステム開発を、要件定義から実装、テスト、保守改善まで支援しています。
