tohokuaikiのチラシの裏

技術的ネタとか。

2026年のLaravel+Inertia.js+React(with starter kit)の開発環境をdockerで用意してみる(その1)

2024年にWEB業界に復帰してから、dockerを学びWSLを学びTypeScriptとReactに取り組み、Laravelアプリはbitnamiのdockerで開発してたけどなんかbitnamiイマイチじゃね?有料になった感じもあるし、そもそもファイルがbitnamiユーザー(WSLからはrootユーザー)になるしって思って改めてやり直してみる。

概要

色々と調べたらこんな感じみたい。 - docker はLaravel-sailで用意してくれる。 - 認証周りとデフォルトテンプレートを作ってくれるはBreezeが終わってstarter kitsが用意されている。 - docker containerの中身を直接VS Codeが見てくれる dev containerがあると barryvdh/laravel-ide-helper よりいいらしい。そりゃそうだ。

Laravel+Dockerの設定を実行する

環境

WSL2+Ubuntu24で実行している。

Laravelインストーラーのインストール

当初、よくあるsailの導入記事の curl -s "https://laravel.build/my-app?with=mariadb" | bash って感じでLaravelを導入しようとしたら、Starter Kitsが入らない。 どうもStarter Kitsは、Laravel インストーラーのインストール途中で入れるものになったらしい。BreezeだとあとでComposerで入れられたんだけどな…

ということで、Laravelインストーラーのインストールからはじめる。

composer global require laravel/installer

これで、WSLのbashでlaravelコマンドが使えるようになった。

Laravelのインストール

laravel new my-app

これでmy-appディレクトリが作られてその中にLaravelがプレーンな状態で展開されるのだけど、途中でStarter Kitの選択を行う。

$ laravel new sample-app

 ██╗       █████╗  ██████╗   █████╗  ██╗   ██╗ ███████╗ ██╗
 ██║      ██╔══██╗ ██╔══██╗ ██╔══██╗ ██║   ██║ ██╔════╝ ██║
 ██║      ███████║ ██████╔╝ ███████║ ██║   ██║ █████╗   ██║
 ██║      ██╔══██║ ██╔══██╗ ██╔══██║ ╚██╗ ██╔╝ ██╔══╝   ██║
 ███████╗ ██║  ██║ ██║  ██║ ██║  ██║  ╚████╔╝  ███████╗ ███████╗
 ╚══════╝ ╚═╝  ╚═╝ ╚═╝  ╚═╝ ╚═╝  ╚═╝   ╚═══╝   ╚══════╝ ╚══════╝

 ┌ Do you want to use a starter kit? ───────────────────────────┐
 │ Yes                                                          │
 └──────────────────────────────────────────────────────────────┘

 ┌ Which frontend stack should your starter kit use? ───────────┐
 │ React                                                        │
 └──────────────────────────────────────────────────────────────┘

 ┌ Which authentication provider do you prefer? ────────────────┐
 │ Laravel's built-in authentication                            │
 └──────────────────────────────────────────────────────────────┘

 ┌ Would you like to add teams support to your application? ────┐
 │ Yes                                                          │
 └──────────────────────────────────────────────────────────────┘

認証方法はとりあえず全部チェックしておいて必要なモノだけ後でONにするようにする。

 ┌ Which authentication features would you like to enable? ─────┐
 │ › ◼  Email verification                                     │
 │   ◼ Registration                                            │
 │   ◼ Two-factor authentication                               │
 │   ◼ Passkeys                                                │
 │   ◼ Password confirmation                                   │
 └──────────────────────────────────────────────────────────────┘
  Use space to select, enter to confirm.

Running database migrations...で×が出るけど、まぁそれは今の所は仕方ない。あとで artisan migrateを実行する。

sailのインストールとセットアップ

docker周りのセットアップや通信をしてくれるsailがあるのでcomposerコマンドでインストール

cd sample-app
composer require laravel/sail --dev
./artisan sail:install

この時必要なサービスを選択する。私はmariadbだけを選択したが、redisとかも追加できそれに対応したdockerがセットアップされる。

 ┌ Which services would you like to install? ───────────────────┐
 │ mariadb                                                      │
 └──────────────────────────────────────────────────────────────┘

