環境・使用ライブラリ

PHP 8.2
Laravel 10
laravel breeze 1.2
React Router 6.10
React Query 4.28

Laravelのインストール

$ composer create-project --prefer-dist laravel/laravel laravel-react-spa
$ cd laravel-react-spa

Laravelの初期設定

configのapp.phpを開き、言語設定をします。

config/app.php
return [
// ...
'timezone' => 'Asia/Tokyo',
'locale' => 'ja',
'faker_locale' => 'ja_JP',

次はデータベースの設定です。
デフォルトではMySQLですが、今回はSQLiteを使用します。
.envを開いて、DB_CONNECTION=sqliteを追記して既存のMySQLの設定部分は削除します。

.env
DB_CONNECTION=sqlite
#DB_CONNECTION=mysql
#DB_HOST=127.0.0.1
#DB_PORT=3306
#DB_DATABASE=homestead
#DB_USERNAME=homestead
#DB_PASSWORD=secret

touchコマンドでsqliteファイルを作成。

$ touch database/database.sqlite

Laravel Breezeの設定

Laravel Breezeはログイン、ユーザー登録、パスワードリセットはスキャフォルドで作成します。
実はBreezeだけでフロントの画面も作ってくれるのですが、Laravel独自になりすぎてあまり汎用的ではないので今回はバックエンドの機能だけを利用します。

コンポーザーでインストールしましょう。

$ composer require laravel/breeze --dev

インストールコマンドを実行します。
apiオプションを指定してPHPファイルだけ生成するようにしましょう。

$ php artisan breeze:install api

これでいろいろファイルが書き出されます。
「routes/auth.php」開いてください。
ログインが「/login」、ログアウトが「/logout」に割り当ててありますが、このパスはフロントで使用するので、バックエンドでは「/api/login」「/api/logout」になるように編集します。

routes/auth.php
Route::prefix('api')->name('api.')->group(function () {
Route::post('login', [AuthenticatedSessionController::class, 'store'])
->middleware('guest')
->name('login');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->middleware('auth')
->name('logout');
});

ルートの確認をしてみます。

$ php artisan route:list

login,logoutとかが追加されているのがわかります。

GET|HEAD / ...............................................................................................................
POST _ignition/execute-solution ........ ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController
GET|HEAD _ignition/health-check .................... ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController
POST _ignition/update-config ................. ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController
GET|HEAD api/user ........................................................................................................
POST api/email/verification-notification .......... verification.send › Auth\EmailVerificationNotificationController@store
POST api/forgot-password ......................................... password.email › Auth\PasswordResetLinkController@store
POST api/login ......................................................... login › Auth\AuthenticatedSessionController@store
POST api/logout ..................................................... logout › Auth\AuthenticatedSessionController@destroy
POST api/register ......................................................... register › Auth\RegisteredUserController@store
POST api/reset-password ................................................ password.store › Auth\NewPasswordController@store
GET|HEAD api/verify-email/{id}/{hash} ....................................... verification.verify › Auth\VerifyEmailController
GET|HEAD sanctum/csrf-cookie ........................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show

PHPファイルを生成すると同時にpackage.jsonとかvite.config.jsファイルが削除されてしまいます。
これはフロントの開発に使うので戻しておきましょう。

.envのSANCTUM_STATEFUL_DOMAINSも設定します。
今回はビルドインサーバーの初期設定のまま使うので「localhost:8000」です。

.env
SANCTUM_STATEFUL_DOMAINS=localhost:8000

データベースの構築

シーダーファイルにユーザーデータを投入するfactoryがコメントアウトしてありますので外して有効にします。

database/seeders/DatabaseSeeder.php
class DatabaseSeeder extends Seeder
{
public function run(): void
{
\App\Models\User::factory(10)->create();
\App\Models\User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

migrateコマンドを実行してテーブルの作成&データを投入します。

$ php artisan migrate --seed

フロント開発環境の構築

Viteの設定をしてReactの開発環境を構築します。
package.jsonファイルがあることを確認してyarnコマンドを実行してnodeモジュールをインストールします。

$ yarn

必要なモジュールを追加します。

$ yarn add -D react react-dom @types/react @types/react-dom @vitejs/plugin-react @vitejs/plugin-react-refresh react-router-dom @tanstack/react-query

現在(2023/3/26)インストールするとバージョンはこのようになります。

package.json
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@tanstack/react-query": "^4.28.0",
"@types/react": "^18.0.29",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^3.1.0",
"@vitejs/plugin-react-refresh": "^1.3.6",
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.9.0",
"vite": "^4.0.0"
}
}

TypeScriptとReactが使えるようにViteの設定をします。

vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/ts/app.tsx'],
refresh: true,
}),
react()
],
resolve: {
alias: {
'@': '/resources/ts',
}
}
});

