🦖

Laravel で実装した API と Next.js の連携に挑戦する

2024/07/02に公開

普段 PHP + CodeIgniter + jQuery + Bootstrap の構成で業務システムを開発している者が
Laravel + Next.js を触ってみた記録である。

動作確認環境

  • Windows 10 Enterprise 22H2

技術構成

  • PHP v8.2.12
  • Laravel v11.11.0
  • Next.js 14.2.4

ゴール設定

Laravel で実装した API を Next.js から叩く。

Laravel

インストール

> composer create-project laravel/laravel laravel-app

プロジェクト作成時に SQLite の DB ファイルが作成され、マイグレーションも実行される。

> cd .\laravel-app\
laravel-app> php artisan serve

ビルトインサーバーを起動して http://127.0.0.1:8000/ の表示確認をする。

タイムゾーンと言語設定

laravel-app\.env
- APP_TIMEZONE=UTC
+ APP_TIMEZONE=Asia/Tokyo
laravel-app\.env
- APP_LOCALE=en
- APP_FALLBACK_LOCALE=en
- APP_FAKER_LOCALE=en_US
+ APP_LOCALE=ja
+ APP_FALLBACK_LOCALE=ja
+ APP_FAKER_LOCALE=ja_JP

Laravel Breeze

いきなり横道にそれるが、Laravel Breeze を試してみる。

https://laravel.com/docs/11.x/starter-kits

Composer で Laravel Breeze パッケージを追加する。
※インストーラーなので開発環境だけ

laravel-app> composer require laravel/breeze --dev

インストールする。

laravel-app> php artisan breeze:install

  Which Breeze stack would you like to install?
  Blade with Alpine ............................................... blade  
  Livewire (Volt Class API) with Alpine ........................ livewire  
  Livewire (Volt Functional API) with Alpine ........ livewire-functional  
  React with Inertia .............................................. react  
  Vue with Inertia .................................................. vue  
  API only .......................................................... api  
❯ blade

  Would you like dark mode support? (yes/no) [no]
❯ no

  Which testing framework do you prefer? [PHPUnit]
  Pest ................................................................ 0  
  PHPUnit ............................................................. 11

使用するスタック、モダンフロントエンド何もワカランなので安牌の blade を選択した。
インストール前後でルーティングは以下のように変化する。

before
laravel-app> php artisan route:list

  GET|HEAD  / ..........................................................................................................  
  GET|HEAD  up .........................................................................................................  

                                                                                                      Showing [2] routes
after
laravel-app> php artisan route:list

  GET|HEAD  / ..........................................................................................................  
  GET|HEAD  confirm-password ................................ password.confirm › Auth\ConfirmablePasswordController@show  
  POST      confirm-password .................................................. Auth\ConfirmablePasswordController@store  
  GET|HEAD  dashboard ........................................................................................ dashboard  
  POST      email/verification-notification ..... verification.send › Auth\EmailVerificationNotificationController@store  
  GET|HEAD  forgot-password ................................. password.request › Auth\PasswordResetLinkController@create  
  POST      forgot-password .................................... password.email › Auth\PasswordResetLinkController@store  
  GET|HEAD  login ................................................... login › Auth\AuthenticatedSessionController@create  
  POST      login ............................................................ Auth\AuthenticatedSessionController@store  
  POST      logout ................................................ logout › Auth\AuthenticatedSessionController@destroy  
  PUT       password .................................................. password.update › Auth\PasswordController@update  
  GET|HEAD  profile .............................................................. profile.edit › ProfileController@edit  
  PATCH     profile .......................................................... profile.update › ProfileController@update  
  DELETE    profile ........................................................ profile.destroy › ProfileController@destroy  
  GET|HEAD  register ................................................... register › Auth\RegisteredUserController@create  
  POST      register ............................................................... Auth\RegisteredUserController@store  
  POST      reset-password ........................................... password.store › Auth\NewPasswordController@store  
  GET|HEAD  reset-password/{token} .................................. password.reset › Auth\NewPasswordController@create  
  GET|HEAD  up .........................................................................................................  
  GET|HEAD  verify-email .................................. verification.notice › Auth\EmailVerificationPromptController  
  GET|HEAD  verify-email/{id}/{hash} .................................. verification.verify › Auth\VerifyEmailController  

                                                                                                     Showing [21] routes