これで vendor/bin/sail コマンドが使えるようになる。これはdocker exec みたいなもんだけど、コンテナ名とか省略できて非常に便利。

~/.bashrc に以下を入れておくとsail だけでLaravelプロジェクトのルートから使えるようになる。

alias sail='sh $([ -f sail ] && echo sail || echo vendor/bin/sail)'

相対パスなのがポイント。

で、

vendor/bin/sail up -d 
vendor/bin/sail down

でdockerの上げ下げになる。

とりあえず、データベースのmigrationを実行しておく。Starter KitのDBテーブルが作成される。

sail artisan migrate

コンテナ名を固定にしたい

コンテナ名はディレクトリ名がprefixで付く。src をソースディレクトリにした場合には src-laravel.test-1とかのコンテナ名になってしまいわかりにくい。そこで、.envに以下のパラメータを入れるとprefixを任意にできる。

COMPOSE_PROJECT_NAME=my-app

VS CodeのDev Containersを接続

機能拡張の Dev ContaintersがあるとVS Codeの環境がdocker内の環境として認識するようになる。

Containerに接続

WSLのソースルートに移動して、code . でVS Code起動。Shift+Ctrl+Pでコマンドパレットを開いて Dev Containers: Reopen in Containerを選択する。初回は色々と選択肢を選ぶウィザードが始まる。

> From : compose.yaml
   From: a predefiend template

でsailのcompose.yamlを選択。次の Select Additional Featuresはどれも選択せずに大丈夫。次の include the following optinal files/directories も何も選択しない。

で、こんな設定ファイルが生成されるので次回以降はこの設定内容で接続される。

.devcontainer/
├── devcontainer.json
└── docker-compose.yml

devcontainer.json

色々とコメントがあるけど、必要なのはこれだけ。

{
    "name": "docker my-app",
    "dockerComposeFile": [
        "../compose.yaml",
        "docker-compose.yml"
    ],
    "service": "laravel.test",
    "workspaceFolder": "/var/www/html"
}

serviceは最初のウィザードで選択したdockerのサービス名。compose.yaml にあるservicesのキーである。 docker composeコマンドでサービスレベルで指定しているからなのだろうか? 重要なのがworkspaceFolderでこれはdocker Containerから見たソースのパス。sailならデフォルトは/var/www/htmlなのでここを書き換えておく。

docker-compose.yml

これはdevcontainer.jsonのdockerComposeFileキーで指定されている。docker composeファイルとして上書き設定する必要があったらこのymlで設定する。基本的には上書き設定することは無いので

#    volumes:
      # Update this to wherever you want VS Code to mount the folder of your project
     - ..:/workspaces:cached

の所を

#    volumes:
      # Update this to wherever you want VS Code to mount the folder of your project
#      - ..:/workspaces:cached

とコメントアウトしておく。

2026年になってから記事を一本も書いてなかった件

2026年も半分を過ぎているのにエントリを1本も書いて無かった。

教員に転職した2021年~2023年でも書いていたのに。

理由はおそらくAI使っていることと関係ある。

このブログは「自分がプログラミングしててわからなかったことを調べて後で読み返すように」という動機で書いている割合が高い。

しかし、最近はChatGPTやClaudeとやり取りしてるとだいたいができてしまう。

AIと壁打ちしてて「あー、なるほど、そうだったのか!!」って思わせることはこの半年で何回もあった。それこそ、以前だったらブログエントリに上げなければ!!という動機になるものが沢山あったはずだ。たとえば、Reactのp-queueやuseCallbackの使いどころ、PHP5.6からPHP8へのマイグレーションなどいくつもあった。

しかし、それらはチャットの履歴を検索すればまた出てくるし、なんならまたAIに聞けばいい。このブログを読み直すことも無い。

斯くしてこの半年間エントリが上がることは無かったのである。割とめんどいしね、習得したことをまとめて書くの。

しかし、習得したことをまとめて書き上げることは知識の定着につながると思うし、より深い理解のためには大切なことだと思う。

でも、定着なんていらんねん、うろ覚えでええねん、だってうろ覚えでAIに聞けば正解を教えてくれるから。