「resources/ts/index.tsx」を読み込む設定にしたので仮で作成しておきます。

resources/ts/index.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
const root = createRoot(
document.getElementById('app') as HTMLElement
)
root.render(
<React.StrictMode>
<h1>Hello React !</h1>
</React.StrictMode>
)

ベースとなるbladeファイルを作成します。

resources/views/app.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laravel React SPA</title>
</head>
<body>
<div id="app"></div>
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/ts/index.tsx'])
</body>
</html>

SPAの場合は設定したエンドポイント以外はすべてこのbladeファイルが表示されるようにします。

routes/web.php
Route::any('{all}', fn () => view('app'))
->where(['all' => '^(?!api/*).*']);

ここまできたら表示の確認をしてみましょう。
今回はビルドインサーバーで簡易的に確認します。

$ yarn run dev
$ php artisan serv

http://localhost:8000」にアクセスして「Hello React !」と表示されるか確認できたら設定は完了です。

設定方法は下記のページも参考にしてください。

Laravelに新しく導入されたフロントビルドツール「Vite」でTypeScript+React開発環境を構築する方法

React Routerの設定

次はReact Routerでページのベースを作っていきます。

最初にページコンポーネントを作ります。
作成するのはログインページとログインしないと見れないページのダッシュボードの2ページです。

resources/ts/pages/Login.tsx
import React from 'react'
const LoginPage: React.FC = () => {
return (
<div>
<h1>ログイン</h1>
</div>
)
}
export default LoginPage
resources/ts/pages/Dashboard.tsx
import React from 'react'
const DashboardPage: React.FC = () => {
return (
<div>
<h1>ダッシュボード</h1>
</div>
)
}
export default DashboardPage

このページを表示する為のルーターファイルを作成

resources/ts/routes.tsx
import { createBrowserRouter } from 'react-router-dom'
import DashboardPage from '@/pages/Dashboard'
import LoginPage from '@/pages/Login'
export const router = createBrowserRouter([
{
path: 'login',
element: <LoginPage />
}, {
path: '/',
element: <DashboardPage />
}
])

作成したルーターをApp.tsxで設定します。

resources/ts/App.tsx
import React from 'react'
import { RouterProvider } from 'react-router-dom'
import { router } from './routes'
const App: React.FC = () => {
return (
<RouterProvider router={router} />
)
}
export default App

index.tsxで作成したApp.tsxを表示するようにします。

resources/ts/index.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from '@/App'
const root = createRoot(
document.getElementById('app') as HTMLElement
)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

ここまでできたらブラウザで「/」と「/login」にアクセスして作成したページが表示されることを確認してください。

React Query設定

React Queryを使えるようにします。
新しくQueryClientを初期化するファイルを作成します。
基本的な設定はここで行います。

resources/ts/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false
},
mutations: {
retry: false
}
}
})

App.tsxで作成したqueryClientをインポートしてQueryClientProviderの設定をします。

resources/ts/App.tsx
import React from 'react'
import { RouterProvider } from 'react-router-dom'
import { router } from './routes'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/queryClient'
const App: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)
}
export default App

これでReact Queryを使用する準備ができました。

ログイン中の判定

初期のLaravelの状態だと「api/user」にアクセスするとログイン中のユーザー情報が取得できるので、この情報が取れるかでどうかでログイン中かを判定します。

最初の取得する情報のtypeファイルを作成します。

resources/ts/types/User.ts
export type User = {
id: number
name: string
email: string
}

AxiosでAPIと通信する処理です。