この後、公式等で DB マイグレーションを実行する手順となっていたが必要なさそ?

ビルトインサーバーを起動して http://127.0.0.1:8000/ の表示確認をする。

右上に Log in と Register のリンクが追加されている。

以下の機能が利用できるようになった。なんだこれは、たまげたなあ。

  • ユーザー登録
  • ログイン
  • パスワードリマインダ
  • ダッシュボード
  • プロフィール(ユーザー変更、ユーザー削除)
  • ログアウト

日本語化

https://github.com/askdkc/breezejp

Composer で Laravel Breeze 日本語化パッケージを追加する。
※インストーラーなので開発環境だけ

laravel-app> composer require askdkc/breezejp --dev

インストールする。

laravel-app> php artisan breezejp

これで Laravel Breeze で追加された画面と Laravel のバリデーションメッセージが日本語化された。

API

Next.js から叩くためのユーザー API を実装してみる。

laravel-app> php artisan install:api
...
 One new database migration has been published. Would you like to run all pending database migrations? (yes/no) [yes]:    
 > yes

新たに作成された routes/api.php に以下のルーティングが追加された。

  GET|HEAD  api/user ...................................................................................................
  GET|HEAD  sanctum/csrf-cookie ...................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show

sanctum(聖域)は何だかよくわからんが凄そうだ。

API 用の Controller を作成する。

laravel-app> php artisan make:controller UserController --resource
laravel-app\routes\api.php
  <?php
  
  use Illuminate\Http\Request;
  use Illuminate\Support\Facades\Route;
  
  Route::get('/user', function (Request $request) {
      return $request->user();
  })->middleware('auth:sanctum');
  
+ Route::apiResource('users', \App\Http\Controllers\UserController::class);
  GET|HEAD        api/users ......................................................... users.index › UserController@index  
  POST            api/users ......................................................... users.store › UserController@store  
  GET|HEAD        api/users/{user} .................................................... users.show › UserController@show  
  PUT|PATCH       api/users/{user} ................................................ users.update › UserController@update  
  DELETE          api/users/{user} .............................................. users.destroy › UserController@destroy
laravel-app\app\Http\Controllers\UserController.php
  <?php
  
  namespace App\Http\Controllers;
  
+ use App\Models\User;
+ use Illuminate\Http\JsonResponse;
  use Illuminate\Http\Request;
  
  class UserController extends Controller
  {
      /**
       * Display a listing of the resource.
       */
-     public function index()
+     public function index(): JsonResponse
      {
-         //
+         $users = User::all();
+         return response()->json(['users' => $users]);
      }
- 
-     /**
-      * Show the form for creating a new resource.
-      */
-     public function create()
-     {
-         //
-     }
  
      /**
       * Store a newly created resource in storage.
       */
-     public function store(Request $request)
+     public function store(Request $request): JsonResponse
      {
-         //
+         $user = User::create([
+             'name' => $request->get('name'),
+             'email' => $request->get('email'),
+             'password' => $request->get('password'),
+         ]);
+         return response()->json(['user' => $user]);
      }
  
      /**
       * Display the specified resource.
       */
-     public function show(string $id)
+     public function show(string $id): JsonResponse
      {
-         //
+         $user = User::find($id);
+         return response()->json(['user' => $user]);
      }
- 
-     /**
-      * Show the form for editing the specified resource.
-      */
-     public function edit(string $id)
-     {
-         //
-     }
  
      /**
       * Update the specified resource in storage.
       */
-     public function update(Request $request, string $id)
+     public function update(Request $request, string $id): JsonResponse
      {
-         //
+         $user = User::findOrFail($id);
+         $user->update([
+             'name' => $request->get('name'),
+             'email' => $request->get('email'),
+             'password' => $request->get('password'),
+         ]);
+         return response()->json(['user' => $user]);
      }
  
      /**
       * Remove the specified resource from storage.
       */
-     public function destroy(string $id)
+     public function destroy(string $id): JsonResponse
      {
-         //
+         $user = User::findOrFail($id);
+         $user->delete();
+         return response()->json(null, 204);
      }
  }

