💨

Laravel 12:--using= で作るカスタムスターターキット完全解説

に公開

https://zenn.dev/catatsumuri/articles/bb17287cdd9d0d

以前の記事において、スターターキットでは--using=を使えばカスタムテンプレートが使えるという記事を書いた。本稿では実際に--using=を利用したカスタムテンプレートを作成し、自身のプロジェクトに適用するためのガイドラインを示す。

用意したテンプレート

ここではhttps://github.com/laravel/react-starter-kit をベース(upstream)としたコピーをgithubに用意する事にする。これはgithubである必要は無いのであるが、githubであればforkという機能で簡単にupstreamを用意する事ができるので、本稿ではその機能を利用する事とする。なお、ここではlaravel/react-starter-kitを利用しているが、vueの場合はlaravel/vue-starter-kitと読みかえればうまく行く可能性がある(なお、本稿では検証対象外とする)

本稿で利用するカスタムテンプレート

弊GitHubにて用意した https://github.com/catatsumuri/react-starter-kit-ja を利用する。

Github URLが示すように、laravel/react-starter-kitを日本語にして提供する事を目標とする。以下に作成手順を残すが、必要が無い場合は導入まで読み飛ばしてもらっても構わない(実際にカスタムはある程度Laravelプロジェクトの体裁を残す限り自由に行う事ができる)

日本語対応への改造

以下改造内容である。内容に興味があれば参照されたい

ここからは、スターターキットを日本語環境に対応させるための変更を行う。
対応はバックエンドとフロントエンドの両方に分けて実施する。

ライブラリーの導入

laravel-lang/langの導入

まず、バックエンド(Laravel)側のメッセージを日本語化する。
laravel-lang/lang パッケージを導入し、日本語ロケールを追加する。

composer require laravel-lang/lang --dev
php artisan lang:add ja

これにより、バリデーションメッセージや認証メッセージなど、Laravel標準の文言が日本語化される。

i18next + react-i18next

次に、フロントエンド(Inertia/React)側の翻訳を扱えるようにする。
i18nextreact-i18next を導入することで、バックエンドと同一のキー体系で国際化(i18n)を統一できる。

以下のように package.json に依存関係を追加する:

package.json
@@ -49,11 +49,13 @@
         "clsx": "^2.1.1",
         "concurrently": "^9.0.1",
         "globals": "^15.14.0",
+        "i18next": "^25.6.0",
         "input-otp": "^1.4.2",
         "laravel-vite-plugin": "^2.0",
         "lucide-react": "^0.475.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
+        "react-i18next": "^16.2.4",
         "tailwind-merge": "^3.0.1",
         "tailwindcss": "^4.0.0",
         "tw-animate-css": "^1.4.0",

この段階で、バックエンドとフロントエンドの両方に翻訳基盤が整う。
以降では、実際に翻訳ファイルを共有し、Inertia経由で連携させる方法を解説する。

バックエンドから翻訳キーをフロントエンドに渡す

HandleInertiaRequests.php は、Inertia アプリ全体で共通して利用するデータをバックエンドからフロントエンドへ渡すためのミドルウェアである。
ここでは、現在のロケール情報と翻訳辞書を Inertia ページ(React 側)にシームレスに共有できるように改修する。

Laravel の Lang ファサードを使えば、バックエンド側で定義した翻訳配列をそのまま取得できる。
これを Inertia の共有データ (share()) に追加することで、React 側でも同一キー体系で翻訳を参照できるようになる。

以下のように、ロケール情報と翻訳辞書を追加する。

app/Http/Middleware/HandleInertiaRequests.php
@@ -4,6 +4,7 @@

 use Illuminate\Foundation\Inspiring;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Lang;
 use Inertia\Middleware;

 class HandleInertiaRequests extends Middleware
@@ -38,6 +39,9 @@ public function share(Request $request): array
     {
         [$message, $author] = str(Inspiring::quotes()->random())->explode('-');

+        $locale = app()->getLocale();
+        $fallbackLocale = config('app.fallback_locale');
+
         return [
             ...parent::share($request),
             'name' => config('app.name'),
@@ -46,6 +50,24 @@ public function share(Request $request): array
                 'user' => $request->user(),
             ],
             'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
+            'locale' => $locale,
+            'fallbackLocale' => $fallbackLocale,
+            'translations' => $this->frontendTranslations($locale),
+            'fallbackTranslations' => $fallbackLocale !== $locale
+                ? $this->frontendTranslations($fallbackLocale)
+                : [],
         ];
     }