それでいんだろうか…そんなもんなんだろうかっていう不安はあるよね。だって、それはAI無しではやっていけない人間になるってことだし。でも、既に半分IDE無しではやっていけない身にはなってるし、似たようなもんなのかな、そこで反逆する意味は何なんだろうなって思う。

SchemaSpyを使ってDockerのデータベース情報をHTMLで出力

前提

  • WSLのcliでdockerを導入
  • dockerにMariaDBなんかでDBを作っている。
  • MariaDBコンテナはややこしいアクセス制限とかしてない。

Docker

compose.yaml はこんな感じでMariaDBコンテナを起動している。このデータベーステーブルのSchemaを出力したい。

name: schemaspy_sample
services: 
  mariadb:
    container_name: schemaspy_sample_mariadb
    build:
      dockerfile: ./mariadb/Dockerfile
      context: .
      tags: 
       - "docker-mariadb:latest"
    image: "docker-mariadb:latest"
    environment:
      MARIADB_ROOT_PASSWORD: passwd
      MARIADB_USER: docker
      MARIADB_PASSWORD: passwd
      MARIADB_DATABASE: schemaspy_sample
    volumes: 
      - type: volume
        source: mariadb
        target: /var/lib/mysql
      - type: bind
        source: ./mariadb
        target:  /docker-entrypoint-initdb.d

./mariadb/Dockerfile はこんな感じ

FROM --platform=linux/amd64 mariadb:latest

SchemaSpyコンテナを作って実行

その前に出力ディレクトリを作っておく。

mkdir ./output
chmod 777 output

で、こんな感じでdocker run

docker run --rm \
  -v "$(pwd)/output":/output \
  --network schemaspy_sample_default \
  schemaspy/schemaspy:7.0.2 \
  -t mysql \
  -host schemaspy_sample_mariadb \
  -db schemaspy_sample \
  -u docker -p passwd -s schemaspy_sample

networkは docker network ls で調べる。-db はデータベース、と -sスキーマMySQL/MariaDBの場合は同じものになる。

出力フォルダは schemaspyコンテナの/output に出力されるので、-v "$(pwd)":/output でボリュームマウントしておく。Dockerからこのディレクトリに書き込みできるように777指定を忘れずに。

/output に出されるのは

$ docker inspect schemaspy/schemaspy:7.0.2|grep output
                "SCHEMASPY_OUTPUT=/output"

というようにschemaspyイメージで指定されているから。変えたければ -o /foo とかで変えられるらしい。

Laravel+Inertia.js(+Breeze)でセキュリティ的な意識からRouteをそれぞれに分けた話

buildしてから気づいたんだけど、思い違いがあれば是非指摘して欲しい。以下の記事では「問題」って書いてるけど、別にこれは問題じゃなくて適正な仕様だと思っているので別にInertia.jsが悪いわけではない。それを意識して使えない方が悪い。…が、初心者とかが何も考えずに「うわー、便利!」って使うとそりゃそうなるよね…っていう気はする。

Laravel+Inertia.jsのセキュリティ上の問題点

RoutingやPageのJavaScriptが露出してしまうこと。Build時にHashが付けられるので、PageのJavaScriptは当てずっぽうではURLを突き止めにくいけど、

Routing見えちゃってるよ問題

Breezeでお手軽に作れるLoginページでChrome DevToolsのコンソールから

Object.entries(Ziggy.routes).forEach((r)=> console.log(r[1].uri))

ってやると、

sanctum/csrf-cookie
/
logout
dashboard
register
login
forgot-password
reset-password/{token}
reset-password
verify-email
verify-email/{id}/{hash}
email/verification-notification
confirm-password
password
storage/{path}

と出る。ということは、あなたのLaravel+Inertia.jsアプリケーションはトップページでこのコンソールコマンドを打たれたら全てのURIが出てしまうということになる。

対応策

ZiggyにRouting情報を渡しているのは、Viewのルートになるapp.blade.phpになる。この@routes ディレクティブには引数を渡せる。例えば、

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title inertia>{{ config('app.name', 'Laravel') }}</title>

        <!-- Scripts -->
        @routes('admin')

みたいに。

で、この引数は、config/ziggy.php に紐づけられる。ここで、グループ設定をしてやることで表出するRoutingを制限できる。