ひと通り実装できたので試運転をしてみる。

index

リクエスト

GET http://127.0.0.1:8000/api/users
Accept: application/json

レスポンス
HTTP/1.1 200 OK
データがない場合

{
  "users": []
}

データがある場合

{
  "users": [
    {
      "id": 1,
      "name": "Test1",
      "email": "test1@example.com",
      "email_verified_at": null,
      "created_at": "2024-06-20T02:36:20.000000Z",
      "updated_at": "2024-06-20T02:36:20.000000Z"
    },
    {
      "id": 2,
      "name": "Test2",
      "email": "test2@example.com",
      "email_verified_at": null,
      "created_at": "2024-06-20T05:33:51.000000Z",
      "updated_at": "2024-06-20T05:33:51.000000Z"
    }
  ]
}

store

リクエスト

POST http://127.0.0.1:8000/api/users
Content-Type: application/json

{
  "name": "Test3",
  "email": "test3@example.com",
  "password": "password"
}

レスポンス
HTTP/1.1 200 OK

{
  "user": {
    "name": "Test3",
    "email": "test3@example.com",
    "updated_at": "2024-06-20T05:37:06.000000Z",
    "created_at": "2024-06-20T05:37:06.000000Z",
    "id": 3
  }
}

show

リクエスト

GET http://127.0.0.1:8000/api/users/3
Accept: application/json

レスポンス
HTTP/1.1 200 OK

{
  "user": {
    "id": 3,
    "name": "Test3",
    "email": "test3@example.com",
    "email_verified_at": null,
    "created_at": "2024-06-20T05:37:06.000000Z",
    "updated_at": "2024-06-20T05:37:06.000000Z"
  }
}

update

リクエスト

PUT http://127.0.0.1:8000/api/users/3
Content-Type: application/json

{
  "name": "Test4",
  "email": "test4@example.com",
  "password": "password"
}

レスポンス
HTTP/1.1 200 OK

{
  "user": {
    "id": 3,
    "name": "Test4",
    "email": "test4@example.com",
    "email_verified_at": null,
    "created_at": "2024-06-20T05:37:06.000000Z",
    "updated_at": "2024-06-20T05:41:52.000000Z"
  }
}

destroy

リクエスト

DELETE http://127.0.0.1:8000/api/users/2
Accept: application/json

レスポンス
HTTP/1.1 204 No Content

<Response body is empty>

Response code: 204 (No Content); Time: 248ms (248 ms); Content length: 0 bytes (0 B)

バリデーション

新規登録 API にバリデーションを追加する為、リクエストクラスを作成する。

laravel-app> php artisan make:request UserStoreRequest
laravel-app\app\Http\Requests\UserStoreRequest.php
  <?php

  namespace App\Http\Requests;

+ use Illuminate\Contracts\Validation\Validator;
  use Illuminate\Foundation\Http\FormRequest;
+ use Illuminate\Http\Exceptions\HttpResponseException;

  class UserStoreRequest extends FormRequest
  {
      /**
       * Determine if the user is authorized to make this request.
       */
      public function authorize(): bool
      {
-         return false;
+         return true;
      }

      /**
       * Get the validation rules that apply to the request.
       *
       * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
       */
      public function rules(): array
      {
          return [
-             //
+             'name' => [
+                 'required',
+                 'string',
+                 'max:255',
+             ],
+             'email' => [
+                 'required',
+                 'string',
+                 'email',
+                 'unique:users,email',
+             ],
+             'password' => [
+                 'required',
+                 'string',
+                 'min:8',
+             ],
          ];
      }
+
+     /**
+      * @param Validator $validator
+      */
+     protected function failedValidation(Validator $validator)
+     {
+         $data = [
+             'errors' => $validator->errors()->toArray(),
+         ];
+         throw new HttpResponseException(response()->json($data, 422));
+     }
  }