+
+    /**
+     * Get frontend translations for the given locale.
+     *
+     * @return array<string, mixed>
+     */
+    protected function frontendTranslations(string $locale): array
+    {
+        $translations = Lang::get('frontend', [], $locale);
+
+        return is_array($translations) ? $translations : [];
+    }
 }

これで、Laravel 側で定義した lang/{locale}/frontend.php の配列を、Inertia を介して React 側に直接渡せるようになる。
バックエンドでの翻訳キーとフロントエンドでのキーを統一することで、両者の管理コストを減らし、翻訳の整合性を保ちやすくなる。

続いて、TypeScript 側でこの共有データを型定義に追加する。

resources/js/types/index.d.ts
@@ -27,6 +27,10 @@ export interface SharedData {
     quote: { message: string; author: string };
     auth: Auth;
     sidebarOpen: boolean;
+    locale: string;
+    fallbackLocale: string;
+    translations: Record<string, any>;
+    fallbackTranslations: Record<string, any>;
     [key: string]: unknown;
 }

これにより、React 側では usePage().props.translations から翻訳辞書を参照できるようになる。
たとえば、t('frontend.common.dashboard') のように i18next を通して同一キーで呼び出せば、バックエンド・フロントエンド間で一貫した翻訳管理が実現できる。

さらに:フロントエンドの i18n 初期化

ここまでで、Laravel 側から現在のロケールと翻訳辞書を Inertia 経由で渡せるようになった。次は React 側でその翻訳データを受け取り、i18next を通して利用可能にする。


i18n 初期化ロジックの定義

まずは resources/js/lib/i18n.ts に i18n の初期化処理を実装する。i18next 本体と react-i18next プラグインを組み合わせ、Laravel 側で受け取った翻訳辞書を登録する仕組みだ。

resources/js/lib/i18n.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';

export type TranslationDictionary = Record<string, unknown>;
const NAMESPACE = 'frontend';

const sanitize = (value?: TranslationDictionary): TranslationDictionary => {
    if (!value || typeof value !== 'object') return {};
    return value;
};

export const configureI18n = (
    locale: string | undefined,
    fallbackLocale: string | undefined,
    translations?: TranslationDictionary,
    fallbackTranslations?: TranslationDictionary,
) => {
    const primary = sanitize(translations);
    const fallback = sanitize(fallbackTranslations);

    const lng = locale && locale.trim() ? locale : 'en';
    const flng = fallbackLocale && fallbackLocale.trim() ? fallbackLocale : 'en';

    if (!i18next.isInitialized) {
        i18next.use(initReactI18next).init({
            resources: {
                [flng]: { [NAMESPACE]: fallback },
                [lng]: { [NAMESPACE]: primary },
            },
            lng,
            fallbackLng: flng,
            interpolation: { escapeValue: false },
            defaultNS: NAMESPACE,
        });
        return;
    }

    if (!i18next.hasResourceBundle(flng, NAMESPACE)) {
        i18next.addResourceBundle(flng, NAMESPACE, fallback, true, true);
    }
    if (!i18next.hasResourceBundle(lng, NAMESPACE)) {
        i18next.addResourceBundle(lng, NAMESPACE, primary, true, true);
    }

    if (i18next.language !== lng) {
        void i18next.changeLanguage(lng);
    }
};

export { useTranslation } from 'react-i18next';

アプリ初期化時に i18n を構成

次に、Inertia アプリ起動時 (resources/js/app.tsx) に、サーバー側から渡された翻訳データを使って i18n を初期化する。

resources/js/app.tsx
@@ -5,6 +5,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
 import { StrictMode } from 'react';
 import { createRoot } from 'react-dom/client';
 import { initializeTheme } from './hooks/use-appearance';