<?php
return [
    'groups' => 
        'admin' => ['admin.*'],
];

色々なやり方ができるので、ZiggyのREADMEを読んで対応する。

resources/js/Pages 配下がアクセスできちゃってるよ問題

StaticなJavaScriptなんてアクセスできてもDBの内容はAPIで保護しとるし問題ないやろ!とか思うかもしれないが、使い方とか微妙にセンシティブな記述があるとあまり気持ちのよろしいものではない。そういう態度が(略

これはどこで出てしまうかというと、build後のHTMLに出てしまっている。ソースコードを見ると

            document.head.append(fragment)
        })

        loadNext(JSON.parse('[{\u0022rel\u0022:\u0022prefetch\u0022,\u0022href\u0022:\u0022https:\\\/\\\/laravel.inertiajs.example.com\\\/build\\\/assets\\\/DeleteModal-sGdPz8-i.js\u0022,\u0022fetchpriority\u0022:\u0022low\u0022},{\u0022rel\u0022:\u0022prefetch\u0022,\u0022href\u0022:\u0022https:\\\/\\\/laravel.inertiajs.example.com\\\/build\\\/assets\\\/InputError-D8SSzIW6.js\u0022,\u0022fetchpriority\u0022:\u0022low\u0022},{\u0022rel\u0022:\u0022prefetch\u0022,\u0022href\u0022:\u0022https:\\\/\\\/laravel.inertiajs.example.com\\\/build\\\/assets\\\/CAlert-CTPOVJDO.js\u0022,\u0022fe

というJavaScriptのPrefetch部分である。これはnpm run devではhmrであるのでbuild後じゃないと見えない。

ではこれはどこで制御するのかというと、viteの入り口である resources/ts/admin.tsx になる。先ほどのbladeファイルのすぐ下の部分で指定されている。

        @routes('admin')
        @viteReactRefresh
        @vite(['resources/ts/admin.tsx', "resources/ts/Pages/{$page['component']}.tsx"])
        @inertiaHead

対応策

いくつかの面から対応する必要がある。

PageのJavaScriptを収めるファイルを分割しておく

そもそも、アクセスされても仕方がないというスコープを設計段階で決めておき、それらをPagesの下階層のフォルダに分割しておく。 こんな感じ。

resources/ts/Pages/
├── Admin(管理者ページ)
├── App(ログイン後のユーザー用ページ)
├── Auth(Breezeの作ったページ)
└── Guest(ゲストユーザー用のページ)

分割したファイルに合ったようにそれぞれのPathを設定する

これが結構な影響がある。以下のような流れで考える。
route => bladeファイル =>Reactの起点&デフォルトページのtsx => LaravelのInerrtia::render()が返すPageのPath

この全てで対応が必要。

Inertia.jsのrootViewを変更してrootになるbladeファイルを変更する。

app/Http/Middleware/HandleInertiaRequests.php がrootViewを決定するのでこんな感じ。

<?php
    public function rootView(Request $request)
    {
        if ($rootView = $this->getRootViewName($request)) {
            return $rootView;
        }
        return parent::rootView($request);
    }

    private function getRootViewName(Request $request): string | null
    {
        $routeName = $request->route()->getName();
        if (strpos($routeName, 'admin.') === 0) {
            return 'admin';
        }
        if ($routeName === 'home' || strpos($routeName, 'guest.') === 0) {
            return 'guest';
        }
        if (
            in_array($routeName, ['login', 'logout', 'register',]) ||
            strpos($routeName, 'password.') === 0 ||
            strpos($routeName, 'verification.') === 0
        ) {
            return 'auth';
        }

        return null;
    }
}

ここで、routeによって4つのbladeに振り分けることができる。admin.blade.php app.blade.php auth.blade.php guest.blade.php の4つを作っておく。

bladeファイルの記述とLaravel Controllerのメソッドの返り値

先ほどまでで変更してきたものの、 @vite ディレクティブの引数の配列第二要素はアクセスしたURLに対応するデフォルトのPageになる。つまり、あるRouteに対してLaravel Controllerで以下のように返していた場合、

<?php
namespace App\Http\Controllers;
use Inertia\Inertia;

class AdminController extends Controller
{
    // 
    public function topPage(string $page = ''): \Inertia\Response
    {
        return  Inertia::render('Admin/Index');
    }
}

bladeの {$page['component']} には、Admin/Index が入る。Controllerの全てのメソッドの返り値に逐一 Admin/を付けてもいいのだが、それは面倒なので

        <!-- Scripts -->
        @routes('admin')
        @viteReactRefresh
        @vite(['resources/ts/admin.tsx', "resources/ts/Pages/Admin/{$page['component']}.tsx"])
        @inertiaHead

としておく。ただし、ControllerではPathが1階層ずれることを忘れないように。

Reactの起点である'resources/ts/admin.tsx' の変更

ここまで来てようやくPrefetchのための記述を変更することができる。 resolvePageComponentの引数2つに /Admin を入れることができる。

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) =>
        resolvePageComponent(
            `./Pages/Admin/${name}.tsx`,
            import.meta.glob('./Pages/Admin/**/*.tsx'),
        ),