今回は API リクエストに対する認証は不要にする為、authorize メソッドを変更する。
バリデーションルールを rules メソッドに定義する。
バリデーション失敗時に HTML ではなく JSON を返却したいので failedValidation メソッドを生やす。

laravel-app\app\Http\Controllers\UserController.php
  <?php
  
  namespace App\Http\Controllers;

+ use App\Http\Requests\UserStoreRequest;
  use App\Models\User;
  use Illuminate\Http\JsonResponse;
  use Illuminate\Http\Request;
  
  class UserController extends Controller
  {
      //
-     public function store(Request $request): JsonResponse
+     public function store(UserStoreRequest $request): JsonResponse
      {
          //
      }
      //
  }

作成したリクエストクラスを使用するようにコントローラーを変更する。

続けて更新 API にバリデーションを追加していく。
各リクエストクラスに毎回 failedValidation メソッドを生やすのが面倒なので FormRequest を継承した API 用のベースクラスを作成する。

laravel-app> php artisan make:request ApiRequest
laravel-app\app\Http\Requests\ApiRequest.php
  <?php

  namespace App\Http\Requests;

+ use Illuminate\Contracts\Validation\Validator;
  use Illuminate\Foundation\Http\FormRequest;
+ use Illuminate\Http\Exceptions\HttpResponseException;

  class ApiRequest extends FormRequest
  {
-     /**
-      * Determine if the user is authorized to make this request.
-      */
-     public function authorize(): bool
-     {
-         return false;
-     }
-
-     /**
-      * Get the validation rules that apply to the request.
-      *
-      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
-      */
-     public function rules(): array
-     {
-         return [
-             //
-         ];
-     }
-
+     /**
+      * @param Validator $validator
+      */
+     protected function failedValidation(Validator $validator)
+     {
+         $data = [
+             'errors' => $validator->errors()->toArray(),
+         ];
+         throw new HttpResponseException(response()->json($data, 422));
+     }
  }

更新 API にバリデーションを追加する為、リクエストクラスを作成する。

laravel-app> php artisan make:request UserUpdateRequest
laravel-app\app\Http\Requests\UserUpdateRequest.php
  <?php

  namespace App\Http\Requests;

- use Illuminate\Foundation\Http\FormRequest;
+ use Illuminate\Validation\Rule;

- class UserUpdateRequest extends FormRequest
+ class UserUpdateRequest extends ApiRequest
  {
      /**
       * Determine if the user is authorized to make this request.
       */
      public function authorize(): bool
      {
-         return false;
+         return true;
      }

      /**
       * Get the validation rules that apply to the request.
       *
       * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
       */
      public function rules(): array
      {
          return [
-             //
+             'name' => [
+                 'string',
+                 'max:255',
+             ],
+             'email' => [
+                 'string',
+                 'email',
+                 Rule::unique('users')->ignore($this->user),
+             ],
+             'password' => [
+                 'string',
+                 'min:8',
+             ],
          ];
      }
  }

ignore メソッドへの指定、コントローラーでは $id で受け取っているのでそれを指定すればいいけど、リクエストでどのように書けばいいのか謎だった。

https://eza-s.com/blog/archives/73/ を参考にさせていただいた。

laravel-app\app\Http\Controllers\UserController.php
  <?php
  
  namespace App\Http\Controllers;

  use App\Http\Requests\UserStoreRequest;
+ use App\Http\Requests\UserUpdateRequest;
  use App\Models\User;
  use Illuminate\Http\JsonResponse;
- use Illuminate\Http\Request;
  
  class UserController extends Controller
  {
      //
-     public function update(Request $request, string $id): JsonResponse
+     public function update(UserUpdateRequest $request, string $id): JsonResponse
      {
          //
      }
      //
  }