+import { configureI18n } from './lib/i18n';
+import type { SharedData } from './types';

 const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -16,6 +18,16 @@ createInertiaApp({
             import.meta.glob('./pages/**/*.tsx'),
         ),
     setup({ el, App, props }) {
+        const { locale, fallbackLocale, translations, fallbackTranslations } =
+            props.initialPage.props as SharedData;
+
+        configureI18n(
+            locale,
+            fallbackLocale,
+            translations,
+            fallbackTranslations,
+        );
+
         const root = createRoot(el);

         root.render(

これで、アプリの初期化時に i18next が自動的に設定され、すべてのページで翻訳データが利用可能になる。


各ページで翻訳を利用

翻訳を使いたいコンポーネントでは、useTranslation() フックを呼び出して t() 関数を取得する。以下はログインページの例。

resources/js/pages/auth/login.tsx
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
 import { Label } from '@/components/ui/label';
 import { Spinner } from '@/components/ui/spinner';
 import AuthLayout from '@/layouts/auth-layout';
+import { useTranslation } from '@/lib/i18n';
 import { register } from '@/routes';
 import { store } from '@/routes/login';
 import { request } from '@/routes/password';
@@ -22,12 +23,14 @@ export default function Login({
     canResetPassword,
     canRegister,
 }: LoginProps) {
+    const { t } = useTranslation();
+
     return (
         <AuthLayout
-            title="Log in to your account"
-            description="Enter your email and password below to log in"
+            title={t('auth.login.title')}
+            description={t('auth.login.description')}
         >
-            <Head title="Log in" />
+            <Head title={t('auth.login.title')} />

これで、Laravel 側の lang/{locale}/frontend.php で定義した翻訳キーを React 側でも同一キーで参照できるようになった。バックエンドとフロントエンドで翻訳管理を統一し、ローカライズの重複を防ぐことができる。

laravelコマンドで --using=を使った場合の処理

laravelコマンドの詳細は以下の記事で解説している。

https://zenn.dev/catatsumuri/articles/bb17287cdd9d0d

ただし、--using= は特殊すぎるため詳細は省いており、本稿でその動作を補足する。以下は laravel/installer のコードから動作を整理したものである。

--react, --vue, --livewireに負ける

return match (true) {
    $input->getOption('react') => 'laravel/react-starter-kit',      // 優先度1
    $input->getOption('vue') => 'laravel/vue-starter-kit',          // 優先度2
    $input->getOption('livewire') => 'laravel/livewire-starter-kit', // 優先度3
    default => $input->getOption('using'),                           // 優先度4(最低)
};

この処理により、たとえば次のように実行した場合:

laravel new myapp --using=myorg/custom --react

--using= の指定は無視され、--react が優先される。

条件付きで無効化される

if ($this->usingLaravelStarterKit($input) && $input->getOption('workos')) {
    // dev-workosブランチを使用
}

この条件分岐は、laravel/ で始まる公式スターターキットのみを対象としており、
laravel/ 以外のカスタムキット(例:myorg/custom)では無効となる。
つまり、--using= で指定したカスタムスターターにはこの workos オプションは作用しない。
laravel プレフィックスを自分で付けることはできないため、実質的に無効)

--no-authentication は作用しない

laravel new myapp --using=myorg/custom --no-authentication

この場合、単に myorg/custom が展開されるだけで、
--no-authentication オプションは無視される。

展開時にマイグレーションがスキップされる

if (! $input->getOption('database') && $this->usingStarterKit($input)) {
    $migrate = false;  // マイグレーションをスキップ
    $input->setOption('database', 'sqlite');
}

--using= を指定すると、以下の動作となる:

  • デフォルトで sqlite が設定される
  • マイグレーションは実行されない

これにより、スターター展開後に自分で php artisan migrate を実行する必要がある。

常に有効

以下のオプションは --usingと併用可能:

  • --git: リポジトリ初期化
  • --github: GitHubリポジトリ作成
  • --branch: ブランチ名指定
  • --organization: Organization指定
  • --pest: Pestインストール
  • --phpunit: PHPUnit使用
  • --boost: Laravel Boostインストール
  • --npm, --pnpm, --bun, --yarn
  • --database: データベース設定(ただしマイグレーションはスキップ)
  • --dev: 開発版(ただしスターターキット使用時は--stability=dev固定)
  • --force: 強制上書き

ここで --pest--phpunit効く点に注意して、以下で実際に弊スターターキットを展開してみよう

実際に展開してみる

それでは実際にlaravelコマンドを用いて弊スターターキット(https://github.com/catatsumuri/react-starter-kit-ja.git) を展開してみる

laravel new myapp --using=https://github.com/catatsumuri/react-starter-kit-ja.git

実行例

$ laravel new myapp --using=https://github.com/catatsumuri/react-starter-kit-ja.git

   _                               _
  | |                             | |
  | |     __ _ _ __ __ ___   _____| |
  | |    / _` |  __/ _` \ \ / / _ \ |
  | |___| (_| | | | (_| |\ V /  __/ |
  |______\__,_|_|  \__,_| \_/ \___|_|


 ┌ Which testing framework do you prefer? ──────────────────────┐
 │ PHPUnit                                                      │
 └──────────────────────────────────────────────────────────────┘

> cloned catatsumuri/react-starter-kit-ja#HEAD to /home/admin/tmp/myapp
Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current platform.
Package operations: 133 installs, 0 updates, 0 removals
  - Installing dasprid/enum (1.0.7): Extracting archive
# 略
  - Installing phpunit/phpunit (11.5.43): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

   INFO  Discovering packages.

  inertiajs/inertia-laravel ............................................................. DONE
# 略
................................................................... DONE

86 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php -r "file_exists('.env') || copy('.env.example', '.env');"

   INFO  Application key set successfully.

 ┌ Would you like to run npm install and npm run build? ────────┐
 │ No                                                           │
 └──────────────────────────────────────────────────────────────┘

   INFO  Application ready in [myapp]. You can start your local development using:

➜ cd myapp
➜ npm install && npm run build
➜ composer run dev

  New to Laravel? Check out our documentation. Build something amazing!

ここではPHPUnitを指定して**myapp*を作っている。

Pest を指定した場合

laravel new myapp-pest --using=https://github.com/catatsumuri/react-starter-kit-ja.git

このようにして myapp-pest を作成し、選択画面で Pest を指定すると:

 ┌ Which testing framework do you prefer? ──────────────────────┐
 │ › ● Pest                                                     │
 │   ○ PHPUnit                                                  │
 └──────────────────────────────────────────────────────────────┘

出力例は以下の通り。

Using version ^4.0 for pestphp/pest-plugin-drift

  ✔✔✔✔✔.✔✔✔✔✔✔✔

   INFO  The [tests] directory has been migrated to PEST with 12 files changed.

pestphp/pest-plugin-drift により、既存の PHPUnit テストが Pest 形式に後から変換される ことが確認できる。

考察:カスタムテンプレートでは PHPUnit が安全

この挙動から分かるように、カスタムスターターキットを作成する場合は、現時点では PHPUnit ベースで構築するのが無難。

  • Pest を指定しても、内部的には PHPUnit ベースが展開された後に Pest へ自動変換される。
  • 逆方向(Pest → PHPUnit)の変換はサポートされていない。

したがって、初期テンプレート段階では PHPUnit 構成で用意し、スターターキットを展開時に Pest へとウィザードやオプションにて切り替える方が安定する。

laravel boostについて

laravel コマンドは *--boostオプションで Laravel Boost を導入しようとするが、 現状ではartisan boost:install` が SQLite データベース未作成時に例外を起こす。そのためそのままでは正常に動作しない。

Using version ^1.7 for laravel/boost

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

  ✦ Laravel Boost :: Install :: We Must Ship ✦

 Let's give Laravel a Boost


   Illuminate\Database\QueryException

  Database file at path [/home/admin/tmp/myapp-boost/database/database.sqlite] does not exist. Ensure this is an absolute path to the database. (Connection: sqlite, SQL: select * from "cache"
where "key" in (laravel-cache-boost.roster.scan))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:824
    820▕                     $this->getName(), $query, $this->prepareBindings($bindings), $e
    821▕                 );
    822▕             }
    823▕
  ➜ 824▕             throw new QueryException(
    825▕                 $this->getName(), $query, $this->prepareBindings($bindings), $e
    826▕             );
    827▕         }
    828▕     }

      +49 vendor frames

  50  artisan:16

対処法

単純な回避策として、SQLite データベースを手動で作成してから再実行すればよい。

$ cd myapp-boost/
$ touch database/database.sqlite
$ php artisan migrate
$ php artisan boost:install

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

  ✦ Laravel Boost :: Install :: We Must Ship ✦

 Let's give Laravel a Boost

 ┌ Which third-party AI guidelines do you want to install? ─────┐
 │ › ◻ laravel/fortify (~311 tokens) Laravel Fortify            │
 └──────────────────────────────────────────────────────────────┘
  You can add or remove them later by running this command again

この問題は、Laravel Installer 側でデータベース生成をスキップしていることに起因する。
Boost は初期化時にキャッシュストアへ即アクセスする設計のため、DB 接続が存在しない状態では例外を回避できない。

現時点での最も簡潔な解決策は:

  • SQLite ファイルを先に作成する
  • php artisan migrate を実行する
  • その後 php artisan boost:install を行う

今後の修正版では、Boost 側で DB 未作成時に自動生成するロジックを加える必要がある。

実際に展開されたプロジェクトを起動してみる

以下に起動後のスクリーンショットを置いた


起動後の画面(弊スターターキット適用後、UI が日本語化されている様子)

環境変数 .env.env.example からコピーされており、次の値が有効になっている:

APP_LOCALE=ja

が適切にセットされ、日本語となっている

Upstream 変更への追従

幸運にも、この記事を書いているときにアップストリームlaravel/react-starter-kitが変更されていたようだ

  • 1 commit behind laravel/react-starter-kit:main. が見える*

変更内容の確認

まず、差分の内容を確認する:

https://github.com/laravel/react-starter-kit/commit/08add9724480cdc3e4acd3d4b3a76335e6739a18


どう見ても小変更だ

小さな修正(ドキュメントや軽微なコード調整)であることが分かる

Web での追従(小規模変更の場合)

この程度の変更であれば、GitHub の Web UI からマージしても問題ない。

  • laravel/react-starter-kit の main ブランチを base に指定し、自分のリポジトリに pull request を作成。
  • PR の作成者とマージ先がどちらも自分になる(=「自分に対する PR」)。


自分リポジトリ宛てにプルリクを立てる


そのまま自分でマージする

コンフリクトが発生した場合は、対象ファイルを手作業で修正すればよい。
ただし、変更が複雑になる場合は ローカル環境で rebase / merge した方が安全である。
その作業の詳細は長くなるので紹介しないが、AIに聞くと現代では大体解決できるのではないだろうか

laravel new で展開したプロジェクトへの反映への注意点

laravel new コマンドで生成したプロジェクトは upstream リンクを持たない。
つまり、git remote -v で見ても upstream が存在しないため、元のスターターキットの更新を自動で取り込むことはできない。

したがって:

  • laravel new で展開した後に upstream 変更を取り込みたい

これほぼ不可能。個別に差分を手動反映するしかない

追従を前提に開発したい場合は

laravel newによるスターターキットを使わず、手動gitで導入する

git clone https://github.com/catatsumuri/react-starter-kit-ja.git
cd react-starter-kit-ja
git remote add upstream https://github.com/laravel/react-starter-kit.git

以降は:

git fetch upstream
git merge upstream/main

で公式変更を安全に取り込める。

そもそもの思想:「雛形」ではなく「テンプレート展開」

laravel newclone ではなく テンプレート展開ツールである。つまり、Starter Kit は「ひな型のコードをコピーして即開発に入る」ための仕組みであり、継続的に upstream と同期する前提では作られていない。「プロジェクトが始まる時点で雛形を展開し、その後は完全に独立して進む」ことにより初学者でも laravel new myapp --react だけで即動く。ただしその代償として、「雛形の継続的メンテナンス」は切り捨てている。

現実的な構造的欠陥

その結果:

  • 公式スターター(React/Vue/Livewire)も カスタムもclone 前提ではない
  • --using で指定したカスタムキットも同様に 一度きりの展開
  • .git は展開した時点でイニシャライズされる

この設計では、後から upstream に追従しようとするほど「壊れやすいコピー」になってしまう。

実務的な解法(割り切り)

現実的には3パターンの戦略がある:

方針 メリット デメリット
① clone + upstream 管理 継続的な追従が可能。差分を可視化できる。 初期セットアップに慣れが必要。laravel new の手軽さは失われる。
laravel new で一度展開し、以後は独立開発 簡単・即動作。 upstream 更新を一切取り込めない。メンテは手動。
③ カスタム installer を自作した後でupstreamをセット 両立可能。 それやるなら ① でいいんじゃねえか的な疑問があり...

laravelコマンド使わないなら https://zenn.dev/catatsumuri/articles/bb17287cdd9d0d#以上を踏まえてlaravelコマンドを「使わない」選択 も参照。こちらはforkしない選択(社内向けgitlabとか他のgit bareリポジトリに対応)

Discussion