ここまでやってなんだけど

こんなのみんな普通に意識できてるんかな…

LaravelのControllerでValidationを行う時

メモ

Controller

validateメソッドの - 第一引数がRule - 第二引数がエラーメッセージ - 第三引数がAttribute名

<?php
use App\Rules\UserPassword;

class PasswordController extends Controller
{
    /**
     * Update the user's password.
     */
    public function update(Request $request): RedirectResponse
    {
        $validated = $request->validate(
            [
                'current_password' => ['required', 'current_password'],
                'password' => ['confirmed', new UserPassword()],
            ],
            [
                'password.confirmed' => ':attribute の入力が確認用と一致しません。',
            ],
            [
                'current_password' => '現在のパスワード',
                'password' => '新しいパスワード',
            ]
        );

        $request->user()->update([
            'password' => $validated['password'],
        ]);

        return back();
    }
}

独自Rule Validator

ここでもAttributeのPlaceholderは使える。

<?php

class UserPassword implements ValidationRule
{

    private $min_length = 8;

    /**
     * Run the validation rule.
     *
     * @param  \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString  $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $rules = [
            'required',
            'string',
            Password::min($this->min_length)
                ->mixedCase()
                ->numbers()
                ->symbols(),
        ];
        $messages = [
            ':attribute は必須です。',
            ':attribute は文字列で入力してください。',
            ':attribute は' . $this->min_length . '文字以上で、大文字・小文字・数字・記号を含める必要があります。',
        ];
        foreach ($rules as $key => $rule) {
            $validator = Validator::make(
                [$attribute => $value],
                [$attribute => [$rule]],
            );
            if ($validator->fails()) {
                $fail($messages[$key] ?? self::class . " validation error");
            }
        }
    }
}

$failでエラーメッセージを配列に積んでいける。ただし、Inertia.jsではエラーメッセージ配列の最初の1つ目だけを送るのでちょっと工夫が必要。

ということでInteria.jsでの工夫

ちょっと工夫はRequestクラスに行う。これを継承していけばOK

<?php

class InertiaRequest 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 [
            //
        ];
    }


    /**
     * Inertia.jsはValidationエラーを最初の1つしか投げないので、改行でJOINしたものにする。
     * 
     */
    protected function failedValidation(Validator $validator)
    {
        $errors = collect($validator->errors()->toArray())
            ->map(fn($messages) => implode("\n", $messages))
            ->toArray();

        // Inertiaにキャッチさせず、ここでResponseExceptionを投げて同一ページ更新
        throw new HttpResponseException(
            back()->withErrors($errors)->withInput()
        );
    }
}

何をしているかというと、改行コードでJOINしているだけ。受け取ったInertia.jsではそれをsplitして複数に見せれれば大丈夫。

Laravel12+Inertia.js+Reactの足場をBreezeを利用して整える(2025年11月)

なんか、まぁ、いっつも似たことやってるけど、ホント1年前と同じ事やってもダメなんよね。

ということで、メモ。

Docker

いつもbitnami/laravelのイメージ使ってたんだけど、無くなってた(笑)。なんか、有料プランになるらしくって、とりあえず FROM bitnamilegacy/laravel:12.3.1 は置いておいてくれるんだけど、これもいつまで使えるかなーって。