resources/ts/api/authAPI.ts
import axios from 'axios'
import type { User } from '@/types/User'
export const getUser = async () => {
const { data } = await axios.get<User>('/api/user')
return data
}

AxiosのReactQueryに設定します。

resources/ts/hooks/useAuth.ts
import * as api from '@/api/authAPI'
import { queryClient } from '@/queryClient'
const authUserQuery = () => ({
queryKey: ['user'],
queryFn: api.getUser
})
export const useAuthUser = async () => {
const query = authUserQuery()
return queryClient.getQueryData(query.queryKey)
?? (await queryClient.fetchQuery(query).catch(() => undefined))
}

ReactRouterのLoader機能を使用してページを表示する前にAPIからデータを取得できるかできないかでページのリダイレクトを行います。

resources/ts/hooks/useAuth.ts
import { createBrowserRouter, redirect } from 'react-router-dom'
import { useAuthUser } from '@/hooks/useAuth'
import DashboardPage from '@/pages/Dashboard'
import LoginPage from '@/pages/Login'
/**
* ログイン済みのみアクセス可能
*/
const guardLoader = async () => {
const user = await useAuthUser()
return user ? true : redirect('/login')
}
/**
* ログインしていない場合のみアクセス可能
*/
const guestLoader = async () => {
const user = await useAuthUser()
return user ? redirect('/') : true
}
export const router = createBrowserRouter([
{
path: 'login',
element: <LoginPage />,
loader: guestLoader
}, {
path: '/',
element: <DashboardPage />,
loader: guardLoader
}
])

「/」にアクセスすると「/login」にリダイレクトされることを確認してください。

ログインとログアウト

APIに接続するところから作ります。

resources/ts/api/authAPI.ts
export const login = async ({ email, password }: {
email: string,
password: string
}) => {
const { data } = await axios.post<User>(
'/api/login', { email, password }
)
return data
}
export const logout = async () => {
const { data } = await axios.post<User>('/api/logout')
return data
}

hooksにも追加します。
ログインできなかったときの処理も必要になりますが、今回は省略します。

resources/ts/hooks/useAuth.ts
export const useLogin = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: api.login,
onError: (error: AxiosError) => {
console.log(error)
},
onSuccess: (data) => {
console.log(data)
queryClient.invalidateQueries(['auth'])
window.location.href = '/'
}
})
}
export const useLogout = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: api.logout,
onError: (error: AxiosError) => {
console.log(error)
},
onSuccess: (data) => {
console.log(data)
queryClient.invalidateQueries(['auth'])
window.location.href = '/login'
}
})
}

ログイン画面を修正して、作成したuseLoginを実行できるようにします。

resources/ts/pages/Login.tsx
import React, { useState } from 'react'
import { useLogin } from '@/hooks/useAuth'
const LoginPage: React.FC = () => {
const login = useLogin()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
login.mutate({
email: email,
password: password,
})
}
return (
<div>
<h1>ログイン</h1>
<form onSubmit={onSubmit}>
<fieldset>
<div>
<label htmlFor="email">メールアドレス:</label>
<input
type="email"
id="email"
onChange={e => setEmail(e.target.value)}
defaultValue={email}
/>
</div>
<div>
<label htmlFor="password">パスワード:</label>
<input
type="password"
id="password"
onChange={e => setPassword(e.target.value)}
defaultValue={password}
/>
</div>
<button type="submit">送信</button>
</fieldset>
</form>
</div>
)
}
export default LoginPage

ダッシュボードではログアウトボタンを追加して、useLogoutを実行できるようにします。

resources/ts/pages/Dashboard.tsx
import React from 'react'
import { useLogout } from '@/hooks/useAuth'
const DashboardPage: React.FC = () => {
const logout = useLogout()
const onLogout = () => {
logout.mutate()
}
return (
<div>
<h1>ダッシュボード</h1>
<button type="button" onClick={onLogout}>ログアウト</button>
</div>
)
}
export default DashboardPage

これで完成です。
ログイン画面からログインできることを確認してください。
初期データのメールアドレスは「test@example.com」、パスワードは「password」です。

ログインしている状態ではダッシュボードが見れて、ログイン画面が見れない。
ログインしていない状態の場合、逆にログイン画面が見れて、ダッシュボードが見れないという状態になっていると思います。