新規登録 API 用のリクエストクラスも API 用のベースクラスを使用するように変更する。

laravel-app\app\Http\Requests\UserStoreRequest.php
  <?php

  namespace App\Http\Requests;

- use Illuminate\Contracts\Validation\Validator;
- use Illuminate\Foundation\Http\FormRequest;
- use Illuminate\Http\Exceptions\HttpResponseException;
-
- class UserStoreRequest extends FormRequest
+ class UserStoreRequest extends ApiRequest
  {
      //
-
-     /**
-      * @param Validator $validator
-      */
-     protected function failedValidation(Validator $validator)
-     {
-         $data = [
-             'errors' => $validator->errors()->toArray(),
-         ];
-         throw new HttpResponseException(response()->json($data, 422));
-     }
  }

バリデーションの試運転をしてみる。

store

リクエスト

POST http://127.0.0.1:8000/api/users
Content-Type: application/json

{
  "name": "Test4",
  "email": "test4@example.com",
  "password": "pass"
}

レスポンス
HTTP/1.1 422 Unprocessable Content

{
  "errors": {
    "email": [
      "メールアドレスの値は既に存在しています。"
    ],
    "password": [
      "パスワードは、8文字以上で指定してください。"
    ]
  }
}

update

リクエスト

PUT http://127.0.0.1:8000/api/users/3
Content-Type: application/json

{
  "name": "",
  "email": "test4@example.com",
  "password": "pass"
}

レスポンス
HTTP/1.1 422 Unprocessable Content

{
  "errors": {
    "name": [
      "名前は文字列を指定してください。"
    ],
    "password": [
      "パスワードは、8文字以上で指定してください。"
    ]
  }
}

Next.js

TypeScript - React - Next.js を利用する為には Node.js が必要。
Node.js の環境構築をする際によく出てくるマネージャーたち を片手に親子関係にテンパらないように進める。

ここでのターミナル操作は Windows PowerShell で進める。

Windows Package Manager(winget)

まずはパッケージマネージャー、こいつが無ければ始まらない。

> winget
v1.7.11261 の Windows パッケージ マネージャー
Copyright (c) Microsoft Corporation. All rights reserved.

最新版がインストール済みだったのでそのまま進む。

nvm for Windows

Node.js の バージョンを管理するツールは色々ある。
せっかくだから俺はこの nvm for Windows を選ぶぜ。
winget を使ってインストールする。

> winget install CoreyButler.NVMforWindows

インストールが完了しても慌てない。
まずは Windows PowerShell を静かに閉じ、心を落ち着かせて再度 Windows PowerShell を起動する。

> nvm version
1.1.12

無事インストールができたようだ。

Node.js

ようやく Node.js をインストールできる。
nvm を使用して、インストールが可能なバージョンを確認する。

> nvm list available

|   CURRENT    |     LTS      |  OLD STABLE  | OLD UNSTABLE |
|--------------|--------------|--------------|--------------|
|    22.3.0    |   20.14.0    |   0.12.18    |   0.11.16    |
|    22.2.0    |   20.13.1    |   0.12.17    |   0.11.15    |
|    22.1.0    |   20.13.0    |   0.12.16    |   0.11.14    |

LTS の最新版である 20.14.0 を nvm でキメる。

> nvm install 20.14.0
Downloading node.js version 20.14.0 (64-bit)...
Extracting node and npm...
Complete
npm v10.7.0 installed successfully.


Installation complete. If you want to use this version, type

nvm use 20.14.0
> nvm use 20.14.0
Now using node v20.14.0 (64-bit)
> node -v
v20.14.0

npm

Node.js をインストールしたら Node.js の パッケージを管理するツールもついてきた。
助かる。

> npm -v
10.7.0

Next.js

npm に同梱されている npx コマンドを使用して Next.js をインストールする。