で、npmも最新のにしておきたいのでDockerfileはこんな感じ。 *1

FROM bitnamilegacy/laravel:12.3.1
USER root
RUN npm install -g npm@latest

これもいつまで使えるかわからんから、時間ある時にLaravel用のPHP環境そろえたDocker作らないとダメですね。

dockerコンテナに入ってインストール

Breezeは便利なんだけど、アップデートのタイミングが遅すぎるのでどうもReactとかちょっと古いんですよね。

先にReactなんかをインストール

先にこのあたりの最新版をインストールしておく。後でもいいのかもしれない。

npm i -D react@latest react-dom@latest @types/react-dom @types/react @types/node@^24.6.0 axios@latest @types/axios vite @vitejs/plugin-react @tailwindcss/vite @inertiajs/react @vitejs/plugin-react typescript sass

Reactは結局viteでコンパイルするから-Dオプション付きでいいんじゃないかな。知らんけど。

Inertia.jsとBreezeをインストール

こんな感じ。

composer require inertiajs/inertia-laravel
php artisan inertia:middleware
composer require laravel/breeze --dev
php artisan breeze:install

で、breeze:install の時に、さっきのpackageを古いので上書きするよってWarningが出ます。

なので、さっきの npm i -D react@latest ... なんかを再度実行する。 これでもpackage.jsonには古いのいっぱいあるんじゃないかな、いや絶対あるやろ…って気になるが、npm audit でエラーが出なければまぁいいか。

*1:実際にはmariadbとmailpitも使ってるからcompose.yamlだけど。

2025年10月にNext.jsをcreate-next-appして静的HTMLをbuild

Next.jsは1年前に取り組んだのが最後で、それからずっとReactやってたのでもう一度やり直し。1年も経てばNext.jsのことだからゼロから始めた方が良いと思います。

参考にしたのは公式サイト

https://nextjs.org/docs/app/getting-started/installation

create-next-app

npm ではなく、 pnpm が推奨らしい。pnpm = performant npm(高性能な npm)ということ。

npm install -g pnpm

で、

pnpm create next-app@latest foo

質問はこんな感じ。

? Which linter would you like to use? ? ESLint
? Would you like to use Tailwind CSS? … No (Tailwind使わないので)
? Would you like your code inside a `src/` directory? … Yes
? Would you like to use App Router? (recommended) … Yes
? Would you like to use Turbopack? (recommended) … Yes
? Would you like to customize the import alias (`@/*` by default)? … No

こんな Warning が出た。

Ignored build scripts: sharp, unrs-resolver.                                            
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts. 

pnpm approve-builds ってやったらこの2つをインストールした。

pnpm dev コマンドで http://localhost:3000/ にアクセスするとDevサイトが表示されている。

buildを試してみる。

とりあえず、コードには触らずにbuildしてStaticファイルが出るかを試す。

pnpm i