> npx create-next-app
Need to install the following packages:
create-next-app@14.2.4
Ok to proceed? (y) y
? What is your project named? » next-app
? Would you like to use TypeScript? » No / Yes

Yes

? Would you like to use ESLint? » No / Yes

Yes

? Would you like to use Tailwind CSS? » No / Yes

Yes

? Would you like to use `src/` directory? » No / Yes

No

? Would you like to use App Router? (recommended) » No / Yes

Yes

? Would you like to customize the default import alias (@/*)? » No / Yes

No

Creating a new Next.js app in C:\dev\next-app.

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next

npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm warn deprecated @humanwhocodes/config-array@0.11.14: Use @eslint/config-array instead
npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead

added 361 packages, and audited 362 packages in 3m

137 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Success! Created next-app at C:\dev\next-app

TypeScript - React - Next.js とその他諸々をインストールできた。

> cd .\next-app\
next-app> npm run dev

> next-app@0.1.0 dev
> next dev

  ▲ Next.js 14.2.4
  - Local:        http://localhost:3000

 ✓ Starting...
 ✓ Ready in 1694ms

サーバーを起動して http://localhost:3000 の表示確認をする。

axios

API となんか良い感じに通信するには
JavaScript で使用できる Promise ベースの HTTP クライアントライブラリがなんか良いらしい。
インストールする。

next-app> npm install axios

added 9 packages, and audited 371 packages in 4s

138 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Laravel API 連携

https://zenn.dev/mkt_engr/articles/axios-req-res-typescript を参考にさせていただくことで
とりあえず API は叩けた。

新規登録

http://localhost:3000/api/users/create

next-app/app/api/users/create/page.tsx
'use client';
import React, {useState, useEffect} from 'react'
import axios, {AxiosRequestConfig, AxiosResponse, AxiosError} from 'axios';

type USER = {
    id: number, name: string, email: string
};

const url = 'http://localhost:8000/api';
const data = {
    'name': 'Test5',
    'email': 'test5@example.com',
    'password': 'password',
};
const options: AxiosRequestConfig = {
    url: `${url}/users`,
    method: 'POST',
    data: data,
};

const AxiosPost: React.FC = () => {
    const [user, setUser] = useState<USER>({} as USER);
    // API 通信を行う箇所
    useEffect(() => {
        axios(options)
            .then((response: AxiosResponse<USER>) => {
                setUser(response.data.user);
            })
            .catch((e: AxiosError<{ error: string }>) => {
                // エラー処理
                console.log(e.message);
            });
    }, []);
    // 登録したユーザー情報を表示する箇所
    return (
        <div>
            <ul>
                <li key={user.id}>
                    {user.id} : {user.name} : {user.email}
                </li>
            </ul>
        </div>
    );
};
export default AxiosPost;

一覧

http://localhost:3000/api/users

next-app/app/api/users/page.tsx
'use client';
import React, {useState, useEffect} from 'react'
import axios, {AxiosRequestConfig, AxiosResponse, AxiosError} from 'axios';

type USER = {
    id: number, name: string, email: string
};

const url = 'http://localhost:8000/api';
const options: AxiosRequestConfig = {
    url: `${url}/users`,
    method: 'GET',
};

const AxiosGet: React.FC = () => {
    const [users, setUsers] = useState<USER[]>([]);
    // API 通信を行う箇所
    useEffect(() => {
        axios(options)
            .then((response: AxiosResponse<USER[]>) => {
                setUsers(response.data.users);
            })
            .catch((e: AxiosError<{ error: string }>) => {
                // エラー処理
                console.log(e.message);
            });
    }, []);
    // ユーザー情報を表示する箇所
    return (
        <div>
            <ul>
                {users.map(({id, name, email}) => {
                    return (
                        <li key={id}>
                            {id} : {name} : {email}
                        </li>
                    );
                })}
            </ul>
        </div>
    );
};
export default AxiosGet;

おわり

自身の JavaScript ヂカラが赤ちゃんすぎることを再認識。大人しく jQuery ワールドに帰ります。

Discussion