してnode_modules以下に依存性ファイル設置(pnpmの場合はシンボリックリンク

react-hooks が足りない?

pnpm build

すると

 Linting and checking validity of types  .. × ESLint: Failed to load plugin 'react-hooks' declared in ' ≫ eslint-config-next/core-web-vitals ≫ /home/t-ito/projects/foo/src/node_modules/.pnpm/eslint-config-next@15.5.5_eslint@9.37.0_typescript@5.9.3/node_modules/eslint-config-next/index.js': Cannot find module 'eslint-plugin-react-hooks' Require stack: - /home/t-ito/projects/foo/src/__placeholder__.js Referenced from: /home/t-ito/projects/foo/src/node_modules/.pnpm/eslint-config-next@15.5.5_eslint@9.37.0_typescript@5.9.3/node_modules/eslint-config-next/index.js

というエラーが出た。エラーの通りに pnpm i react-hooks してから再度buildしたら通った。

静的ファイルが無い

普通に静的ファイルが無い。 /.next/server/ にあるのはSSR用のファイルだと分かる。

で、pnpm next export したら

 ×
    `next export` has been removed in favor of 'output: export' in next.config.js.
Learn more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports

と言われてしまった。

で、当該のURLを読んで、

const nextConfig: NextConfig = {
  /* config options here */
  output: "export",
  distDir: 'dist',
};

としてから、pnpm build したら、dist というフォルダができているのでこれが静的HTML
distDir: 'dist', を指定しない場合は out になる。

LaravelのSanctumでBearer Tokenを取得して使用したり期限を設定したり

使っているLaravelは11なので、デフォルトでSanctumがインストールできるのですがそのあたりは公式サイトにて割愛。

laravel.com

やりたいこと

ログイン時にBearer Tokenで使えるTokenを発行して、そのTokenの管理をしたり有効期限を設定したりする。

Tokenの発行

ログイン処理をするところにひっかけておく。要はログインした $user が取れればよい。Auth::login()で行う場合は

<?php
Auth::login($user, true);

とかしてる付近の $user を使う。

Inertiajsを使ったセッションコントローラなら

<?php
    public function store(LoginRequest $request): HttpResponse
    {
        /// 省略
        LoginSuccess::dispatch($request->user());

あたりの $request->user() とか。

ModelにTraitでTokenを発行するメソッドを追加

<?php
    use HasApiTokens;

Tokenの発行はcreateToken()メソッド

<?php
$token = $user->createToken('jwt-token')->plainTextToken

引数は何でもいいんだけど、とりあえずjwt-tokenにしておいた。

Tokenの情報は、personal_access_tokens テーブルに保存される。

こんな感じ。

> select * from personal_access_tokens \G;
*************************** 1. row ***************************
            id: 5
tokenable_type: App\Models\User
  tokenable_id: 93
          name: jwt-token
         token: 7cd13a56f215a93d0f0050c04f32590d79cce58cee668dce9037202a9126f40d
     abilities: ["*"]
  last_used_at: NULL
    expires_at: NULL
    created_at: 2025-08-18 13:53:50
    updated_at: 2025-08-18 13:53:50
1 row in set (0.003 sec)

このDBテーブルに入っているTokenはハッシュ化されたものであり、生トークンではないのでこれだけあっても認証には使えない。認証には先ほどのcreateTokenメソッドで取得した $token 変数が必要。

で、このTokenをどうやってログインしたユーザーに知らせるか

生TokenはDBに保存とかしたくない。メールで通知するとかしてもいいかもしれない。とりあえず面倒だったので自分はログイン後のリダイレクトリンクにHTTPヘッダとして仕込んだ。

<?php
return redirect(config('app.admin_path'), 302, [
       'X-JWT-TOKEN' => $user->createToken('jwt-token')->plainTextToken
]);

で、ユーザーにはChromeのDevToolsとかでヘッダ探してなという不親切設計。

有効期限とartisanコマンドの sanctum:prune-expired

artisanコマンドには、sanctum:prune-expired というのがある。これを使うのがよさそう。

データベースの expires_at という幻惑フィールドと、config/sanctum.php について

この expires_at は、ユーザーが自由に使うフィールドで結局のところLaravelは何の面倒も見ない

sanctum:prune-expired コマンドの実行時には以下のようになる。

  1. config('sanctum.expiration') がnullなら何もしない(つまり、Tokenは無期限有効)
  2. nullでないなら、personal_access_tokens テーブルのcreated_at + config('sanctum.expiration') 分 が現在より小さいものを削除する

PHPのSlim Frameworkでセッションを使う

別に普通にsession_start()とかしてもよかったんだろうけど、なんとなくMiddlewareとか使ってみた。

使ってみたのはいいけど、どうもSlim FrameworkのMiddlewareは基本的に手出しができないものらしくって、そのMiddleware内部のObjectに手を出そうとしたらContainerを扱わなきゃなくなってしまって、とても面倒だった。

とりあえず、SESSIONをスタートさせたい。

Middlewareに登録するセッションライブラリのインストール

ChatGPTに聞いたら、これが良いみたいな話だったので GitHub - odan/session: A middleware oriented session handler for PHP and Slim 4+ を使う。

composer install odan/session

Middlewareに登録する。

Slim Frameworkのエントリポイントのadd()で登録する。

<?php
use Odan\Session\Middleware\SessionStartMiddleware;
use Odan\Session\PhpSession;

$app = AppFactory::create();
$app->add(new SessionStartMiddleware(new PhpSession([
    'path' => '/',
    'name' => 'your-session-name'
])));

とりあえずこんな感じでページを開くと自動的にセッションが始まる。

セッションの読み書き

普通にグローバル変数 $_SESSION を使う。

セッションの読み書きにグローバル変数$_SESSIONはいかがなものか?

何となくカッコ悪いというか、今更グローバル変数$_SESSIONもどうよって感じでもにょっとするのでカッコよくコンテナ経由で操作したい。要は、 new PhpSessionのObjectにアクセスしたいんですわ。

Middleware登録時にコンテナに結び付ける。

DIのライブラリ php-di/slim-bridge をインストール

composer require php-di/slim-bridge

以下のポイントは2点

  • createする前にContainerをセットする
  • Containerに session という名称でObjectをセットしておく
<?php
use Odan\Session\Middleware\SessionStartMiddleware;
use Odan\Session\PhpSession;
use DI\Container;

$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();
$app->add(new SessionStartMiddleware($session = new PhpSession([
    'path' => '/',
    'name' => $_ENV['APP_NAME']
])));
$container->set('session', $session);

$sessionを使う

$appさえ使えるならどこでも引っ張り出せます。

<?php
$session = $app->getContainer()->get("session");
$oldValue = $session->get('foo');
$session->set('foo', $newValue);

みたいな。

Slim Frameworkは情報が少ないのか、ChatGPTやGeminiに聞いても嘘ばっかで全く役に立たなかったよ。逆に言うと、AIがここまで使えなかったホンの数年前は面倒くさかったんだよなぁ。もう忘れてしまったけど。

今更ながらORIGINの違う(≒クロスドメイン)のAPIをブラウザからのJavaScriptで叩くときにCORSとかいろいろとありがとうございました。

JavaScriptドメインを越えるにはCORS設定をしなければならない。ドメインというか、正確にはORIGINだけど。

ORIGINってこの3つから成り立ってる。

項目
スキーム http / https
ホスト名 example.com / localhost
ポート 80, 443, 3000, etc.

以下、ブラウザが表示しているWEBページを http://localhost:8080 (送信元ORIGIN)として、そのWEBページからXHRで叩きたいAPIhttp://localhost:8000API ORIGIN)とする。

API側は、CORSヘッダを適切にブラウザに返さなければならない。

送ってきたリクエストに対してCORS(Cross-Origin Resource Sharing)が成立するためには以下のヘッダを適切に送らなければならない。適切にというのは、イケナイ送信元ORIGINだったらこれらを送らないということです。サンプルはPHPのSlim Frameworkだが、まあだいたいわかるだろう。

<?php
// $originは(送信元ORIGIN)であること。
$response =  $response->withHeader('Access-Control-Allow-Origin', $origin)
                        ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
                        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
return $response;

CORSエラーの場合にJavaScriptではどうするか。

axiosを使っている場合だが、CORS エラーが発生した場合、サーバーからのレスポンスがJavaScriptまで届かないようにブラウザがブロックしてしまう。なのでこんな感じでCORSエラーを処理する。

try {
    await axios.post(url, data);
} catch (e) {
    if (axios.isAxiosError(e)) {
        if (e.response === undefined && e.request !== undefined){
             alert('ネットワークエラー');
        }
        setErrors(e.response?.data.errors);
    }
}

COOKIEを使いたい場合

更にCOOOKIEについてもCORSが成立するために、はヘッダーが必要である。これはクライアントとサーバーの両方で必要になる。

クライアント側(JavaScript

// fetch
fetch('https://api.example.com/endpoint', {
  method: 'POST',
  credentials: 'include', // ← これが必須
});

// axios
axios.post(url.confirm, data, {
   withCredentials: true
});

サーバ側(PHPのSlim Framework)

<?php
$response =   $response->withHeader('Access-Control-Allow-Origin', $origin)
                        ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
                        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
                        ->withHeader('Access-Control-Allow-Credentials', 'true'); // trueではなく文字列の"true"

すげぇ面倒です。

サーバ側(追加)

POSTの前にはPreflightがOPTIONメソッドで飛ぶので、全てのPOSTルーティングに対してOPTIONルートも同様のCORS対応ヘッダを送るようにしないといけないよ。