PHP + Laravel マニュアル(VS Code / 初心者向け)

0. 全体像(最初に地図を持つ)

Laravel で「Webアプリ」を動かすには、ざっくり ①PHP実行環境②依存管理(Composer) が必要です。
さらに、フロント側(CSS/JSのビルド)を扱うなら ③Node.js(または Bun) も使います(Laravel公式でも推奨)。

おすすめの選び方(Windows)

選択肢こんな人向け特徴
A. Laravel Herd(最短) 「まず動かしたい」「Dockerは後で」 Windowsに PHP + nginx 等がまとまって入る。最短で開始できる。
B. Laravel Sail(Docker) 「チームと同じ環境」「本番に近い」 Dockerで環境が揃う。Windowsは WSL2 前提が基本。
✅ このドキュメントの方針 まず A(Herd) で「確実に動く」体験を作り、
必要になったら B(Sail) に移行できるように説明します。

1. 必要なもの(用語と最低限)

名前役割なぜ必要?
PHP Laravel を実行する言語ランタイム Laravel は PHP で動くため
Composer PHPの依存ライブラリ管理 Laravel 本体や周辺ライブラリを入れるため
Laravel Installer / composer create-project Laravel プロジェクト生成 雛形を作って、すぐ開発開始するため
VS Code エディタ 補完・デバッグが強い
Xdebug PHPのステップ実行(デバッガ) ブレークポイント停止・変数確認をするため
📘 Laravel の公式が言っている前提 Laravel のインストール前に PHP と Composer を用意し、必要ならフロント資産ビルド用に Node.js / npm(または Bun) を用意する、という流れです。

2. 環境構築(Windows:おすすめ順)

2-A. Laravel Herd(いちばん簡単)

Herd は Windows に Laravel / PHP 開発環境をまとめて入れるツールです。まずはこれで「動く」を作るのが最短です。
  1. Herd をインストール(公式サイトから)
  2. インストール後、PowerShell で確認:
    php -v
    composer -V
    (Herd により PHP が入っていれば php が動きます。)
ここで詰まる典型
php が見つからない:PATH が通っていない / 端末を再起動していない、など。
まずは PC 再起動 or VS Code を再起動してから再チェック。

2-B. Laravel Sail(Docker / WSL2)

Sail は Laravel 公式の Docker 開発環境で、Windows は WSL2 経由での利用がサポートされています。
  1. WSL2(Ubuntuなど)を入れる(Windowsの標準機能)
  2. Docker Desktop を入れ、WSL2 統合を有効にする
  3. 以降の作業は基本的に WSL2 側のターミナル で行う(パスや権限でハマりにくい)
⚠ Sail は「環境が揃う」代わりに前提が重い はじめの学習では Herd の方が速いです。
ただしチーム開発・本番寄せなら Sail が強いです。

3. 新規プロジェクトを作る(2通り)

Laravel プロジェクトの作り方は主に 2 通りです:
① Laravel Installer(laravel new) / ② Composer(create-project)

3-1. まず作業フォルダを作る(VS Codeで開く)

  1. 例:C:\work\laravel-sample を作成
  2. VS Code → FileOpen Folder... で開く
  3. VS Code → TerminalNew Terminal

3-2. Composer で作る(初心者はこれでOK)

# フォルダ直下で(例:C:\work)
composer create-project laravel/laravel my-app
💡 composer create-project って何? Laravel一式の雛形をダウンロードして依存を解決し、すぐ実行できる状態を作ります。
「テンプレ展開+依存インストール」までがワンコマンドだと思ってOKです。

3-3. Laravel Installer で作る(慣れたら)

Laravel 公式は「PHP / Composer / Laravel installer」の用意を前提に案内しています。

# Laravel installer を入れる(例)
composer global require laravel/installer

# 新規作成
laravel new my-app
global の注意
Windows では Composer の global bin パスが通っていないと laravel コマンドが見つからないことがあります。
まずは 3-2 の create-project が確実です。

4. 起動・動作確認(最初のゴール)

4-1. 開発サーバー起動(標準)

cd my-app
php artisan serve

ブラウザで http://127.0.0.1:8000 を開いて Laravel のトップが出ればOK。

4-2. フロントのビルド(必要になったら)

Laravel はフロント資産のコンパイルに Node.js / npm(または Bun) を使う想定があります。
# 依存導入 → 開発ビルド
npm install
npm run dev

4-3. Sail の場合

# (プロジェクト直下)
./vendor/bin/sail up -d

※ Sail は Docker のコンテナを起動します。

5. VS Code 設定(最低限これだけ)

5-1. 入れる拡張機能

拡張名前用途
必須 PHP Intelephense 補完・定義ジャンプ・型っぽい解析。PHPの開発体験が一気に上がる。
デバッグ用 PHP Debug Xdebug でブレークポイント停止するため

5-2. ワークスペース推奨(初心者向け)

💡 まずは「プロジェクトフォルダを丸ごと開く」 Laravel は app/, routes/, config/ などフォルダが多いです。
ファイル単体ではなく、必ず プロジェクトフォルダを開くのが基本です。

6. デバッグ(Xdebug + VS Code)

PHP のステップ実行は Xdebug(PHP側)VS Code(待ち受け側) のセットです。
Xdebug 3 では VS Code の既定ポートが 9003 でよく使われます。

6-1. VS Code 側:launch.json を作る

  1. 左の「実行とデバッグ」 → 「launch.json を作成」
  2. テンプレから Listen for Xdebug(または PHP Debug)を選ぶ

最小例(.vscode/launch.json)

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Listen for Xdebug",
      "type": "php",
      "request": "launch",
      "port": 9003,
      "pathMappings": {
        "${workspaceFolder}": "${workspaceFolder}"
      }
    }
  ]
}
💡 pathMappings とは 「PHPが実行しているファイルのパス」と「VS Code が開いているパス」を対応づけます。
Docker(Sail)だと /var/www/html のようなコンテナパスになるので、ここが重要になります。

6-2. PHP 側:Xdebug を有効化する(Herd の場合)

Herd は PHP 環境をまとめて提供します。まずは「Xdebug を ON にする」→「VS Code で Listen」→「アクセスして止まる」順で確認してください。

概念として必要になる設定(例:xdebug.mode / start_with_request / client_port など)

; 例:php.ini / xdebug.ini(場所は環境により異なる)
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_port=9003
注意(超重要)
PHP には「CLI 用の php.ini」と「Web サーバー用の php.ini」が別になることがあります。
「Webで止めたいのに止まらない」時は、どの php.ini が効いているかをまず疑います。

6-3. Sail(Docker)の場合:典型の考え方

Sail は公式で Windows(WSL2) を含む利用が案内されています。
  1. VS Code は Listen for Xdebug を開始
  2. コンテナ側の PHP に Xdebug を入れて debug を ON
  3. pathMappings を「コンテナ内パス ↔ ワークスペース」に合わせる
✅ “止まった” を確認する最短テスト
  1. routes/web.php に一時的なルートを追加
  2. その行にブレークポイント
  3. ブラウザでアクセスして止まるか確認

7. リリース(本番配置)— まず「何をやるか」を固定する

Laravel 公式の Deployment では、本番で効率よく動かすためにキャッシュ生成等を推奨しています(例:php artisan config:cache)。

7-1. “配布物” とは何か(Laravelの場合)

7-2. 本番の基本手順(超定番チェックリスト)

  1. 本番の .env を用意(最低限)
    APP_ENV=production
    APP_DEBUG=false
    APP_KEY=...(本番用)
  2. 依存インストール(本番向け)
    composer install --no-dev --optimize-autoloader
    --no-dev:開発用依存を入れない(本番を軽く)
    --optimize-autoloader:オートロード最適化(本番向け)
  3. アプリ最適化(代表)
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
    公式の Deployment で config:cache などの実行が推奨されています。
  4. DB を使うなら migrate(本番は force)
    php artisan migrate --force
  5. ストレージリンク(必要な場合)
    php artisan storage:link
⚠ キャッシュ系コマンドの注意 本番で config:cache を使うと、
以後は .env を直接読まない(キャッシュに固まる)ため、
設定変更時は「キャッシュ作り直し」が必要です。

7-3. 公開(ホスティング)の代表パターン

方式向いている要点
レンタルサーバー(共有) 小規模・学習 public/ をドキュメントルートにする、PHPバージョン要確認
VPS(nginx/Apache) 実務に近い 権限、Queue、Scheduler(cron)、ログ運用が必要
マネージド(Forge等) 運用を楽に デプロイ自動化・SSL・監視を寄せられる(別途学習)

8. よくある詰まり(ここだけ見れば復帰できる)

8-1. composer が見つからない

8-2. php artisan serve は動くが、VS Code デバッグで止まらない

💡 デバッグ確認の順番(事故らない) ① VS Code を Listen → ② いちばん簡単なルートにブレーク → ③ ブラウザで叩く → ④ 止まるか
これで「どこまでできてるか」が切り分けできます。

8-3. 本番で 500 エラー(画面真っ白)

1) Laravel プロジェクトフォルダー構成(代表)

ポイント:Laravel は「どこに何を書くか」が決まっているため、まずはフォルダ構成を地図として覚えると迷いません。
パス 役割 ここに何を書く?
app/ アプリ本体のコード Controller / Service / Model / Enum など(実装の中心)
app/Http/Controllers/ コントローラ(HTTPの入口) リクエストを受け取り、入力を検証し、処理を呼び出してレスポンスを返す
app/Models/ モデル(DBアクセス) DBテーブルに対応するクラス(Eloquent ORM)
app/Services/(自作) 業務ロジック層(任意) 注文計算・請求判定など、Controller から分離したいロジック
app/Enums/(自作) enum 置き場(任意) 状態区分・種別区分など(例:OrderStatus)
routes/ ルーティング(URL対応) web.php(画面系)/ api.php(API系)
resources/views/ 画面(Blade) HTMLテンプレート(.blade.php
config/ 設定 DB設定、メール設定、アプリ設定など(.env と連携)
database/ DB関連 migration(テーブル定義)/ seeder(初期データ)
public/ 公開ディレクトリ Webサーバーのドキュメントルート(入口は public/index.php
storage/ ログ・キャッシュ・アップロード等 storage/logs にログ、storage/app にファイル等
tests/ テスト Unit / Feature テスト
vendor/ Composer依存 ライブラリ本体(触らない・Gitに入れないことが多い)
初心者が最初に触る場所:
  • routes/web.php(URLの入り口)
  • app/Http/Controllers/(処理の入口)
  • app/Services/(業務ロジック:自作すると理解しやすい)

2) PHPの変数の型と基本文法(if / switch / while / for / foreach)

ポイント:PHP は「動的型付け」ですが、最近のPHPは declare(strict_types=1) や型宣言でかなり安全にできます。

2-1. 代表的な型

説明
int123整数
float3.14小数
string"abc"文字列
booltrue真偽値
array[1,2,3]配列(連番/連想、両方を含む)
objectnew User()オブジェクト
nullnull値がない

2-2. 変数宣言($ が必須)

// 変数名の先頭に $ が必要
$age = 20;               // int
$price = 19.8;           // float
$name = "Taro";          // string
$isActive = true;        // bool
$items = ["a", "b"];     // array
$nothing = null;         // null

2-3. if / else

$score = 75;

if ($score >= 80) {
  $rank = "A";
} elseif ($score >= 60) {
  $rank = "B";
} else {
  $rank = "C";
}

2-4. switch(値の分岐)

$status = "PAID";

switch ($status) {
  case "NEW":
    $label = "新規";
    break;
  case "PAID":
    $label = "支払済";
    break;
  default:
    $label = "不明";
    break;
}
注意:break を忘れると次の case に「落ちる」ので、意図がない限り必ず書きます。

2-5. while

$i = 0;
while ($i < 3) {
  // 0,1,2 と出る
  echo $i . PHP_EOL;
  $i++;
}

2-6. for

for ($i = 0; $i < 3; $i++) {
  echo $i . PHP_EOL;
}

2-7. foreach(配列ループ)

$names = ["Aki", "Ken", "Mao"];

foreach ($names as $n) {
  echo $n . PHP_EOL;
}

// key と value の両方を取る
$map = ["a" => 10, "b" => 20];
foreach ($map as $k => $v) {
  echo $k . ":" . $v . PHP_EOL;
}

3) 文字型・数値型の相互変換

3-1. 数値 → 文字列

$n = 123;

// (string) キャスト
$s1 = (string)$n;     // "123"

// 文字列連結でも暗黙変換される
$s2 = "ID=" . $n;     // "ID=123"

3-2. 文字列 → 数値(基本)

$s = "19.8";

// int / float キャスト(先頭から数値として読める部分を使う)
$i = (int)$s;         // 19
$f = (float)$s;       // 19.8
注意:PHPのキャストは「先頭から数値として読める範囲」を使うため、入力が自由な場合は検証が必要です。

3-3. 数字っぽいかチェック

$input = "123";
if (is_numeric($input)) {
  $value = (int)$input;
} else {
  // エラー扱いなど
}

4) 文字列操作(toupper / split / replace / format)

4-1. 大文字化(toupper)

$s = "hello";
$upper = strtoupper($s); // "HELLO"
日本語などマルチバイト文字を扱う場合は mb_strtoupper を使うことが多いです(環境により mbstring 拡張が必要)。

4-2. 分割(split)

$csv = "a,b,c";
$parts = explode(",", $csv); // ["a","b","c"]

4-3. 置換(replace)

$text = "I like cats";
$out  = str_replace("cats", "dogs", $text); // "I like dogs"

4-4. format(埋め込み)

$name = "Taro";
$age  = 20;

// 1) 連結
$s1 = "name=" . $name . ", age=" . $age;

// 2) ダブルクォート内の変数展開
$s2 = "name=$name, age=$age";

// 3) sprintf(C言語風のフォーマット)
$s3 = sprintf("name=%s, age=%d", $name, $age);

// 0埋め(例:IDを6桁に)
$id = 42;
$s4 = sprintf("ID-%06d", $id); // "ID-000042"

5) 数値操作(ceil / floor / round / max など)

5-1. 小数の切り上げ・切り捨て・四捨五入

$x = 12.34;

$a = ceil($x);   // 13(切り上げ)
$b = floor($x);  // 12(切り捨て)
$c = round($x);  // 12(四捨五入)

// 小数第2位まで
$d = round($x, 2); // 12.34

5-2. max / min

$m = max(10, 3, 99); // 99
$n = min(10, 3, 99); // 3

5-3. abs(絶対値)

$v = abs(-15); // 15

PHPの演算子まとめ(算術・論理・ビット・代入・三項など)

PHP には C / Java と似た演算子が多くあります。
ここでは 「業務コードを書くときによく使うもの」 を中心に、 種類ごとに整理して説明します。

1. 算術演算子(計算に使う)

数値の計算に使います。

演算子 意味 結果
+ 加算 3 + 2 5
- 減算 3 - 2 1
* 乗算 3 * 2 6
/ 除算 5 / 2 2.5
% 剰余(余り) 5 % 2 1
** べき乗 2 ** 3 8
$total = $price * $qty;
$remain = $count % 10;

2. 比較演算子(条件判定)

値を比較して true / false を返します。

演算子 意味
== 等しい(型は見ない) 1 == "1"
=== 等しい(型も見る) 1 === "1" → false
!= 等しくない $a != $b
< 小さい $a < $b
> 大きい $a > $b
<= 以下 $a <= $b
>= 以上 $a >= $b
実務では === を使うのが安全
型違いによるバグを防げます。

3. 論理演算子(条件を組み合わせる)

演算子 意味
&& AND(かつ) $a && $b
|| OR(または) $a || $b
! NOT(否定) !$a
if ($age >= 18 && $isMember) {
    // 条件を両方満たす
}

4. ビット演算子(低レベル処理)

数値を 2進数として扱う演算子です。
業務アプリではあまり頻繁には使いません。

演算子 意味
& AND
| OR
^ XOR
<< 左シフト
>> 右シフト
$flags = 0b1010 & 0b1100;

5. 代入演算子(値を入れる)

演算子 意味
= 代入 $a = 10
+= 加算して代入 $a += 5
-= 減算して代入 $a -= 3
*= 乗算して代入 $a *= 2
/= 除算して代入 $a /= 2

6. 文字列演算子(PHP 特有)

演算子 意味
. 文字列連結 "Hello " . "World"
.= 連結して代入 $s .= "!"

7. 三項演算子(条件分岐を1行で)

$label = ($age >= 20) ? '成人' : '未成年';

意味:

条件 ? trueの場合 : falseの場合

null 合体演算子(よく使う)

$name = $inputName ?? 'guest';

$inputNamenull の場合だけ右側が使われます。


8. インクリメント / デクリメント

$i++;
$i--;
++$i;
--$i;

9. まとめ

  • 算術演算子:数値計算
  • 比較・論理演算子:条件分岐
  • 代入演算子:値更新
  • 文字列演算子:PHP 特有(.
  • 三項 / null 合体:条件を簡潔に書ける

PHPで正規表現(Regex)を扱う方法(実務向け)

PHPの正規表現は基本的に PCRE(Perl互換正規表現)で、
主に preg_* 系関数を使います。

1. PHPの正規表現の基本(delimiter が必須)

PHPでは、正規表現パターンは 必ず区切り文字(delimiter)で囲みます。
よく使う delimiter は / です。

$pattern = '/^abc/';   // "abc"で始まる
$pattern = '/abc$/';   // "abc"で終わる
$pattern = '/a.c/';    // a + 任意1文字 + c
delimiter を / にすると、パターン内で / を使いたい場合は \/ とエスケープが必要です。
例:URL を扱うなら delimiter を # にすることが多いです。
$pattern = '#^https?://#'; // / をエスケープしなくてよい

2. よく使う preg_* 関数一覧

関数 用途 戻り値(超重要)
preg_match() 1件マッチするか(先頭からでなくても探す) 1=マッチ, 0=不一致, false=エラー
preg_match_all() 複数マッチを全部取る マッチ件数, false=エラー
preg_replace() 置換(検索→置き換え) 置換後文字列, null=エラー(環境による)
preg_split() 正規表現で split 配列
preg_grep() 配列からマッチする要素だけ抽出 配列
preg_quote() ユーザー入力を安全にパターン文字列化 エスケープ済み文字列
実務での注意
preg_match() の戻り値は boolean ではありません。
1 / 0 / false の3種類なので、エラーと不一致を区別したい場合は必ず === 比較を使います。

3. preg_match()(最重要:マッチ判定+グループ取得)

3-1. マッチするかだけ判定

$pattern = '/^[0-9]+$/';   // 数字だけ
$input   = '12345';

$result = preg_match($pattern, $input);

if ($result === 1) {
    // マッチした
} elseif ($result === 0) {
    // マッチしない(入力が条件を満たさない)
} else {
    // false: 正規表現の書き方が間違っている等のエラー
    throw new RuntimeException('Regex error');
}

3-2. キャプチャ(グループ)の値を取り出す

() で囲んだ部分が キャプチャグループです。
第2引数 $matches に結果が入ります。

$pattern = '/^(\d{4})-(\d{2})-(\d{2})$/'; // YYYY-MM-DD
$input   = '2026-02-17';

$result = preg_match($pattern, $input, $matches);

if ($result === 1) {
    // $matches[0] は全体マッチ
    // $matches[1] は1番目の()の中(年)
    // $matches[2] は2番目(⽉)
    // $matches[3] は3番目(日)
    $year  = $matches[1];
    $month = $matches[2];
    $day   = $matches[3];
}

3-3. 名前付きキャプチャ(実務で読みやすい)

$pattern = '/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/';
$input   = '2026-02-17';

if (preg_match($pattern, $input, $m) === 1) {
    $year  = $m['year'];
    $month = $m['month'];
    $day   = $m['day'];
}

4. preg_match_all()(複数ヒットを全部取る)

文章からメールアドレスっぽいものを全部抜き出す例です。

$text = 'mail: a@example.com / b@test.jp';
$pattern = '/[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/i';

$count = preg_match_all($pattern, $text, $matches);

if ($count === false) {
    throw new RuntimeException('Regex error');
}

// $matches[0] に全部入る
$emails = $matches[0];

5. preg_replace()(置換)

5-1. 文字をマスクする(例:電話番号の数字を伏字)

$tel = '090-1234-5678';

// 数字を全部 "X" にする(ハイフンは残す)
$masked = preg_replace('/\d/', 'X', $tel);
// 結果: XXX-XXXX-XXXX

5-2. グループ参照で整形する

$input = '20260217';

// 8桁数字を YYYY-MM-DD にする
$output = preg_replace('/^(\d{4})(\d{2})(\d{2})$/', '$1-$2-$3', $input);
// 結果: 2026-02-17
置換文字列の $1, $2 は「() の1番目、2番目」を指します。

6. preg_split()(正規表現で分割)

$input = "a, b; c  d";
$parts = preg_split('/[\s,;]+/', $input);
// 結果: ["a", "b", "c", "d"]

7. preg_grep()(配列からマッチする要素だけ)

$names = ['apple', 'banana', 'apricot', 'grape'];

// a で始まる要素だけ
$result = preg_grep('/^a/', $names);
// 結果: ["apple", "apricot"](キーは元のまま)

8. 正規表現の「よく使う記号」まとめ

記号 意味
^ 行(文字列)の先頭 /^a/(aで始まる)
$ 行(文字列)の末尾 /z$/(zで終わる)
. 任意の1文字 /a.c/
\d 数字1文字(0-9) /\d{4}/(4桁)
\w 英数+_(環境により範囲注意) /^\w+$/
[...] 文字クラス /[A-Z]/
+ 1回以上繰り返し /\d+/
* 0回以上繰り返し /a*/
? 0回 or 1回(任意) /colou?r/
(...) グループ(キャプチャ) /(\d{3})-(\d{4})/
(?:...) 非キャプチャグループ /(?:ab)+/
| OR /(cat|dog)/

9. Laravel(FormRequest)での正規表現バリデーション例

Laravel では regex: ルールで正規表現チェックできます。

// app/Http/Requests/UserUpdateRequest.php
public function rules(): array
{
    return [
        'tel' => [
            'nullable',
            'string',
            // 例:数字とハイフンだけ許可
            'regex:/^[0-9\-]+$/',
        ],
    ];
}
実務のコツ
正規表現は「通す」より「弾く」方が難しいため、
仕様(許可する文字・形式)を先に決めるのが重要です。

6) class / interface / enum / abstract class

6-1. class(クラス)

class User {
  public function __construct(
    public string $name,
    public int $age
  ) {}

  public function greet(): string {
    return "Hello, " . $this->name;
  }
}

6-2. interface(契約)

interface PriceCalculator {
  public function calc(int $basePrice): int;
}

class TaxCalculator implements PriceCalculator {
  public function calc(int $basePrice): int {
    return (int)round($basePrice * 1.1);
  }
}
interface は「このメソッドを必ず持つ」という約束です。実装(中身)は持ちません。

6-3. abstract class(共通実装を持つ基底クラス)

abstract class BaseService {
  protected function log(string $msg): void {
    // 例:共通ログ
    // logger($msg);  // Laravelなら logger() が使える
  }

  // 抽象メソッド:子が必ず実装
  abstract public function execute(): void;
}

class OrderService extends BaseService {
  public function execute(): void {
    $this->log("OrderService start");
  }
}
interface と abstract の使い分け(ざっくり):
  • interface:型の約束だけ(実装なし)
  • abstract class:共通処理(実装あり)+未実装の強制

6-4. enum(業務区分に最適)

// PHP 8.1+ の enum
enum OrderStatus: string {
  case NEW = "NEW";
  case PAID = "PAID";
  case CANCELED = "CANCELED";
}

// 利用例
$status = OrderStatus::PAID;

if ($status === OrderStatus::PAID) {
  // 支払済の処理
}
注意:enum は PHP 8.1 以上が必要です。Laravelのバージョンによって必要PHPバージョンがあるので合わせてください。

7) array / list / set / map(PHPでの考え方)と主なメソッド

PHP は基本のコンテナ型が array 1種類です。
しかし array は「list(連番)」「map(連想)」の両方として使えます。
set は「重複なし」を自前で表現します(例:キーにして true を入れる等)。

7-1. array を list として使う(連番配列)

$list = ["a", "b", "c"];

// 追加
$list[] = "d";          // ["a","b","c","d"]

// サイズ
$len = count($list);    // 4

// 先頭/末尾
$first = $list[0];
$last = $list[$len - 1];

// 末尾削除
$tail = array_pop($list); // "d"

よく使う関数(list向け)

$nums = [1, 2, 3];

// map 相当(各要素変換)
$double = array_map(fn($x) => $x * 2, $nums); // [2,4,6]

// filter 相当(条件抽出)
$even = array_filter($nums, fn($x) => $x % 2 === 0); // [2](キーは残る)

// reduce 相当(合計など)
$sum = array_reduce($nums, fn($acc, $x) => $acc + $x, 0); // 6

// 並び替え(値)
sort($nums);  // 昇順(破壊的)
注意:array_filter は「キーが残る」ので、連番に戻したい時は array_values() を使います。
$even = array_values($even);

7-2. array を map として使う(連想配列)

$map = [
  "apple" => 120,
  "banana" => 80,
];

// 参照
$price = $map["apple"];

// 追加/更新
$map["orange"] = 150;

// 存在チェック
if (array_key_exists("banana", $map)) {
  // OK
}

// key一覧 / value一覧
$keys = array_keys($map);
$values = array_values($map);

7-3. set 相当(重複なし)

// 値をキーにして true を入れると「集合」になる
$set = [];
$set["A"] = true;
$set["B"] = true;
$set["A"] = true; // 重複しても上書きされるだけ

$hasA = array_key_exists("A", $set); // true

// set の全要素(キー)を list として取得
$all = array_keys($set);

7-4. Laravel Collection(配列より便利な “メソッド集合”)

Laravel では collect([...])Collection になり、map/filter/reduce/unique などが読みやすく書けます。
$col = collect([1, 2, 3, 4]);

$even = $col->filter(fn($x) => $x % 2 === 0)->values(); // [2,4]
$double = $col->map(fn($x) => $x * 2);                 // [2,4,6,8]
$sum = $col->reduce(fn($acc, $x) => $acc + $x, 0);     // 10
業務ロジック学習に向く理由:「配列処理が宣言的に書ける」ので、ループより意図が読みやすくなります。

補足解説:Laravel Collection(collect)の考え方と徹底サンプル

ここで言いたいこと:
Laravel の collect([...]) は「配列を便利な オブジェクト に変える」仕組みです。

for / foreach を直接書く代わりに、
「何をしたいか(map / filter / reduce など)」を宣言的に書けるため、
業務ロジックが 短く・読みやすく・間違いにくく なります。

1) Collection とは何か(array との違い)

// 普通の配列
$array = [1, 2, 3];

// Collection に変換
$collection = collect([1, 2, 3]);
項目 array Collection
言語組み込み クラス(オブジェクト)
処理方法 foreach / 関数 メソッドチェーン
可読性 処理の意図が見えにくい 「何をしたいか」が見える
副作用 破壊的操作が多い 基本は非破壊(元を変えない)
重要:Collection は「配列を置き換える魔法」ではなく、
業務ロジックを安全に書くためのラッパーです。

2) map:各要素を変換する(for の代わり)

// 元データ(例:金額一覧)
$prices = collect([100, 200, 300]);

// 税込み価格(10%)に変換
$withTax = $prices->map(function ($price) {
  // 各要素に対して処理される
  return (int) round($price * 1.1);
});

// 結果:Collection [110, 220, 330]
map の意味:
「同じ個数のまま、中身だけを別の値に変換する」

短縮記法(アロー関数)

$withTax = $prices->map(fn($price) => (int) round($price * 1.1));

3) filter:条件に合うものだけ残す

// 元データ
$scores = collect([45, 60, 72, 90]);

// 合格点(60点以上)だけ抽出
$passed = $scores->filter(function ($score) {
  return $score >= 60;
});

// 結果:Collection [60, 72, 90]
// ※ 元のキーは保持される
注意:filter はキーを保持します。
連番に戻したい場合は values() を使います。
$passed = $passed->values(); // [60, 72, 90]

4) reduce:集計・畳み込み(合計・集約)

// 注文金額一覧
$amounts = collect([1000, 2500, 1800]);

// 合計金額を計算
$total = $amounts->reduce(function ($sum, $amount) {
  // $sum : これまでの合計
  // $amount : 現在の要素
  return $sum + $amount;
}, 0);

// 結果:5300
reduce の考え方:
「配列全体を 1 つの値にまとめる」
合計、最大値、文字列結合、集計オブジェクト生成などに使えます。

業務っぽい reduce 例(合計+件数)

$orders = collect([
  ['price' => 1000],
  ['price' => 2000],
  ['price' => 1500],
]);

$result = $orders->reduce(function ($acc, $order) {
  $acc['count']++;
  $acc['sum'] += $order['price'];
  return $acc;
}, ['count' => 0, 'sum' => 0]);

// 結果:['count' => 3, 'sum' => 4500]

5) unique:重複を除く(set 的な使い方)

// 商品カテゴリ一覧(重複あり)
$categories = collect([
  'food',
  'drink',
  'food',
  'snack',
]);

$unique = $categories->unique()->values();

// 結果:['food', 'drink', 'snack']
unique:
「同じ値は 1 回だけにしたい」= set の考え方

6) map + filter + reduce の連結(Collection の真価)

// 注文データ
$orders = collect([
  ['price' => 1000, 'status' => 'PAID'],
  ['price' => 2000, 'status' => 'NEW'],
  ['price' => 1500, 'status' => 'PAID'],
]);

// 「支払済の注文だけ」を「税込価格にして」合計する
$totalPaid = $orders
  ->filter(fn($o) => $o['status'] === 'PAID')   // 支払済のみ
  ->map(fn($o) => (int) round($o['price'] * 1.1)) // 税込み
  ->reduce(fn($sum, $price) => $sum + $price, 0);

// 結果:1000*1.1 + 1500*1.1 = 2750
ここが重要:
for / if / 変数の初期化 が一切出てこないのに、
「何をしているか」が上から読むだけで分かる

7) foreach と Collection の比較

foreach 版

$sum = 0;
foreach ($orders as $o) {
  if ($o['status'] === 'PAID') {
    $sum += (int) round($o['price'] * 1.1);
  }
}

Collection 版

$sum = collect($orders)
  ->filter(fn($o) => $o['status'] === 'PAID')
  ->map(fn($o) => (int) round($o['price'] * 1.1))
  ->sum();
結論:
・短い
・条件漏れが起きにくい
・業務ルールが「文章のように」読める

8) 業務ロジック学習向けの覚え方

  • map:形を変える(同じ件数)
  • filter:間引く(件数が減る)
  • reduce:まとめる(1つになる)
  • unique:重複排除(集合)
「for をどう書くか」ではなく、
「業務として何をしたいか」を先に考えると Collection は一気に理解しやすくなります。

クラス設計の基本:可視性(public / protected / private)とファイル単位、命名規約

このセクションでは「PHP / Laravel で読みやすく・壊れにくいコードを書くための共通ルール」を説明します。
文法の正しさよりも、業務コードとして長く保守できるかを重視した考え方です。

1) public / protected / private とは何か

PHP のクラスでは、プロパティ(変数)メソッドpublic, protected, private を付けて、 「どこから触ってよいか」を制御します。

修飾子 アクセスできる場所 用途の考え方
public どこからでも 外部に公開する「入口」
protected 自分+継承した子クラス 内部拡張用(サブクラス向け)
private そのクラスの中だけ 完全に内部実装
重要な考え方:
「外から触れるものほど public
内部実装に近いほど private にする」

2) シンプルな例で理解する

class OrderService
{
  // 外から参照させたくない内部状態
  private int $basePrice;

  public function __construct(int $basePrice)
  {
    $this->basePrice = $basePrice;
  }

  // 外部に公開する「業務API」
  public function calcTotalPrice(): int
  {
    return $this->applyTax($this->basePrice);
  }

  // 内部処理(外からは触れない)
  private function applyTax(int $price): int
  {
    return (int) round($price * 1.1);
  }
}
外部のコードは calcTotalPrice() だけを知っていればよく、
税計算の中身(applyTax)を誤って呼ぶことはできません。

3) protected はいつ使う?

protected は「将来、継承される前提の共通処理」に使います。

abstract class BaseService
{
  // 子クラスからは使わせたい共通処理
  protected function log(string $message): void
  {
    // Laravel では logger() が使える
    // logger($message);
  }
}

class OrderService extends BaseService
{
  public function execute(): void
  {
    $this->log("OrderService start");
  }
}
注意:
継承を使わないなら protected は不要です。
迷ったら private を選ぶ方が安全です。

4) ファイル単位の考え方(1ファイル=1クラスが基本)

PHP は文法上、1ファイルに複数クラスを書けますが、
実務では「1ファイルに1クラス」がほぼ共通ルールです。

推奨構成(Laravel)

app/
 └─ Services/
    ├─ OrderService.php        // class OrderService
    ├─ PaymentService.php      // class PaymentService
 └─ Enums/
    └─ OrderStatus.php         // enum OrderStatus
理由:
  • ファイル名からクラスが一意に分かる
  • 検索・移動が簡単
  • Git差分が読みやすい
  • オートロード(Composer)と相性が良い
例外:
非常に小さな enum や DTO をまとめる場合もありますが、
初学者・業務コードでは「1ファイル1クラス」で統一するのが無難です。

5) クラス / 変数 / 定数 / メソッドの命名規約

Laravel / PHP では、次の命名規約が事実上の標準です。

対象 命名規約
クラス名 パスカルケース(UpperCamelCase) OrderService
メソッド名 キャメルケース(lowerCamelCase) calcTotalPrice()
変数名 キャメルケース $totalPrice
定数(const) 大文字+スネークケース MAX_RETRY_COUNT
enum ケース 大文字(慣習) PAID, CANCELED

6) 命名の具体例(良い例・悪い例)

良い例

class OrderService
{
  private int $totalPrice;

  public function calcTotalPrice(): int
  {
    return $this->totalPrice;
  }
}

避けたい例

class orderservice   // クラス名が小文字
{
  private int $Total_Price;   // 命名規約が混在

  public function Calc_total_price()  // 読みにくい
  {
    return $this->Total_Price;
  }
}
ポイント:
命名規約は「好み」ではなく、
チーム全体でコードを読むための共通言語です。

7) 実務向けの覚え方(重要)

  • public:業務として「使わせたい入口」
  • private:実装の詳細(外に漏らさない)
  • protected:継承前提の共通処理
  • 1ファイル1クラス:迷わない・壊れにくい
  • 命名規約:読む人のためのルール
「動くコード」ではなく、
「他人(未来の自分)が安心して触れるコード」を目標にすると、
これらのルールは自然に腑に落ちてきます。

Laravel(PHP)実装の基礎:構造体(DTO)、static/const、関数/ラムダ、ルーティング〜MVC〜業務ロジック

このセクションは「Laravelで業務アプリを作る時に必要な “書き方の型” 」をまとめたものです。
特に ルーティング → コントローラ → リポジトリ → サービス(業務ロジック) → ビュー(Blade) の流れが理解できるようにしています。

1) 構造体(DTO)の書き方・説明・注意点

PHP には C 言語の struct のような「構造体キーワード」はありません。
代わりに、DTO(Data Transfer Object) として「データだけを持つクラス」を作るのが一般的です。

1-1. DTOの基本(readonly / 型宣言あり)

// app/Dto/OrderDto.php(例:自作フォルダ)
final class OrderDto
{
  // readonly: コンストラクタで一度だけ代入でき、その後は変更できない(PHP 8.1+)
  public function __construct(
    public readonly int $orderId,
    public readonly string $customerName,
    public readonly int $subtotalYen
  ) {}
}
DTO の目的:
  • 配列(['orderId' => ...])のままだと、キーのスペルミスや型崩れが起きやすい
  • DTO にすると 項目名 が固定され、業務ロジックが読みやすい
  • 「入力」「内部処理」「出力」の境界がはっきりする

1-2. 注意点(DTOは “データだけ” に寄せる)

注意: DTO にDBアクセスや外部API呼び出しなどの “処理” を入れると、責務が混ざって読みづらくなります。
DTO は「ただの入れ物」に寄せるのが基本です。

1-3. 配列からDTOへ変換する例(コメント付き)

// 配列(例:request入力やDB結果)から DTO を生成する
$row = ['order_id' => 10, 'customer_name' => 'Mao', 'subtotal_yen' => 2500];

$dto = new OrderDto(
  orderId: (int)$row['order_id'],          // 型を固定
  customerName: (string)$row['customer_name'],
  subtotalYen: (int)$row['subtotal_yen']
);

2) static / const の定義方法と使い方

2-1. const(クラス定数)

class TaxRule
{
  // クラス定数:変更されない値
  public const TAX_RATE = 1.10;

  public static function calcWithTax(int $yen): int
  {
    return (int) round($yen * self::TAX_RATE);
  }
}

$price = TaxRule::calcWithTax(1000); // 1100
const の使いどころ:
税率、上限回数、固定メッセージ、業務ルールの固定値など「変わらないもの」。

2-2. static(インスタンス不要のメソッド/プロパティ)

class IdGenerator
{
  // static メソッド:new しなくても呼べる
  public static function makeOrderCode(int $orderId): string
  {
    return sprintf("ORD-%06d", $orderId);
  }
}

$code = IdGenerator::makeOrderCode(42); // "ORD-000042"
注意:
static を多用すると「テストしづらい」「依存が隠れる」ことがあります。
Laravelでは DI(依存注入)で Service を差し替えやすくする方が実務向きです。
迷ったら static ではなく “サービスクラスを new して使う/DIする” を選ぶのが安全です。

3) 関数の書き方とラムダ関数(無名関数)

3-1. 関数(通常の関数)

// 通常の関数
function add(int $a, int $b): int
{
  return $a + $b;
}

$sum = add(2, 3); // 5

3-2. 無名関数(ラムダ)

// 無名関数(クロージャ)
$fn = function (int $x): int {
  return $x * 2;
};

$result = $fn(10); // 20

PHPの可変長引数(Variadic Parameters)の取り方

PHPでは、引数の数が決まっていない関数を定義したい場合、 ...(スプレッド構文 / 可変長引数)を使います。

基本形

<?php

/**
 * 任意の数の引数を受け取る関数
 *
 * @param int ...$numbers 可変長引数(0個以上)
 */
function sum(int ...$numbers): int
{
    // $numbers は「配列」として扱われる
    $total = 0;

    foreach ($numbers as $n) {
        $total += $n;
    }

    return $total;
}

// 呼び出し側
sum(1, 2, 3);      // 6
sum(10, 20);       // 30
sum();             // 0

重要なポイント

  • ...$numbers配列として受け取られる
  • 引数は 0個でもOK
  • 型指定(int, string など)も可能

固定引数 + 可変長引数

可変長引数は 必ず最後 に書きます。

<?php

/**
 * ログ出力用のサンプル
 *
 * @param string $level ログレベル
 * @param mixed  ...$messages 出力したいメッセージ群
 */
function logMessage(string $level, ...$messages): void
{
    foreach ($messages as $msg) {
        echo "[{$level}] {$msg}\n";
    }
}

logMessage('INFO', 'start', 'processing', 'end');

既存配列を可変長引数として渡す(スプレッド)

逆に、配列を分解して引数として渡すこともできます。

<?php

$values = [1, 2, 3];

// 配列を展開して渡す
sum(...$values); // sum(1, 2, 3) と同じ

業務プログラムでよくある使用例

<?php

/**
 * ログ出力(console.log 的な用途)
 *
 * @param mixed ...$args 可変長のデバッグ情報
 */
function debugLog(...$args): void
{
    // implode などでまとめてログに出す
    error_log(implode(' | ', array_map('strval', $args)));
}

debugLog('userId', 100, 'status', 'active');

注意点(業務での落とし穴)

  • 可変長引数は 型が曖昧になりやすいため、使いすぎない
  • 業務ロジックの核心部分では、DTOや配列を明示的に渡す方が安全
  • ログ・デバッグ・ユーティリティ系で使うのが定番

一言まとめ

PHPの可変長引数は「配列として受け取れる糖衣構文」
function f(...$args) は「$args という配列を受け取る」と理解すると、 業務コードでも迷いません。

PHPの初期値つき引数・名前付き引数

PHPでは、初期値つき引数(デフォルト引数)名前付き引数(Named Arguments)の両方が書けます。 業務コードでは「可読性」と「安全性」を上げるために重要です。


1. 初期値つき引数(デフォルト引数)

引数に 初期値(デフォルト値)を指定すると、 呼び出し時に省略された場合にその値が使われます。

<?php

/**
 * ページネーション設定のサンプル
 *
 * @param int $page 現在ページ(省略時は1)
 * @param int $perPage 1ページ件数(省略時は20)
 */
function getUsers(int $page = 1, int $perPage = 20): void
{
    echo "page={$page}, perPage={$perPage}";
}

// 呼び出し例
getUsers();            // page=1, perPage=20
getUsers(2);           // page=2, perPage=20
getUsers(3, 50);       // page=3, perPage=50

重要なルール

  • 初期値つき引数は、必ず後ろに書く
  • 必須引数の後ろに置くのがPHPの文法ルール
<?php

// OK
function okSample(string $name, int $age = 20) {}

// NG(構文エラー)
function ngSample(string $name = 'taro', int $age) {}

2. 名前付き引数(Named Arguments)【PHP 8.0+】

PHP 8.0以降では、引数名を指定して呼び出すことができます。 引数の順番を意識しなくてよくなるため、業務コードで非常に有用です。

<?php

/**
 * ユーザー検索条件のサンプル
 *
 * @param string $name ユーザー名
 * @param bool $active 有効ユーザーのみ取得するか
 * @param int $limit 最大取得件数
 */
function searchUser(
    string $name,
    bool $active = true,
    int $limit = 20
): void {
    var_dump($name, $active, $limit);
}

// 名前付き引数で呼び出す
searchUser(
    name: 'taro',
    limit: 50
);

// 実際に渡される値
// name   = 'taro'
// active = true(省略されたので初期値)
// limit  = 50

名前付き引数のメリット

  • 引数の順番を気にしなくてよい
  • 「何を指定しているのか」が一目で分かる
  • 初期値つき引数と相性が良い

3. 可変長引数 × 名前付き引数の注意点

可変長引数(...$args)は 名前付き引数では指定できません

<?php

function sample(string $type, ...$values) {}

// OK
sample('A', 1, 2, 3);

// NG(エラー)
sample(type: 'A', values: [1, 2, 3]);

業務プログラムでの使い分け指針

  • 必須引数:名前付き引数で明示的に指定
  • オプション引数:初期値つき引数にする
  • 可変長引数:ログ・ユーティリティ用途に限定

一言まとめ

PHPは「初期値つき引数」「名前付き引数」「可変長引数」をすべてサポートしており、
これらを組み合わせることで、業務コードの可読性と安全性を大きく向上できる。

3-3. アロー関数(短いラムダ)

// アロー関数(短縮版)
$fn = fn(int $x): int => $x * 2;

$result = $fn(10); // 20
Laravelではどこで使う?
Collection の map/filter/reduce や、バリデーション、ルート定義などで頻繁に使います。

3-4. 外側の変数を使う(use)

$taxRate = 1.10;

// 無名関数は use で外側の変数を取り込める
$calc = function (int $yen) use ($taxRate): int {
  return (int) round($yen * $taxRate);
};

$price = $calc(1000); // 1100
補足:アロー関数は use が暗黙 アロー関数(fn() => ...)は外側の変数を自動でキャプチャします。
そのため短い処理に向きます。

4) tuple があるか?(PHPの実態)

PHPにはPythonのような “tuple型” はありません。
ただし、配列で近い表現ができます(戻り値2つなど)。

4-1. 2つ返す(配列で疑似タプル)

// 疑似タプル: [合計, 件数]
function sumAndCount(array $nums): array
{
  $sum = 0;
  foreach ($nums as $n) {
    $sum += $n;
  }
  return [$sum, count($nums)];
}

// 分割代入(PHP 7.1+)
[$sum, $count] = sumAndCount([1,2,3]);
// $sum = 6, $count = 3
注意: 疑似タプルは “要素の意味が曖昧” になりがちです。
業務コードでは DTO(例:SumResultDto)にした方が読みやすいことが多いです。

5) ルーティングファイルの書き方(routes/web.php / routes/api.php)

ルーティングとは:
「URL と “呼び出す処理(コントローラ)” を対応付ける設定」です。

5-1. routes/web.php(画面向け)

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OrderController;

// 例:トップページ
Route::get('/', function () {
  return view('welcome'); // resources/views/welcome.blade.php
});

// 例:注文一覧ページ
Route::get('/orders', [OrderController::class, 'index']);

// 例:注文詳細(URLパラメータ)
Route::get('/orders/{orderId}', [OrderController::class, 'show'])
  ->whereNumber('orderId');

5-2. routes/api.php(API向け)

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\OrderApiController;

Route::get('/orders', [OrderApiController::class, 'list']);
Route::post('/orders', [OrderApiController::class, 'create']);

6) コントローラーの書き方(HTTPの入口)

コントローラの役割は「入力を受け、必要なら検証し、業務ロジックを呼び、出力を返す」です。
業務ルールの中身はサービス層に寄せると整理しやすくなります。
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\OrderService;

class OrderController extends Controller
{
  // Laravel の DI(依存注入):OrderService を自動で渡してくれる
  public function __construct(private OrderService $orderService) {}

  // 画面:注文一覧
  public function index()
  {
    $orders = $this->orderService->listOrders();

    // Blade テンプレートへ渡す
    return view('orders.index', [
      'orders' => $orders,
    ]);
  }

  // 画面:注文詳細
  public function show(int $orderId)
  {
    $order = $this->orderService->getOrder($orderId);

    return view('orders.show', [
      'order' => $order,
    ]);
  }
}

7) リポジトリの書き方(DBアクセス層)

Repository の役割:
DBアクセス(SQLやEloquent)を “業務ロジックから隠す” ための層です。
Service は「DBが何であれ同じ」ように書けるのが理想です。

7-1. インターフェース(契約)

<?php

namespace App\Repositories;

use App\Dto\OrderDto;

interface OrderRepository
{
  /** @return OrderDto[] */
  public function findAll(): array;

  public function findById(int $orderId): ?OrderDto;
}

7-2. 実装(例:DBを使わない簡易版 / 学習用)

<?php

namespace App\Repositories;

use App\Dto\OrderDto;

class InMemoryOrderRepository implements OrderRepository
{
  /** @var OrderDto[] */
  private array $data;

  public function __construct()
  {
    // 学習用:DBの代わりに固定データ
    $this->data = [
      new OrderDto(orderId: 1, customerName: 'Mao', subtotalYen: 1000),
      new OrderDto(orderId: 2, customerName: 'Ken', subtotalYen: 2500),
    ];
  }

  public function findAll(): array
  {
    return $this->data;
  }

  public function findById(int $orderId): ?OrderDto
  {
    foreach ($this->data as $dto) {
      if ($dto->orderId === $orderId) return $dto;
    }
    return null;
  }
}
実務では Eloquent / Query Builder で DB から取得する実装に差し替えます。
ただし学習段階では、まず InMemory で流れを掴むと理解が速いです。

8) ビジネスロジック(Service)の書き方

Service の役割:
業務ルール(計算・判定・制約)をまとめる場所。
Controller に業務ロジックを書かないのが、読みやすさのコツです。
<?php

namespace App\Services;

use App\Repositories\OrderRepository;
use App\Dto\OrderDto;

class OrderService
{
  public function __construct(private OrderRepository $repo) {}

  /** @return OrderDto[] */
  public function listOrders(): array
  {
    // ここは業務ルールが増える場所
    return $this->repo->findAll();
  }

  public function getOrder(int $orderId): OrderDto
  {
    $order = $this->repo->findById($orderId);

    // 取得できなければ業務エラー
    if ($order === null) {
      throw new \RuntimeException("Order not found: " . $orderId);
    }
    return $order;
  }
}
実務のコツ:
  • Service は「入力 → 出力」が分かるように型を付ける
  • Repository は “取得と保存” に寄せる(計算はService)
  • Controller は “HTTP” に寄せる(業務判断はService)

9) ビュー関数(あれば)

Laravel では「ビュー関数」として特別な仕組みがあるわけではなく、
主に view(...) ヘルパー(関数)で Blade テンプレートを返します。
// Controller や Route のクロージャから使える
return view('orders.index', ['orders' => $orders]);

10) HTMLテンプレート(Blade)の書き方と、処理する関数の書き方

Blade は Laravel のテンプレートエンジンです。
resources/views 配下に .blade.php で作ります。

10-1. テンプレート例:resources/views/orders/index.blade.php

<!-- resources/views/orders/index.blade.php -->

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>注文一覧</title>
</head>
<body>

  <h1>注文一覧</h1>

  <ul>
    @foreach($orders as $o)
      <li>
        注文ID: {{ $o->orderId }}
        / 顧客: {{ $o->customerName }}
        / 小計: {{ number_format($o->subtotalYen) }} 円
        <a href="/orders/{{ $o->orderId }}">詳細</a>
      </li>
    @endforeach
  </ul>

</body>
</html>

10-2. このテンプレートを処理する関数(Controller)

// app/Http/Controllers/OrderController.php(抜粋)

public function index()
{
  // 1) サービスからデータ取得(業務ロジックはService)
  $orders = $this->orderService->listOrders();

  // 2) テンプレートに値を渡して返す
  return view('orders.index', [
    'orders' => $orders,
  ]);
}
注意:
Blade テンプレートに “業務判断” を書き始めると、後から読めなくなります。
「if の分岐が多い」「計算が多い」なら、Service 側で “表示用の値” を作って渡す方が安全です。

10-3. 画面共通レイアウト(layout)

<!-- resources/views/layouts/app.blade.php -->

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>@yield('title')</title>
</head>
<body>
  <header>共通ヘッダー</header>

  <main>
    @yield('content')
  </main>

  <footer>共通フッター</footer>
</body>
</html>
<!-- resources/views/orders/show.blade.php -->

@extends('layouts.app')

@section('title', '注文詳細')

@section('content')
  <h1>注文詳細</h1>

  <div>注文ID: {{ $order->orderId }}</div>
  <div>顧客名: {{ $order->customerName }}</div>
  <div>小計: {{ number_format($order->subtotalYen) }} 円</div>
@endsection

Blade テンプレート徹底解説(Laravel)

Blade(ブレード)は Laravel 専用の HTMLテンプレートエンジンです。
単なるHTMLに見えますが、
・変数表示 ・条件分岐 ・ループ ・レイアウト継承 ・フォーム部品
を安全かつ読みやすく書くための仕組みが用意されています。

1) Bladeの基本思想(なぜBladeを使うのか)

Blade に 業務ロジック(計算・判定)を書き始めると破綻します。
Blade は「表示するだけ」に徹するのが鉄則です。

2) 変数の表示(超重要)

{{ $name }}
// Controller 側
return view('sample', [
  'name' => 'Mao',
]);
<!-- Blade 側 -->
<p>名前:{{ $name }}</p>

3) if / 条件分岐

@if($age >= 20)
  <p>成人</p>
@elseif($age >= 18)
  <p>18歳以上</p>
@else
  <p>未成年</p>
@endif
条件判断そのもの(年齢計算など)は Controller / Service 側で済ませ、
Blade では「結果の分岐」だけを書くのが理想です。

4) ループ(テーブル・リスト表示)

4-1. 配列・Collectionのループ

@foreach($orders as $order)
  <div>
    注文ID:{{ $order->orderId }}
    金額:{{ number_format($order->subtotalYen) }} 円
  </div>
@endforeach

4-2. テーブル表示(よく使う)

<table>
  <thead>
    <tr>
      <th>注文ID</th>
      <th>顧客名</th>
      <th>小計</th>
    </tr>
  </thead>

  <tbody>
    @foreach($orders as $order)
      <tr>
        <td>{{ $order->orderId }}</td>
        <td>{{ $order->customerName }}</td>
        <td>{{ number_format($order->subtotalYen) }} 円</td>
      </tr>
    @endforeach
  </tbody>
</table>
@foreach は PHP の foreach とほぼ同じですが、
HTMLの構造が壊れにくいのが利点です。

5) フォーム部品(input / radio / checkbox)

Blade はフォームを 自動生成しません
基本は 普通のHTML を書きます。

5-1. text input

<input
  type="text"
  name="customer_name"
  value="{{ old('customer_name', $customerName ?? '') }}"
>

5-2. radio ボタン

<label>
  <input
    type="radio"
    name="status"
    value="NEW"
    {{ old('status', $status ?? '') === 'NEW' ? 'checked' : '' }}
  >
  新規
</label>

<label>
  <input
    type="radio"
    name="status"
    value="PAID"
    {{ old('status', $status ?? '') === 'PAID' ? 'checked' : '' }}
  >
  支払済
</label>
Blade は「checked を自動で付けてくれない」ため、
条件式で制御します。

6) @yield / @section / @extends(レイアウト)

6-1. 親レイアウト(layouts/app.blade.php)

<!doctype html>
<html>
<head>
  <title>@yield('title')</title>
</head>
<body>
  <header>共通ヘッダー</header>

  <main>
    @yield('content')
  </main>

  <footer>共通フッター</footer>
</body>
</html>

@yield とは?

6-2. 子テンプレート

@extends('layouts.app')

@section('title', '注文一覧')

@section('content')
  <h1>注文一覧</h1>
@endsection

7) form action は必要?

答え:必要です。
Blade は HTML を生成するだけなので、
<form action="..." method="post"> は普通に書きます。
<form action="/orders" method="post">
  @csrf   <!-- CSRF対策(必須) -->

  <input type="text" name="customer_name">
  <button type="submit">送信</button>
</form>

8) Blade を処理する関数(Controller)※超重要

public function index()
{
  // 1. 業務ロジック層(Service)からデータ取得
  $orders = $this->orderService->listOrders();

  // 2. Blade テンプレートを指定し、変数を渡す
  return view('orders.index', [
    // Blade 側で $orders として使える
    'orders' => $orders,
  ]);
}
Controller の役割:
  • 入力(Request)を受け取る
  • Service を呼ぶ
  • Blade に渡すデータを整える

9) よくある失敗と回避策

Blade は「表示専門」。
業務判断は Service、
表示判断だけ Blade に置くと、コードは長く生きます。

Blade を処理する Controller の書き方(関数定義から丁寧に)

このセクションでは、
「Controllerとは何か」→「関数はどう定義するか」→「Bladeにどう値を渡すか」
1行ずつコメント付き で説明します。

1) Controller とは何をするクラスか

Controller は HTTPリクエストの入口です。

Blade を直接呼ぶのではなく、
必ず Controller のメソッドを経由します。

2) Controller クラスの完全な例

<?php

// このクラスが属する名前空間(Laravelの決まり)
namespace App\Http\Controllers;

// Blade を返すために使うヘルパー関数 view() は自動で使える
// Service などを使う場合は use で読み込む
use Illuminate\Http\Request;

// Controller クラスの定義
// すべての Controller は Controller 基底クラスを継承する
class SampleController extends Controller
{
    /**
     * 一覧画面を表示する関数
     *
     * ・public : ルーティングから呼ばれるため public
     * ・index  : 一覧表示でよく使われる名前(慣習)
     * ・戻り値 : Blade(View) を返す
     */
    public function index()
    {
        // --------------------------------------
        // 1. Blade に渡したいデータを準備する
        // --------------------------------------

        // 画面に表示したい名前(例)
        // 実務では Service や Repository から取得する
        $name = 'Mao';

        // --------------------------------------
        // 2. Blade テンプレートを指定して返す
        // --------------------------------------

        // view('sample') は
        // resources/views/sample.blade.php を意味する
        return view(
            'sample',   // テンプレート名
            [
                // Blade 側では $name という変数で使える
                'name' => $name,
            ]
        );
    }
}

3) 上のコードを1行ずつ噛み砕く

説明
class SampleController extends Controller Controller クラスを定義。
Laravelでは Controller を必ず継承する。
public function index() URL から呼ばれる関数。
public でないとルーティングから呼べない。
$name = 'Mao'; Blade に表示するためのデータを準備。
この時点ではただの PHP 変数。
return view('sample', [...]) Blade テンプレートを指定して返す。
ここで HTTP レスポンスが確定する。
'name' => $name Blade 側に渡す変数の定義。
左が変数名、右が値。

4) Blade テンプレート側(対応するファイル)

上の Controller は、次の Blade を処理します。

<!-- resources/views/sample.blade.php -->

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>サンプル</title>
</head>
<body>

  <h1>Blade サンプル</h1>

  <p>
    名前:{{ $name }}
  </p>

</body>
</html>
{{ $name }} は、
Controller の 'name' => $name で渡された値を表示します。

5) ルーティングとの関係(補足)

この Controller の index() 関数は、
ルーティングで次のように指定されます。

<?php
// routes/web.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SampleController;

// URL "/" にアクセスされたら SampleController@index を呼ぶ
Route::get('/', [SampleController::class, 'index']);
流れまとめ:
ブラウザ → URL → Route → Controller の関数 → Blade → HTML

6) 初心者がつまずきやすいポイント

Blade は「表示」だけ。
Controller は「橋渡し」。
業務処理は Service。
この役割分担を守ると、必ず読みやすくなります。

Bladeディレクティブ一覧と詳細解説(@extends / @yield など)

このセクションでは、Bladeで必ず出てくる基本ディレクティブ
「一覧 → 役割 → サンプル → どう処理されるか」
の順で、初心者向けに丁寧に説明します。

1) Bladeディレクティブとは?

Bladeディレクティブとは、
HTMLの中に書ける「Laravel専用の命令」です。

Blade は最終的に 普通の PHP ファイル に変換され、
HTMLとしてブラウザに返されます。

2) @extends(レイアウトを継承する)

@extends は、
「このテンプレートは、どの共通レイアウトを使うか」を宣言します。

@extends('layouts.app')
layouts.app は、
resources/views/layouts/app.blade.php を意味します。

親テンプレート(レイアウト)

<!-- resources/views/layouts/app.blade.php -->

<!doctype html>
<html lang="ja">
<head>
  <title>@yield('title')</title>
</head>
<body>

  <header>共通ヘッダー</header>

  <main>
    @yield('content')
  </main>

  <footer>共通フッター</footer>

</body>
</html>

3) @yield(差し込み口を作る)

@yield は、
「子テンプレートが内容を差し込む場所」を定義します。

@yield('content')
項目 意味
content セクション名(子と親をつなぐキー)
@yield の役割:
「ここに、子テンプレートの @section('content') を差し込む」

4) @section(中身を書く)

@section は、
@yield に対応する中身を定義します。

@section('content')
  <h1>注文一覧</h1>
@endsection

完全な子テンプレート例

<!-- resources/views/orders/index.blade.php -->

@extends('layouts.app')

@section('title', '注文一覧')

@section('content')
  <h1>注文一覧</h1>
  <p>ここに一覧を表示</p>
@endsection
対応関係:
  • 親:@yield('title')
  • 子:@section('title', '注文一覧')
  • 親:@yield('content')
  • 子:@section('content')

5) @include(部品テンプレートを読み込む)

@include は、
ヘッダー・フッター・部品を読み込むための命令です。

@include('parts.flash-message')

部品テンプレート

<!-- resources/views/parts/flash-message.blade.php -->

@if(session('message'))
  <div class="ok">
    {{ session('message') }}
  </div>
@endif
命名ルール:
部品は parts/components/ 配下に置くのが一般的です。

6) @if / @foreach(制御構文)

@if

@if($isAdmin)
  <p>管理者です</p>
@else
  <p>一般ユーザーです</p>
@endif

@foreach

@foreach($orders as $order)
  <li>注文ID:{{ $order->orderId }}</li>
@endforeach
注意:
ここで複雑な条件や計算を書き始めたら、
それは Service に移すサインです。

7) @csrf(フォームで必須)

Blade では、フォーム送信時に CSRF対策が必須です。

<form method="post">
  @csrf
  <input type="text" name="name">
</form>
@csrf を書き忘れると「なぜエラーになるのか」

結論:Laravel には、フォーム送信(POST/PUT/PATCH/DELETE)を守るための CSRF保護が標準で有効になっており、
「正しいトークンが付いていないリクエストは攻撃の可能性がある」として、 サーバー側で拒否するからです。

CSRF とは?
CSRF(Cross-Site Request Forgery)は、日本語では「クロスサイト・リクエスト・フォージェリ」と呼ばれ、
攻撃者が別サイトから、あなたのサイトへ勝手に“操作リクエスト”を送らせる攻撃です。
例:ログイン中のユーザーに対して、本人が押していないのに「注文確定」「住所変更」などのPOSTを発生させる。

Laravel がやっていること(仕組み)
1) Laravel は、ユーザーのセッション(ログイン状態)ごとに、秘密の文字列(CSRFトークン)を持ちます。
2) フォーム送信のとき、リクエストにそのトークンが含まれているか確認します。
3) トークンが無い/一致しない場合は「外部から勝手に送られた可能性が高い」と判断して拒否します。

拒否されるとどうなる?
多くの環境で、POSTすると 419(Page Expired)などのエラーになります。
※表示内容は環境設定によって多少異なりますが、原因は「CSRFトークン不一致」です。


では @csrf は何をしている?
@csrf は、Blade がHTMLを生成するときに、フォームの中へ次のような hidden input(隠し入力)を自動で埋め込みます。

<input type="hidden" name="_token" value="(長いランダム文字列)">
hidden input とは?
<input> はフォームの部品ですが、
type="hidden" にすると 画面には表示されない入力欄になります。

hidden input の用途:
  • ユーザーに見せたくないが、フォーム送信で一緒に送りたい値を入れる
  • 例:CSRFトークン、内部ID、画面遷移元、検索条件の保持など
注意点(重要):
hidden は「見えない」だけで、ブラウザの開発者ツールで誰でも見られます
つまり hidden は 秘密を守る仕組みではありません
「ユーザーが編集できない前提の値」は hidden だけに頼らず、サーバー側で必ず検証します。

まとめ:
@csrf は「CSRFトークンを hidden input としてフォームに埋め込む」命令です。
それにより Laravel が「このPOSTは本当に自分のサイトのフォームから送られた」と確認でき、
攻撃を防げるため、書き忘れるとサーバーが拒否してエラーになります。

8) Bladeディレクティブ一覧(初心者向け・意味が分かる版)

Bladeディレクティブは、
「HTMLの中で、Laravelに指示を出すための命令文」です。
下の表では「何をすると何が起きるのか」を中心に説明しています。
書くもの これを書くと何が起きる? どこに書く?(場所) なぜ必要?
@extends('layouts.app') 「この画面は 共通レイアウト を使います」とLaravelに伝える。
HTMLの <head><header> を自分で書かなくてよくなる。
画面用 Blade ファイルの 一番上 全ページで同じヘッダー・フッターを使うため。
デザイン修正を1か所で済ませられる。
@yield('content') 「ここに、子画面の中身を差し込んでください」という空き場所を作る。 共通レイアウト(親テンプレート) 画面ごとに内容は違うが、外枠は共通にしたいから。
@section('content') 「この部分が @yield('content') に入る中身です」と指定する。 各画面の Blade ファイル 親テンプレートのどこに表示されるかを明確にするため。
@include('parts.header') 別ファイルの Blade を その場に貼り付ける どの Blade ファイルでも可 ヘッダーやメッセージ表示などを部品化して再利用するため。
@if(...) 条件が true のときだけ HTML を表示する。 Blade テンプレート内 「ログイン中だけ表示」「データがある時だけ表示」などを制御するため。
@foreach(...) 配列や Collection を 1件ずつ取り出して、
同じ HTML を繰り返し表示する。
Blade テンプレート内 一覧画面・テーブル表示を作るため。
@csrf フォームの中に 見えないセキュリティ用データを自動で追加する。 <form> タグの中 勝手に送られた危険な POST リクエストを防ぐため。
Blade は「HTMLに少しだけ命令を足したもの」です。
@extends → @yield → @section の関係が分かれば、
Blade の画面構造はほぼ理解できています。

PHP / Laravel に「アノテーション」はあるのか?

結論から言うと、
PHP には Java や C# のような「伝統的アノテーション」は長らく存在しませんでした。
その代わりに使われてきた仕組みと、
現在(PHP 8以降)の正式な代替手段があります。

1) まず結論(時代順に)

時代 書き方 位置づけ
〜 PHP 7.x DocBlock コメント 擬似アノテーション
PHP 8.0〜 #[Attribute] 正式な言語機能
Laravel 実務 設定・命名・コード規約 アノテーションに頼らない設計

2) PHP 7まで:DocBlock(コメント)による擬似アノテーション

PHP では長い間、
コメントの中に特別な書式を書くことで、
アノテーションのような役割を実現していました。

/**
 * @Route("/orders", methods={"GET"})
 * @param int $orderId
 * @return OrderDto
 */
public function show($orderId)
{
    ...
}
これは PHPの文法ではなく、ただのコメントです。
フレームワークやライブラリが 文字列として解析して使っていました。

どこで使われていた?

欠点:
  • 書き間違えても PHP はエラーにしない
  • IDE 以外では安全性が低い
  • 実行時の保証がない

3) PHP 8:Attribute(正式なアノテーション)

PHP 8 から、ついに
言語レベルでのアノテーション機能が導入されました。
PHPではこれを Attribute(属性)と呼びます。

3-1. Attribute の定義

<?php

#[Attribute]
class Route
{
    public function __construct(
        public string $path,
        public array $methods = ['GET']
    ) {}
}

3-2. Attribute の使用例

class OrderController
{
    #[Route('/orders', methods: ['GET'])]
    public function index()
    {
        // ...
    }
}
これは コメントではなく PHP の構文です。
文法として解釈され、Reflection で安全に取得できます。

4) Laravel は Attribute(アノテーション)を使うのか?

基本的に:使いません。

Laravel は設計思想として、
「コードを見れば挙動が分かる」ことを重視しています。

Laravel の代表例

// routes/web.php
Route::get('/orders', [OrderController::class, 'index']);
Java/Spring のように、
「Controllerを開かないとルートが分からない」
という状態を避けています。

5) では、Laravel は「何で代替しているのか?」

役割 Laravelでのやり方
ルーティング routes/web.php
DI / 設定 コンストラクタ型指定
バリデーション FormRequest クラス
ORM定義 Eloquentの命名規約
認可 Policy / Gate
Laravel は 「設定ファイル・命名・クラス構造」で意味を表現し、
アノテーションに依存しない設計をしています。

6) それでも Attribute を使うケースは?

Laravelアプリの通常開発で Attribute を多用すると、
「設定が分散して読みにくい」状態になりがちです。

7) 業務開発向けまとめ(重要)

  • PHP 7まで:アノテーションは「コメントの工夫」
  • PHP 8以降:Attribute という正式機能がある
  • Laravel:基本は Attribute を使わない
  • Laravel流:設定と構造で意味を表す

Laravel では、
「このクラスが何者か」「どこから呼ばれるか」を、
アノテーションではなく、ファイルの置き場所と設定ファイルで表現します。

例:

つまり Laravel では、
「特別な印(アノテーション)を探さなくても、見える場所にそのまま書いてある」
という設計をしています。

非同期処理とは何か(PHP / Laravel)

このセクションでは、
「非同期処理とは何か」→「PHPでどう実現するか」→「Laravelではどう書くか」
を、例外処理(try / catch / finally / throw)込みで丁寧に説明します。
Java / C# / JavaScript 経験者との違いも明確にします。

1) そもそも「非同期処理」とは?

非同期処理とは、
「時間のかかる処理を、今の処理の流れから切り離して実行する」ことです。

同期処理(普通のPHP)

// 同期処理:この関数は処理が終わるまで止まる
sendMail();
saveLog();
echo "完了";

上のコードでは、
sendMail() が終わるまで saveLog()echo も実行されません。

非同期処理の考え方

// 非同期の考え方(イメージ)
dispatchMailJob();   // メール送信は後で
saveLog();           // 先に終わる処理
echo "完了";         // すぐ画面に返す

「ユーザーを待たせない」ために、
重い処理を 後回し にします。

2) PHPは非同期が苦手?(重要な前提)

重要:
PHP には JavaScript の async / await のような
「言語レベルの非同期構文」はありません。

PHP は基本的に:

そのため PHP での非同期処理とは、実際には:

「処理を別プロセス(キュー)に投げる」

3) Laravel における非同期処理の正体(Queue / Job)

Laravel では、非同期処理は Queue(キュー)Job で実現します。

構成イメージ

4) 非同期処理の完全な例(try / catch / finally / throw 含む)

4-1. Job クラス(非同期で実行される)

<?php

namespace App\Jobs;

use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

// Job をキューで実行するために ShouldQueue を実装
class SendMailJob implements ShouldQueue
{
    use InteractsWithQueue, Queueable, SerializesModels;

    private string $email;

    public function __construct(string $email)
    {
        // キューに積まれるデータ
        $this->email = $email;
    }

    /**
     * 非同期で実行される処理本体
     */
    public function handle(): void
    {
        try {
            // ---- 実際の処理(例:メール送信) ----
            if ($this->email === '') {
                // 業務的におかしい場合は例外を投げる
                throw new Exception('メールアドレスが空です');
            }

            // 時間がかかる処理の例
            // Mail::to($this->email)->send(...);

        } catch (Exception $e) {
            // ---- エラー時の処理 ----

            // ログ出力など
            // logger($e->getMessage());

            // 例外を再throwすると、このJobは「失敗」として扱われる
            throw $e;

        } finally {
            // ---- 成功・失敗に関係なく必ず通る ----
            // 一時ファイル削除、ログ出力など
        }
    }
}
ポイント:
  • try:正常系の処理
  • catch:例外が起きたときの処理
  • throw:例外を呼び出し元(Queue)に伝える
  • finally:成功・失敗に関係なく実行

5) Controller 側(非同期処理を「投げる」)

<?php

namespace App\Http\Controllers;

use App\Jobs\SendMailJob;
use Illuminate\Http\Request;

class SampleController extends Controller
{
    public function send(Request $request)
    {
        // 1. 入力値取得
        $email = $request->input('email');

        // 2. 非同期Jobをキューに積む
        SendMailJob::dispatch($email);

        // 3. すぐにレスポンスを返す(Jobの完了は待たない)
        return response()->json([
            'message' => 'メール送信を受け付けました'
        ]);
    }
}
Controller は「成功したかどうか」を待ちません。
「受け付けた」時点で処理は完了です。

6) try / catch / throw の役割を整理

構文 意味 どこで使う?
try 正常に動くはずの処理 Job / Service
catch 失敗時の処理 ログ・復旧
throw 例外を上に伝える 失敗として扱わせたい時
finally 必ず通る後処理 クリーンアップ

7) 非同期処理で初心者が必ずハマる点

非同期処理は「その場で結果が返らない」ことを
前提に設計する必要があります。

8) 業務向けまとめ(重要)

  • PHPの非同期 = キューに投げる
  • Laravelでは Job / Queue を使う
  • try/catch/throw で失敗を明示する
  • Controller は「受け付けたら終わり」

JavaScript の async/await や、
Java/C# の Future/Task とは発想が違う点が、
PHP/Laravel の最大の特徴です。

Laravel 非同期(Queue/Job)実務編:設定・失敗再実行・トランザクション・テスト

ここでは、Laravel の非同期処理(Queue/Job)を「実際に運用できるレベル」まで落として説明します。
・Queue の設定(sync / database / redis)
・失敗した Job の再実行(retry / failed_jobs)
・トランザクションと非同期の関係
・非同期処理のテスト方法
を、初心者向けにコードを省略せずに書きます。

1) Queue の設定(sync / database / redis)

1-1. まず「Queue接続(connection)」とは?

Laravel の Queue は、Job を「どこに溜めるか(保存するか)」を connection で切り替えます。

connection 意味 用途 注意点
sync 即実行(非同期に見せかけて同期) 学習・開発・テストで便利 ユーザーは待つ(重い処理だと遅い)
database DB のテーブルに Job を貯める 小〜中規模、導入が簡単 DB負荷、ワーカー運用が必要
redis Redis に Job を貯める 高速・大規模向け Redis サーバが必要

1-2. 設定の中心は .envQUEUE_CONNECTION

# .env

# 開発中はまず sync が分かりやすい
QUEUE_CONNECTION=sync
覚え方:
QUEUE_CONNECTION を変えるだけで、Job の「貯め方・動き方」が変わります。

1-3. sync(即実行)の動き

<?php

use App\Jobs\SendMailJob;

class SampleController extends Controller
{
    public function send(Request $request)
    {
        // 1) キューに積む指示を出す(見た目は非同期)
        SendMailJob::dispatch($request->input('email'));

        // 2) ただし sync の場合、この dispatch の時点で実行される
        //    =ユーザーは処理が終わるまで待つ

        return response()->json(['message' => 'OK']);
    }
}
注意:
sync は「非同期ではありません」。
ただの “書き方だけ Job” なので、重い処理は本番で使わないこと。

1-4. database(DBキュー)を使う手順

やることは 3つ:
① テーブル作成(migrate)
② .env の connection を database にする
③ ワーカー(queue:work)を起動する
① キューテーブルを作る
# コマンド(ターミナル)
php artisan queue:table
php artisan migrate
② failed_jobs テーブルも作る(失敗ログ用)
# コマンド(ターミナル)
php artisan queue:failed-table
php artisan migrate
③ .env を database にする
# .env
QUEUE_CONNECTION=database
④ ワーカーを起動する(これが無いと動かない)
# コマンド(ターミナル)
php artisan queue:work
超重要:
database / redis は、ワーカーが動いて初めて Job が実行されます。
dispatch は「キューに積むだけ」です。

1-5. redis(Redisキュー)を使う手順

Redis を使う場合も「.env を変えてワーカー起動」は同じです。
ただし Redis サーバと PHP の Redis クライアント設定が必要です。
# .env(例)
QUEUE_CONNECTION=redis

# Redis接続(例:ローカル)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# ワーカー起動
php artisan queue:work redis

2) 失敗した Job の再実行(retry / failed_jobs)

2-1. 「失敗」とは何か?

Job の handle() の中で例外が投げられ、処理が正常終了しなかった場合、Laravel は Job を失敗として扱います。

<?php

class SendMailJob implements ShouldQueue
{
    public function handle(): void
    {
        // 例:何かがおかしいので例外を投げる
        throw new \RuntimeException("送信に失敗しました");
    }
}

2-2. failed_jobs に記録するには?

先ほど作った failed_jobs テーブルがあると、失敗Jobが記録されます。

2-3. 失敗一覧を見る

# コマンド(ターミナル)
php artisan queue:failed

2-4. 失敗した Job を再実行する(retry)

# 失敗一覧に出る「ID」を指定して再実行
php artisan queue:retry 5
# 全部まとめて再実行
php artisan queue:retry all

2-5. 失敗ログを消す

# あるIDだけ消す
php artisan queue:forget 5

# 全部消す
php artisan queue:flush
注意:
retry は「もう一度やれば成功する可能性がある」時だけ使います。
根本原因(設定ミス・データ不正)が残っていると、また失敗します。

2-6. リトライ回数・待ち時間(Job側で設定)

<?php

class SendMailJob implements ShouldQueue
{
    // 最大リトライ回数(例:3回)
    public int $tries = 3;

    // タイムアウト秒(例:30秒)
    public int $timeout = 30;

    // リトライ間隔(秒)を配列で指定(例:10秒後、30秒後、60秒後)
    public function backoff(): array
    {
        return [10, 30, 60];
    }

    public function handle(): void
    {
        // ここで例外が起きると tries/backoff のルールで再実行される
    }
}

3) トランザクションと非同期処理の関係(超重要)

ここが事故ポイントNo.1です。
「DBに保存する処理」と「Jobをdispatchする処理」が同じリクエスト内にあるとき、
dispatchのタイミングを間違えると、Job が “まだ存在しないデータ” を読みに行って失敗します。

3-1. 典型的な事故例

<?php

use Illuminate\Support\Facades\DB;
use App\Jobs\SendMailJob;

class OrderService
{
    public function createOrderAndNotify(array $input): void
    {
        DB::transaction(function () use ($input) {

            // 1) 注文をDBに保存(まだコミットされていない)
            $order = Order::create([...]);

            // 2) ここで Job を投げる(危険)
            //    ワーカーが先に動くと、まだDBに確定してない注文を読みに行く可能性がある
            SendMailJob::dispatch($order->id);
        });
    }
}

3-2. 正しい考え方:「コミット後に dispatch する」

原則:
DBトランザクションの中で作ったデータを Job が使うなら、
トランザクションが成功してコミットされた後に Job を投げる

3-3. 対策①:afterCommit を使う(Laravelの推奨)

<?php

use Illuminate\Support\Facades\DB;
use App\Jobs\SendMailJob;

class OrderService
{
    public function createOrderAndNotify(array $input): void
    {
        DB::transaction(function () use ($input) {

            // 1) DB保存(トランザクション中)
            $order = Order::create([...]);

            // 2) コミット後に実行されるように予約する
            DB::afterCommit(function () use ($order) {
                SendMailJob::dispatch($order->id);
            });
        });
    }
}

3-4. 対策②:Job側で afterCommit を指定する

<?php

class SendMailJob implements ShouldQueue
{
    // これを true にすると「トランザクションがコミットされた後」に dispatch される
    public bool $afterCommit = true;

    public function __construct(public int $orderId) {}

    public function handle(): void
    {
        // コミット後なので、注文を安全に読める
        $order = Order::findOrFail($this->orderId);
    }
}
まとめ:
Job が DB の結果に依存するなら、commit後に走る保証が必要です。

4) 非同期処理のテスト方法(初心者向け・確実に動く)

テストでは、実際にワーカーを起動して “本当の非同期” を回すより、
Laravel の Fake(偽物) を使って「投げたかどうか」を検証するのが基本です。

4-1. Queue::fake() で「dispatchされたか」をテストする

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Support\Facades\Queue;
use App\Jobs\SendMailJob;

class SampleQueueTest extends TestCase
{
    public function test_job_is_dispatched(): void
    {
        // 1) Queue を fake にする(実際には実行されない)
        Queue::fake();

        // 2) 何かの処理を呼ぶ(ControllerでもServiceでもOK)
        //    例:ジョブを投げる処理
        SendMailJob::dispatch('test@example.com');

        // 3) 「投げたこと」を検証
        Queue::assertPushed(SendMailJob::class);

        // 4) さらに中身(引数)も検証したい場合
        Queue::assertPushed(SendMailJob::class, function (SendMailJob $job) {
            // Job の中身が期待通りか
            return $job->email === 'test@example.com';
        });
    }
}
このテストの意味:
非同期の “実行結果” ではなく、
「投げるべき場所で投げているか」を確認します。
これだけで多くのバグを防げます。

4-2. Bus::fake() で Job/Command をまとめて検証する

<?php

use Illuminate\Support\Facades\Bus;
use App\Jobs\SendMailJob;

class SampleBusTest extends TestCase
{
    public function test_bus_dispatch(): void
    {
        // 1) Bus を fake にする
        Bus::fake();

        // 2) dispatch
        SendMailJob::dispatch('test@example.com');

        // 3) dispatch されたか確認
        Bus::assertDispatched(SendMailJob::class);
    }
}

4-3. 「Job の中身(handle)」をテストしたい場合(同期実行でテストする)

非同期の “キュー” を絡めるとテストが複雑になります。
Job の処理ロジックは、できれば Serviceに寄せて、Serviceを単体テストするのが定石です。
<?php

use Tests\TestCase;
use App\Jobs\SendMailJob;

class JobHandleTest extends TestCase
{
    public function test_job_handle_runs(): void
    {
        // 1) Job を作る
        $job = new SendMailJob('test@example.com');

        // 2) handle を直接呼ぶ(=同期実行で中身をテスト)
        $job->handle();

        // 3) 期待する結果を assert(ログ、DB、何かの更新など)
        $this->assertTrue(true);
    }
}
注意:
handle の中で外部APIやメール送信を直接行うとテストが難しくなります。
その場合は Mail::fake() や Http::fake() を使う設計にします(別セクションで深掘り可能)。

4-4. テスト時は QUEUE_CONNECTION=sync にする手もある

# .env.testing(テスト専用のenv)
QUEUE_CONNECTION=sync
意味:
dispatch した瞬間に実行されるので、テストが単純になります。
ただし「非同期に投げたか」の検証は Queue::fake の方が明確です。

5) 最低限の運用メモ(本番で必要になること)

ここまで理解できれば、Laravel の非同期処理は「作れる」だけでなく「運用できる」状態です。

HTTP通信(外部API呼び出し)— Laravel / PHP 実務向け解説

この章では、
「外部サーバーと HTTP 通信するとはどういうことか」を前提から説明し、
Laravel で最も一般的な HTTP Client(fetch 相当) を使って
GET / POST をどう書くのかを、例外処理込みで丁寧に解説します。

1) そもそも「外部APIを呼ぶ」とは?

外部API呼び出しとは、
自分の Laravel アプリが「HTTPクライアント」になり、別のサーバーにリクエストを送ることです。

Laravelアプリ
   ↓ HTTPリクエスト(GET / POST)
外部APIサーバー
   ↓ HTTPレスポンス(JSONなど)
Laravelアプリ

ブラウザの fetch() と役割は同じですが、
実行場所が「サーバー側(PHP)」である点が違います。

2) Laravel で使う基本クラス:Http ファサード

Laravel では、外部HTTP通信に
Illuminate\Support\Facades\Http を使うのが標準です。
use Illuminate\Support\Facades\Http;

これは内部的に Guzzle(HTTPクライアント)を使っていますが、
Guzzleを直接触る必要はありません。

3) GET リクエスト(データ取得)

3-1. GET の意味

GET は、
「サーバーにデータを問い合わせる」ための HTTP メソッドです。
データは URL の クエリパラメータとして送られます。

3-2. 最小構成の GET サンプル

<?php

use Illuminate\Support\Facades\Http;

class ExternalApiService
{
    /**
     * 外部APIからユーザー一覧を取得する
     */
    public function fetchUsers(): array
    {
        try {
            // ① 外部APIへ GET リクエストを送信
            $response = Http::get(
                'https://api.example.com/users',
                [
                    // ② クエリパラメータ(?page=1&limit=10)
                    'page'  => 1,
                    'limit' => 10,
                ]
            );

            // ③ HTTPステータスが 200 系か確認
            if ($response->failed()) {
                throw new \RuntimeException('外部APIの呼び出しに失敗しました');
            }

            // ④ JSONレスポンスを配列に変換して返す
            return $response->json();

        } catch (\Throwable $e) {
            // ⑤ 通信エラー・例外を捕捉
            // logger($e->getMessage());
            throw $e;

        } finally {
            // ⑥ 後処理(必要なら)
        }
    }
}

3-3. GET のポイント整理

項目 意味
URL アクセス先のAPIエンドポイント
第2引数 クエリパラメータ(URLに付く)
$response->json() JSON → PHP配列
GET用途 取得・検索・参照のみ(副作用なし)

4) POST リクエスト(データ送信)

4-1. POST の意味

POST は、
「サーバーにデータを送って、何かを作成・更新してもらう」ためのメソッドです。
データは リクエストボディに含まれます。

4-2. JSON を送る POST の基本例

<?php

use Illuminate\Support\Facades\Http;

class ExternalApiService
{
    /**
     * 外部APIにユーザーを登録する
     */
    public function createUser(array $input): array
    {
        try {
            // ① POSTリクエストを送信(JSON)
            $response = Http::post(
                'https://api.example.com/users',
                [
                    'name'  => $input['name'],
                    'email' => $input['email'],
                ]
            );

            // ② 失敗判定
            if ($response->failed()) {
                throw new \RuntimeException('ユーザー登録APIが失敗しました');
            }

            // ③ レスポンスを配列で取得
            return $response->json();

        } catch (\Throwable $e) {
            throw $e;
        }
    }
}

4-3. POST のデータの流れ

Laravel
  ↓ POST + JSONボディ
外部API
  ↓ 処理(登録など)
  ↓ JSONレスポンス
Laravel

5) ヘッダー・認証付きリクエスト(実務で必須)

5-1. APIトークンを付ける例

<?php

$response = Http::withHeaders([
    'Authorization' => 'Bearer ' . $apiToken,
    'Accept'        => 'application/json',
])->get('https://api.example.com/profile');
よくあるヘッダー:
  • Authorization:APIキー / Bearerトークン
  • Accept:期待するレスポンス形式
  • Content-Type:送信データ形式

6) タイムアウト・例外処理(必須)

6-1. タイムアウトを設定する

Http::timeout(5)->get('https://api.example.com/users');

外部APIは 止まる・遅い が前提です。
タイムアウトを設定しないと、アプリ全体が待たされます。

6-2. HTTPエラーを例外として扱う

$response = Http::get('https://api.example.com/users')
    ->throw(); // 4xx / 5xx で例外を投げる
->throw() を使うと、
ステータスコード判定を自分で書かなくてよくなります。

7) GET と POST の違い(業務で重要)

比較 GET POST
目的 取得・検索 作成・更新
データ位置 URL(クエリ) ボディ(JSON等)
副作用 基本なし あり得る
再送安全 高い 注意が必要

8) よくある失敗と注意点

外部APIは 信用してはいけません
常に「失敗する前提」で設計します。

9) 実務での設計指針(重要)

  • HTTP通信は Service クラスにまとめる
  • GET / POST の意味を守る
  • try / catch / timeout を必ず書く
  • テストでは Http::fake() を使う

ここまで理解できれば、
「外部APIを安全に呼べる Laravel アプリ」が書ける状態です。

コールアウト:HTTPレスポンスの JSON を DTO に詰め替える(初心者向け)
外部APIから返ってくる JSON は、Laravel ではたいてい
$response->json() により 配列(array) になります。
しかし配列のままだと、キーのスペルミス型の混乱が起きやすいです。
そこで「DTO(データだけを持つクラス)」に詰め替えて、安全に扱える形にします。

1) まず前提:外部APIのJSON(例)

{
  "id": 10,
  "name": "Mao",
  "email": "mao@example.com",
  "created_at": "2026-02-16T10:00:00Z"
}

この JSON を Laravel で受け取ると、だいたい次のような配列になります。

// $response->json() の結果(PHP配列のイメージ)
[
  'id' => 10,
  'name' => 'Mao',
  'email' => 'mao@example.com',
  'created_at' => '2026-02-16T10:00:00Z',
]

2) DTO を作る(データの形を固定する)

DTO は「データを入れる入れ物」です。
ここでは “ユーザー情報” を入れる DTO を作ります。
<?php

namespace App\Dto;

final class UserDto
{
    /**
     * コンストラクタで受け取った値を、そのままプロパティに入れる
     * readonly にすると「作った後に書き換えられない」=安全
     */
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
        public readonly string $createdAt
    ) {}
}
注意:
外部APIは型が崩れて返ってくることがあります。
DTOに詰め替えるときに「型を固定」するのがポイントです。

3) JSON(配列)→ DTO に詰め替える関数を書く

詰め替え処理は「専用の関数」にすると分かりやすいです。
ここでは fromArray() という名前の静的関数にします。

<?php

namespace App\Dto;

final class UserDto
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
        public readonly string $createdAt
    ) {}

    /**
     * 配列(外部APIのJSON)から DTO を生成する
     *
     * @param array $data 例: $response->json() の結果
     * @return UserDto
     */
    public static function fromArray(array $data): UserDto
    {
        // ① 必須キーが存在するかチェック(無いと事故る)
        if (!isset($data['id'], $data['name'], $data['email'], $data['created_at'])) {
            // ② ここで例外を投げると「APIレスポンスが想定外」と分かる
            throw new \RuntimeException('外部APIのレスポンス形式が想定と違います(必須キー不足)');
        }

        // ③ 型を固定しながら DTO を作る
        return new UserDto(
            id: (int) $data['id'],                 // 数値に変換
            name: (string) $data['name'],          // 文字列に変換
            email: (string) $data['email'],
            createdAt: (string) $data['created_at']
        );
    }
}
初心者向けポイント:
  • isset(...) で「キーがあるか」を確認(無いとエラーになる)
  • (int), (string) で「型を固定」(APIの型ブレ対策)
  • 想定外の形式なら throw で止める(バグに気づける)

4) 実際に HTTP レスポンスから DTO を作る(サービス側)

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use App\Dto\UserDto;

class ExternalApiService
{
    /**
     * 外部APIからユーザーを1件取得して DTO を返す
     */
    public function fetchUser(int $userId): UserDto
    {
        try {
            // ① 外部APIへ GET リクエスト
            $response = Http::timeout(5)
                ->get("https://api.example.com/users/{$userId}");

            // ② HTTPステータスが 4xx/5xx なら例外にする(これで判定が簡単になる)
            $response->throw();

            // ③ JSONを配列として取得
            $data = $response->json();

            // ④ 配列を DTO に詰め替える(ここが本題)
            return UserDto::fromArray($data);

        } catch (\Throwable $e) {
            // ⑤ 通信失敗・形式不正などをまとめて捕捉
            // logger($e->getMessage());
            throw $e;
        }
    }
}

5) 配列のままにしないメリット(初心者に重要)

結論:
外部APIの JSON は “そのまま使わず”、一度 DTO に詰め替えると、
バグが減り、業務ロジックが読みやすくなります。

DBに対するI/O処理の全体像(SQLite3を代表例に)

ここでは「DB入出力(I/O)」を初心者向けに、
コネクション → SELECT/INSERT/UPDATE/DELETE → プリペアド → トランザクション
の順に説明します。代表DBは SQLite3 を使います。
最後に PostgreSQL / MySQL / Oracle / SQL Server の違いもまとめます。
また「SQLを書かずにアクセスする方法(ORM/Query Builder)」も紹介します。

0) まず用語(最初にこれだけ)

1) SQLite3とは?(代表例として採用する理由)

SQLite3は「サーバー不要」のDBです。
1つの ファイル(例:database.sqlite)として保存されます。
小〜中規模のアプリ、デスクトップ、学習用途に向きます。
注意:SQLiteは「同時にたくさん書き込む」用途が苦手です(書き込みはロックが起きやすい)。
Web本番でアクセスが増えるなら、PostgreSQL/MySQLなどサーバ型DBを選ぶことが多いです。

2) コネクション(接続)から始める:Laravel + SQLite

2-1. DBファイルを用意する

# プロジェクト直下で(例)
mkdir -p database
type nul > database/database.sqlite   # Windowsの例(空ファイル作成)

2-2. .env に SQLite 接続を指定する

# .env
DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
Laravelは内部で「PDO」という仕組みを使ってDBに接続します。
DB_CONNECTIONDB_DATABASE を正しく設定できれば、接続は完成です。

2-3. 接続確認の考え方

接続確認は「SELECT 1」を投げて例外が出ないか、が最も簡単です。
(Laravelなら DB::select を使います)
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\DB;

class DbHealthController extends Controller
{
    public function ping()
    {
        // 1) 例外が出なければ「接続できている」
        DB::select('select 1');

        // 2) JSONで返す(画面でもAPIでもOK)
        return response()->json(['db' => 'ok']);
    }
}

DB:: が「定義無し」で使える理由

結論から言うと、DB::
Laravel が用意している「Facade(ファサード)」だからです。

このファイルの先頭には、次の use 文があります。

use Illuminate\Support\Facades\DB;

これにより、このクラス内では

DB::select(...)

と書くだけで、
Illuminate\Support\Facades\DB クラスを指すようになります。


::(ダブルコロン)は何を意味するのか

:: は PHP の構文で、
「クラスに対して直接アクセスする」ことを意味します。

具体的には、次のような用途があります。

  • static メソッドを呼ぶ
  • クラス定数を参照する
// クラス名::メソッド名
DB::select('select 1');

見た目は「static メソッド呼び出し」に見えますが、
Laravel の Facade は少し特殊です。


DB Facade の正体(重要)

DB クラス自体が直接 DB 接続をしているわけではありません。

実際には:

  1. DB::select() が呼ばれる
  2. Facade が Laravel の DI コンテナを見る
  3. 中で管理されている Database Manager を取得する
  4. そのオブジェクトの select() メソッドを呼ぶ

つまり、概念的には次のような動きをしています。

DB::select(...)
↓
内部で app('db')->select(...)
↓
実体の DB 接続オブジェクトが処理する

なぜ new しなくていいのか

通常のクラスであれば:

$db = new DB(); // ← こんなことはできない

しかし Facade は、

  • Laravel 起動時に DI コンテナへ登録されている
  • Facade 経由で「すでに存在するインスタンス」を取得する

ため、自分で new する必要がありません。


まとめ

  • DB:: は Facade クラスなので定義無しで使える
  • :: は「クラスに対するアクセス」を意味する PHP 構文
  • 見た目は static だが、内部ではインスタンスが動いている
  • DI コンテナに登録済みのオブジェクトを間接的に呼んでいる

そのため、

DB::select('select 1');

は、
「Laravel に登録されている DB 接続を使って SQL を実行する」
という意味になります。

Laravel の Facade(ファサード)とは何か(初心者向け)

Laravel の Facade(ファサード)とは、
「Laravel が内部で用意している機能を、短い書き方で使えるようにした仕組み」 です。


まず出てくる用語の定義

  • オブジェクト
    プログラムの中で使われる「実体」。new クラス名() で作られるもの。
  • Laravel が管理しているオブジェクト
    データベース接続やログ機能など、
    「アプリ全体で共通して使うもの」を Laravel が最初から用意している。
  • Facade
    その「Laravel が管理しているオブジェクト」を、
    クラス名::メソッド() という形で呼び出すための窓口。

DB::select は何をしているのか

DB::select('select 1');

これは、

「Laravel が最初から用意しているデータベース接続を使って、SQL を実行する」

という意味です。

DB は「データベース用の Facade」で、
実際のデータベース処理は、Laravel の内部にある本物の処理クラスが行っています。


なぜ new しなくていいのか

普通のクラスであれば、

$db = new DbClass();

のように自分で作ります。

しかしデータベース接続は、

  • アプリ全体で 1つあればよい
  • 設定ファイルを読んで初期化する必要がある

ため、Laravel が起動時に作って管理しています。

そのため、開発者が new する必要はありません。

::(ダブルコロン)の意味

:: は PHP の文法で、

「クラスをnewせずに、」
クラス名を指定してメソッドを呼び出す書き方」

という意味です。

クラス名::メソッド名();

Facade はこの書き方を利用して、
「Laravel が用意している機能」を呼び出しています。


Facade のイメージ(たとえ)

Facade は、

「受付窓口」や「リモコン」

のようなものです。

  • 実際の処理は裏で行われている
  • 利用者は簡単な操作だけすればよい

だから、

DB::select('select 1');

と書くだけで、データベースを使えるのです。


まとめ(初心者向け)

  • Facade は Laravel 独自の仕組み
  • Laravel が用意した機能を簡単に使うための入口
  • DB はデータベース用の Facade
  • :: は「クラスから直接呼ぶ」書き方
  • 開発者が new する必要はない

PHP(Laravel)で使える主な Facade 一覧

Laravel には DB 以外にも、
よく使われる Facade が多数用意されています。
これらはすべて
「Laravel が内部で用意・管理している機能を、短い書き方で使うための入口」 です。


1. データベース・ストレージ系

Facade 用途 使用例
DB データベース操作(SQL / クエリ) DB::select(...)
Schema テーブル定義・変更(Migration) Schema::create(...)
Storage ファイル保存・取得 Storage::put(...)
Cache キャッシュ操作 Cache::get(...)

2. HTTP・レスポンス関連

Facade 用途 使用例
Request HTTP リクエスト情報の取得 Request::input('name')
Response HTTP レスポンス生成 Response::json(...)
Redirect リダイレクト Redirect::route(...)
URL URL 生成 URL::to(...)

3. 認証・セキュリティ関連

Facade 用途 使用例
Auth ログインユーザー取得・認証 Auth::user()
Hash パスワードのハッシュ化 Hash::make(...)
Gate 権限制御 Gate::allows(...)
Crypt 暗号化・復号 Crypt::encrypt(...)

4. ログ・デバッグ関連

Facade 用途 使用例
Log ログ出力 Log::info(...)
Debugbar デバッグ情報表示(拡張) Debugbar::info(...)

5. 設定・ユーティリティ系

Facade 用途 使用例
Config 設定値の取得 Config::get('app.name')
Lang 多言語メッセージ取得 Lang::get('messages.ok')
App アプリケーション情報取得 App::environment()

6. 重要なポイント(初心者向け)

  • Facade は Laravel が用意した「入口クラス」
  • どれも new せずに使う
  • Class::method() 形式で呼び出す
  • 実際の処理は Laravel 内部の別クラスが行う
覚え方

よく使う Laravel 機能は、たいてい Facade がある

3) DB I/Oの基本4操作(CRUD)

DB操作は基本4つだけです。
Create(INSERT) / Read(SELECT) / Update(UPDATE) / Delete(DELETE)

3-0. 例として使うテーブル

-- テーブル例:books
-- id: 自動採番
-- title: 本のタイトル
-- price: 価格
CREATE TABLE books (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  price INTEGER NOT NULL
);
Laravelでは本来 migration を使いますが、ここでは「SQLの形」を理解しやすいように書いています。

3-1. Read(SELECT)

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    /**
     * 本を全件取得する(SELECT)
     */
    public function findAll(): array
    {
        // 1) SELECT文をDBに送る
        $rows = DB::select('SELECT id, title, price FROM books ORDER BY id DESC');

        // 2) $rows は「オブジェクトの配列」になる(Laravelの仕様)
        return $rows;
    }
}

3-2. Create(INSERT)

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    /**
     * 本を1件追加する(INSERT)
     */
    public function insert(string $title, int $price): void
    {
        // 1) INSERT文(値は ? でプレースホルダにする)
        // 2) 2番目の引数で値を渡す(SQLインジェクション対策)
        DB::insert(
            'INSERT INTO books (title, price) VALUES (?, ?)',
            [$title, $price]
        );
    }
}

3-3. Update(UPDATE)

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    /**
     * 本を更新する(UPDATE)
     */
    public function updatePrice(int $id, int $newPrice): int
    {
        // 1) update() は「更新された行数」を返す
        $count = DB::update(
            'UPDATE books SET price = ? WHERE id = ?',
            [$newPrice, $id]
        );

        return $count;
    }
}

3-4. Delete(DELETE)

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    /**
     * 本を削除する(DELETE)
     */
    public function deleteById(int $id): int
    {
        // 1) delete() は「削除された行数」を返す
        $count = DB::delete(
            'DELETE FROM books WHERE id = ?',
            [$id]
        );

        return $count;
    }
}

4) 超重要:プリペアド(プレースホルダ)で安全に書く

SQLに値を文字連結すると危険です(SQLインジェクションの原因)。
例:"... WHERE title = '$title'" のように書かない。
安全な書き方はこれです:
SQLは固定して、値だけ別で渡す(? / 名前付き)
// OK:プレースホルダ
DB::select('SELECT * FROM books WHERE title = ?', [$title]);

// OK:名前付きプレースホルダ
DB::select('SELECT * FROM books WHERE title = :title', ['title' => $title]);

5) トランザクション(Transaction)

トランザクションは、複数のDB更新をまとめて
「全部成功したら確定(commit)」「途中で失敗したら全部取り消し(rollback)」
にする仕組みです。

5-1. どういう時に必要?(初心者向け例)

5-2. Laravelの基本:DB::transaction()

<?php

namespace App\Services;

use Illuminate\Support\Facades\DB;

class OrderService
{
    /**
     * (例)注文を作って在庫を減らす:どちらも成功させたい
     */
    public function createOrderAndDecreaseStock(int $bookId, int $qty): void
    {
        DB::transaction(function () use ($bookId, $qty) {

            // 1) 在庫チェック(本当は SELECT して在庫数確認)
            // 2) 在庫を減らす(UPDATE)
            DB::update(
                'UPDATE stocks SET quantity = quantity - ? WHERE book_id = ?',
                [$qty, $bookId]
            );

            // 3) 注文を作る(INSERT)
            DB::insert(
                'INSERT INTO orders (book_id, qty) VALUES (?, ?)',
                [$bookId, $qty]
            );

            // 4) ここまで例外が出なければ commit される
        });

        // 5) transaction() の外は「確定後」
    }
}
ポイント:
transaction() の中で例外が投げられると、自動で rollback されます。
つまり「途中で失敗したら全部なかったことにする」が簡単に実現できます。

5-3. try/catch を付ける(エラー時の対応)

<?php

use Illuminate\Support\Facades\DB;

class OrderService
{
    public function createOrderSafe(int $bookId, int $qty): void
    {
        try {
            DB::transaction(function () use ($bookId, $qty) {

                // 1) 更新(例)
                DB::update('UPDATE stocks SET quantity = quantity - ? WHERE book_id = ?', [$qty, $bookId]);

                // 2) 追加(例)
                DB::insert('INSERT INTO orders (book_id, qty) VALUES (?, ?)', [$bookId, $qty]);

                // 3) 例外が出なければ commit
            });

        } catch (\Throwable $e) {
            // 4) transaction内で失敗 → rollback済み
            // 5) ログや画面用メッセージなどをここで行う
            // logger($e->getMessage());
            throw $e;
        }
    }
}

6) SQLを書かずにアクセスする方法(Laravelの場合)

「SQLを書かずにアクセスする」方法は主に2つです。
① Query Builder(SQLより安全で読みやすい)
② Eloquent ORM(クラスとしてDBを扱う)

6-1. Query Builder(SQLを手書きしない)

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    public function findExpensiveBooks(int $minPrice): array
    {
        // 1) books テーブルを対象にする
        // 2) price が minPrice 以上
        // 3) 取得
        return DB::table('books')
            ->where('price', '>=', $minPrice)
            ->orderByDesc('id')
            ->get()
            ->all();
    }
}
Query Builderは内部でSQLを作りますが、あなたがSQL文字列を書かなくて良いのが利点です。

6-2. Eloquent ORM(モデルで操作する)

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    // テーブル名が books なら省略できる(規約で推測される)
    protected $table = 'books';

    // 代入を許可するカラム(初心者はまずこれを書く)
    protected $fillable = ['title', 'price'];
}
<?php

use App\Models\Book;

class BookService
{
    public function createBook(string $title, int $price): Book
    {
        // 1) INSERT相当(SQLを書かない)
        return Book::create([
            'title' => $title,
            'price' => $price,
        ]);
    }

    public function listBooks(): array
    {
        // 2) SELECT相当(SQLを書かない)
        return Book::orderByDesc('id')->get()->all();
    }
}
ORMは便利ですが「裏で何SQLが出るか」を知らないと遅くなることがあります。
初心者はまず「CRUDの意味」と「トランザクション」を押さえるのが先です。

7) (補足)Laravel以外:素のPHPでSQLiteに接続する(PDO)

Laravelの内部も「PDO」を使っています。
参考として、素のPHPの基本形も載せます(考え方は同じです)。

PDO(PHP Data Objects)とは何か

PDO(ピー・ディー・オー)とは、
PHP からデータベースに接続・操作するための標準機能です。

MySQL、SQLite、PostgreSQL など、
複数のデータベースを同じ書き方で扱えるのが最大の特徴です。


PDO が解決する問題

昔の PHP では、データベースごとに使い方が違いました。

  • MySQL 用の書き方
  • SQLite 用の書き方
  • PostgreSQL 用の書き方

PDO を使うと:

「どの DB でも、ほぼ同じ PHP コードで操作できる」

PDO の基本的な役割

  • データベースに接続する
  • SQL を実行する
  • 検索結果を取得する
  • 値を安全にバインドする(SQLインジェクション対策)

PDO を使った最小例(SQLite)

// 1) データベースに接続
$pdo = new PDO('sqlite:database.sqlite');

// 2) SQL を実行
$stmt = $pdo->query('SELECT * FROM users');

// 3) 結果を取得
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

このコードは、

  • new PDO() で接続を作り
  • query() で SQL を実行し
  • fetchAll() で結果を配列として取得

しています。


なぜ PDO は安全なのか

PDO には プリペアドステートメント という仕組みがあります。

$stmt = $pdo->prepare(
    'SELECT * FROM users WHERE email = :email'
);

$stmt->execute([
    ':email' => $email
]);

これにより、

ユーザー入力を直接 SQL に埋め込まない

= SQLインジェクション攻撃を防げます。


Laravel と PDO の関係

Laravel で使われている

  • DB::select()
  • Eloquent(ORM)

は、
内部では PDO を使ってデータベースと通信しています。

つまり:

Laravel の DB 機能の一番下には PDO がある

まとめ(初心者向け)

  • PDO は PHP 標準の DB 操作機能
  • 複数のデータベースを同じ書き方で扱える
  • 安全な SQL 実行ができる
  • Laravel の DB 処理の土台になっている
<?php

class PdoSqliteExample
{
    public function run(): void
    {
        // 1) SQLiteファイルを指定して接続(コネクション作成)
        $pdo = new \PDO('sqlite:' . __DIR__ . '/database.sqlite');

        // 2) 例外を投げるモードにする(失敗を見逃さない)
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);

        // 3) SELECT(prepare + execute)
        $stmt = $pdo->prepare('SELECT id, title, price FROM books WHERE price >= :minPrice');
        $stmt->execute(['minPrice' => 1000]);

        // 4) 結果取得
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        // 5) 表示など
        // var_dump($rows);
    }
}

\PDO の「\」は何を意味するのか

\PDO の先頭に付いている \(バックスラッシュ) は、
「グローバル名前空間を明示する記号」です。


PHP には namespace(名前空間)がある

PHP では、クラス名の衝突を防ぐために namespace を使います。

namespace App\Services;

class PDO
{
    // App\Services\PDO(自作クラス)
}

この状態で、次のコードを書くとどうなるでしょうか。

$pdo = new PDO(...);

PHP は次の順番でクラスを探します。

  1. App\Services\PDO
  2. 見つからなければエラー

PHP 標準の PDO クラスは探しに行きません。


\PDO と書くと何が変わるか

先頭に \ を付けると、

「グローバル名前空間にある PDO を使う」

と明示したことになります。

$pdo = new \PDO('sqlite:database.sqlite');

これは次の意味です。

グローバル空間の PDO クラスを使う

use PDO; と書いた場合

もしファイルの先頭で次のように書けば:

use PDO;

$pdo = new PDO(...);

これも同じ意味になります。

use PDO; は、

「PDO は \PDO を指す」と宣言している

なぜ \PDO と書くことが多いのか

  • 標準クラスであることが一目で分かる
  • 名前衝突を確実に防げる
  • use 宣言を書かなくてよい

そのため、短いスクリプトやサンプルでは \PDO と直接書くことがよくあります。


まとめ(重要ポイント)

  • \ は「グローバル名前空間を指す」記号
  • \PDO は PHP 標準の PDO クラス
  • 名前空間があるファイルでは必須になることが多い
  • use PDO; でも同じ効果

Eloquent(Laravel の ORM)を「意味・使い方・コード例」まで網羅的に説明

Eloquent(エロクアント)は、Laravel に標準で入っている ORM です。
ORM とは「DB のテーブル行(レコード)を、PHP のオブジェクト(クラス)として扱える仕組み」です。


1. Eloquent の意味(何をしてくれるのか)

  • テーブル ↔ PHPクラス を対応付ける(例:users ↔ Userモデル)
  • SQL を書かずに 検索・追加・更新・削除ができる
  • リレーション(1対多、多対多)をコードで表現できる
  • 作成日時・更新日時(created_at / updated_at)を自動管理できる(設定でON/OFF可能)
要するに
「SQL中心」ではなく「オブジェクト中心」でDB操作できるのが Eloquent です。

App\Models\User とは何か

App\Models\User は、
users テーブルを操作するための Eloquent モデルクラスです。

Laravel では、

  • DB の 1テーブル
  • PHP の 1クラス

を対応付けて扱います。
その対応付けを行うのが Model クラスです。


実際の定義例(User モデル)

ファイル場所:

app/Models/User.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * User モデル
 * users テーブルを表すクラス
 */
class User extends Model
{
    /**
     * 対応するテーブル名
     * (省略するとクラス名の複数形 users が自動で使われる)
     */
    protected $table = 'users';

    /**
     * 一括代入を許可するカラム
     * create() や update() で使用される
     */
    protected $fillable = [
        'username',
        'email',
        'tel',
        'address',
        'update_at',
    ];

    /**
     * created_at / updated_at を使わない場合は false
     */
    public $timestamps = false;
}

このクラスがやっていること

  • users テーブルの 1行1オブジェクトとして表す
  • SELECT / INSERT / UPDATE / DELETE を担当する
  • SQL を直接書かずに DB 操作できるようにする

どうやって使われるか(例)

use App\Models\User;

// users テーブルから全件取得
$users = User::all();

// id=1 のユーザーを取得
$user = User::find(1);

// 新規ユーザーを追加
User::create([
    'username'  => 'taro',
    'email'     => 'taro@example.com',
    'tel'       => '090-0000-0000',
    'address'   => 'Tokyo',
    'update_at' => now(),
]);

namespace App\Models; の意味

namespace App\Models; は、
このクラスの正式な名前が

App\Models\User

であることを表します。

そのため、他のクラスから使うときは:

use App\Models\User;

と書いてから

User::find(1);

のように使えます。


初心者向けまとめ

  • App\Models\User = users テーブル専用の操作クラス
  • 1レコード = 1オブジェクト
  • SQL を書かずに DB を扱える
  • Eloquent を使うときの中心となる存在

2. 基本の準備:Model(モデル)を作る

Eloquent を使うには、まず Model(モデル)クラス を作ります。
モデルは「このテーブルをこのクラスで扱う」という宣言です。

php artisan make:model User

例:app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model; // Eloquentの基底クラス

/**
 * Userモデル(usersテーブルを操作するクラス)
 */
class User extends Model
{
    // ① テーブル名(Laravel規約なら省略可能:User → users)
    protected $table = 'users';

    // ② 一括代入を許可するカラム(セキュリティ上、必要最小限にする)
    protected $fillable = [
        'username',
        'email',
        'tel',
        'address',
        'update_at', // ※要件により独自カラム名
    ];

    // ③ created_at / updated_at を使わない場合は false
    public $timestamps = false;
}
fillable の意味
create() や update() で「まとめて代入」できるカラムを制限します。
(悪意ある入力で想定外のカラム更新がされるのを防ぐ)

「Laravel規約なら省略可能」とはどういう意味か

Laravel には、
「設定を書かなくても自動で判断するための決まり(規約)」 が多数用意されています。

この考え方は

Convention over Configuration(設定より規約)

と呼ばれます。


この場合の「規約」

Eloquent(Model)には、次の 自動対応ルール があります。

モデルクラス名 対応するテーブル名(自動)
User users
Order orders
InventoryItem inventory_items

つまり、

「クラス名をスネークケースにして複数形にしたもの」

が、対応するテーブル名として 自動的に使われます


省略できるとは、どういう状態か

次のようにモデルを書いた場合:

class User extends Model
{
    // protected $table = 'users'; ← 書いていない
}

Laravel は内部で:

「クラス名が User だから、テーブル名は users だな」

と判断します。

そのため、

規約どおりの名前なら、明示的に書く必要がない

省略できないケース

次のような場合は、$table を明示する必要があります。

  • テーブル名が規約と違う場合(例:user_master
  • 単数形テーブル(例:user
  • 接頭辞付きテーブル(例:tbl_users
class User extends Model
{
    protected $table = 'user_master';
}

初心者向けまとめ

  • Laravel には「名前の決まり」がある
  • モデル名 → テーブル名は自動で決まる
  • 規約どおりなら設定を書かなくてよい
  • 規約から外れるときだけ明示する

3. Eloquent の使い方(CRUD:検索/追加/更新/削除)

3-1. 検索(SELECT)

use App\Models\User;

// ① 全件取得(usersテーブル全部)
$users = User::all(); // Collection(配列のように扱える)

// ② 先頭50件だけ
$users = User::query()
    ->orderBy('username') // usernameで並び替え
    ->limit(50)           // 上限50件
    ->get();              // 実行して取得

// ③ 1件取得(id=10)
$user = User::find(10); // 見つからなければ null

// ④ 条件検索(usernameが a で始まる)
$prefix = 'a';
$users = User::query()
    ->where('username', 'like', $prefix . '%') // LIKE 'a%'
    ->get();

// ⑤ 1件だけ取得(条件に合う最初の1件)
$user = User::query()
    ->where('email', 'test@example.com')
    ->first(); // 見つからなければ null

3-2. 追加(INSERT)

use App\Models\User;

// ① newして値をセットして保存(分かりやすい)
$user = new User();              // 新しい行(レコード)を表すオブジェクト
$user->username = 'taro';        // カラムに値を入れる
$user->email = 'taro@example.com';
$user->tel = '03-0000-0000';
$user->address = 'Tokyo';
$user->update_at = now();        // 更新日時(要件カラム)
$user->save();                   // DBにINSERTされる

// ② create()(fillableに書いたカラムだけ一括代入できる)
User::create([
  'username'  => 'hanako',
  'email'     => 'hanako@example.com',
  'tel'       => null,
  'address'   => 'Yokohama',
  'update_at' => now(),
]);

3-3. 更新(UPDATE)

use App\Models\User;

// ① 取得してから更新(一般的・安全)
$user = User::find(10);

if ($user !== null) {
    $user->email = 'new@example.com'; // email変更
    $user->tel = '090-1111-2222';     // tel変更
    $user->update_at = now();         // update_at更新
    $user->save();                    // UPDATEが実行される
}

// ② 条件に合うものをまとめて更新(件数が多い時)
$affected = User::query()
    ->where('username', 'like', 'a%')
    ->update([
        'tel' => null,
        'update_at' => now(),
    ]);

3-4. 削除(DELETE)

use App\Models\User;

// ① 取得して削除
$user = User::find(10);
if ($user !== null) {
    $user->delete(); // DELETE FROM users WHERE id=10
}

// ② 条件削除(まとめて)
User::query()
    ->where('username', 'like', 'test%')
    ->delete();

4. よく使う機能(where / select / orderBy / paginate)

use App\Models\User;

$users = User::query()
    ->select(['id','username','email','tel','address']) // 取るカラムを限定
    ->whereNotNull('email')                             // emailがnullではない
    ->orderBy('username', 'asc')                        // 昇順
    ->paginate(20);                                     // 1ページ20件(ページング)
ポイント
User::query() で「SQLを組み立てるモード(Query Builder)」に入ります。
最後に get() / first() / paginate() などで実行されます。

paginate(20) の「次の 20 件」を読む方法

paginate(20) は、
「1ページあたり 20 件ずつ取得する」という意味です。

このとき Laravel は、

  • 今は何ページ目か
  • 全体で何件あるか

を内部で管理しています。


1. 次の 20 件を読む一番基本の方法(ページ番号)

URL のクエリパラメータでページ番号を指定します。

?page=1  ← 最初の20件
?page=2  ← 次の20件
?page=3  ← さらに次の20件

つまり:

「次の20件」= ?page=2

Laravel は自動的に page という名前のパラメータを見ます。


2. Controller 側のコードは変えなくてよい

先ほどのコードは、そのままで OK です。

$users = User::query()
    ->select(['id','username','email','tel','address'])
    ->whereNotNull('email')
    ->orderBy('username', 'asc')
    ->paginate(20);

?page=2 が付いてくると、 Laravel が自動的に

OFFSET 20 LIMIT 20

相当の SQL を発行します。


paginate(20) の「次ページ取得・最終ページ判定・件数取得」をまとめて説明


1. 次のページを読むには、同じコードをもう一度書くのか?

答え:いいえ。
paginate(20) を使っている場合、 同じコードをそのまま 1 回だけ書けば十分です。

理由は、

  • 現在のページ番号は URL の ?page= から自動で読む
  • 次ページ・前ページは Laravel が内部で判定する

そのため、Controller のコードは常に同じになります。

// Controller 側(常に同じコード)
$users = User::query()
    ->select(['id','username','email','tel','address'])
    ->whereNotNull('email')
    ->orderBy('username', 'asc')
    ->paginate(20);

2. 次のページのデータはどうやって取得されるか

ブラウザや Blade が、

?page=2

のような URL でアクセスすると、 Laravel が自動的に 2ページ目のデータを返します。

開発者が

「次のページ用に別の SQL を書く」必要はありません

3. 最後のページかどうかはどうやって判断するか

paginate() の戻り値は、 LengthAwarePaginator というオブジェクトです。

このオブジェクトには、 ページ情報を判定するメソッドが用意されています。

// Blade / Controller どちらでも使用可能

$users->currentPage(); // 今のページ番号
$users->lastPage();    // 最終ページ番号
$users->hasMorePages();// 次のページがあるか(true / false)
$users->total();       // 全件数
$users->perPage();     // 1ページあたりの件数

たとえば、

// 最後のページかどうか
if (!$users->hasMorePages()) {
    // これ以上次のページはない
}

4. Blade での最終ページ判定例

@if ($users->hasMorePages())
  <a href="{{ $users->nextPageUrl() }}">次へ</a>
@endif

このように、 hasMorePages() を使えば 「次へ」リンクを出すかどうかを判断できます。


5. ページ数を事前に知りたい場合(count の Eloquent 版)

Eloquent で

SELECT COUNT(*) に相当する処理

は、次のように書きます。

$total = User::query()
    ->whereNotNull('email')
    ->count();

これは次の SQL に相当します。

SELECT COUNT(*) FROM users WHERE email IS NOT NULL;

そして、

$perPage = 20;
$lastPage = (int) ceil($total / $perPage);

とすれば、 総ページ数が計算できます。


6. paginate() を使っている場合は count() は不要

実は、 paginate() を使っている場合、

Laravel が内部で自動的に COUNT を実行しています

そのため、

  • 全件数 → $users->total()
  • 最終ページ → $users->lastPage()

がそのまま使えます。


7. 初心者向け結論

  • 次ページ取得のためにコードを書き直す必要はない
  • ?page=N を付けるだけ
  • 最後のページ判定は hasMorePages()
  • 件数は count() または paginate() が自動取得
  • Blade はページ数を知らなくても作れる

3. Blade 側で「次へ」リンクを出す方法

Blade では、次の 1 行で ページング用リンクを出せます。

{{ $users->links() }}

これにより、

  • 前へ
  • 1 / 2 / 3 ...
  • 次へ

などのリンクが自動生成されます。


4. API(JSON)で次ページを読む場合

API の場合は、 次のような URL でアクセスします。

GET /api/users?page=2

レスポンス例:

{
  "current_page": 2,
  "data": [ ... 次の20件 ... ],
  "last_page": 10,
  "per_page": 20,
  "total": 187
}

5. 「次の20件」を取る仕組みの正体

paginate() は内部で、

LIMIT 20 OFFSET (20 × (page - 1))

を自動計算しています。

そのため、

開発者は OFFSET を意識しなくてよい

初心者向けまとめ

  • paginate(20) は 20 件ずつ区切る
  • ?page=2 で次の 20 件
  • Controller のコードは変えない
  • Blade では $users->links()
  • API では ?page=N

paginate() を使うとき、URL は必ず ?page=1 形式になるのか?


結論

いいえ、必ず ?page=1 を付ける必要はありません。

Laravel の paginate() は、

  • ?page付いていなければ 1ページ目 として動作
  • ?page=2 が付いていれば 2ページ目

というルールで自動的に動きます。


URL とページの対応関係

URL Laravel が解釈するページ
https://a.com/api/users/list 1ページ目
https://a.com/api/users/list?page=1 1ページ目
https://a.com/api/users/list?page=2 2ページ目
https://a.com/api/users/list?page=3 3ページ目

つまり、

最初のアクセスでは ?page を付けなくてよい

なぜ Laravel は ?page を見ているのか

paginate() は、

HTTP リクエストのクエリパラメータ page を自動で読む

仕組み的には、Laravel の内部で次のようなことが起きています。

現在のリクエストを確認
↓
?page があればその番号を使う
↓
なければ page = 1 とみなす

開発者が $_GET['page'] を読む必要はありません。


Controller の具体例(API 用)

例として、次の URL に対応する Controller を示します。

GET /api/users/list
GET /api/users/list?page=2
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;

class UserListController extends Controller
{
    /**
     * ユーザー一覧をページングして返す
     */
    public function index(): JsonResponse
    {
        // paginate(20) は自動的に ?page を読む
        $users = User::query()
            ->select(['id', 'username', 'email', 'tel', 'address'])
            ->orderBy('username', 'asc')
            ->paginate(20);

        // JSON でそのまま返す(API向け)
        return response()->json($users);
    }
}

この Controller で起きていること

  • URL に ?page があれば Laravel が自動で解釈
  • Controller はページ番号を一切意識しない
  • 同じコードで全ページに対応できる

Blade(画面表示)の場合はどうなるか

Blade を使う場合は、

{{ $users->links() }}

これだけで、

  • ?page=1
  • ?page=2
  • ?page=3

というリンクを Laravel が自動生成します。


まとめ(初心者向け)

  • ?page を付けなくても paginate は動く
  • 付いていなければ自動で 1ページ目
  • Controller はページ番号を一切書かない
  • URL の ?page を Laravel が勝手に読む
paginate の本質
「ページ番号を気にしなくていいようにする仕組み」

存在しないページ番号(最後のページ以降)が指定されたらどうなるか


結論

Laravel の paginate() はエラーにしません。
最後のページを超える ?page が指定された場合でも、 「空の結果セット」を返すだけです。

例外・404・エラーは発生しません

具体例で説明

例えば、

全件数: 45件
1ページあたり: 20件
最終ページ: 3

このときのアクセス結果は次のとおりです。

URL Laravel の動作 返るデータ
/api/users/list page=1 として処理 20件
/api/users/list?page=2 2ページ目 20件
/api/users/list?page=3 3ページ目(最終) 5件
/api/users/list?page=4 最終ページ超過 0件(空配列)
/api/users/list?page=999 最終ページ超過 0件(空配列)

JSON レスポンスの中身はどうなるか

?page=4 のように 存在しないページを指定した場合、

{
  "current_page": 4,
  "data": [],
  "first_page_url": "https://a.com/api/users/list?page=1",
  "from": null,
  "last_page": 3,
  "last_page_url": "https://a.com/api/users/list?page=3",
  "next_page_url": null,
  "path": "https://a.com/api/users/list",
  "per_page": 20,
  "prev_page_url": "https://a.com/api/users/list?page=3",
  "to": null,
  "total": 45
}

ポイントは、

  • data が空配列になる
  • last_page は正しい値のまま
  • next_page_urlnull

Controller 側で特別な処理は必要か?

通常は不要です。

API や画面側で、

  • 「もうデータがない」
  • 「次のページは存在しない」

という判断を、 data が空かどうか、 next_page_urlnull かで行えば十分です。


もし「ページ超過はエラーにしたい」場合

業務要件によっては、

最終ページを超えたら 404 を返したい

という場合もあります。

その場合は、Controller で明示的にチェックします。

$users = User::query()
    ->orderBy('username')
    ->paginate(20);

// 最終ページを超えていたら 404
if ($users->currentPage() > $users->lastPage() && $users->lastPage() !== 0) {
    abort(404);
}

初心者向けまとめ

  • 最後のページを超えてもエラーにはならない
  • 空データが返るだけ
  • Laravel は安全側(壊れない側)に倒す設計
  • エラーにしたいなら自分で判定を書く
Laravel の Blade で作るページ遷移(「前へ」「次へ」ボタンなど)は、先頭ページでは「前へ」が無効(disabled)になり、最終ページでは「次へ」が無効(disabled)になります。これは paginate() が「今のページ番号」「最終ページ番号」「次/前のページが存在するか」を内部で持っているためで、Blade 側はその情報に従ってボタンの有効・無効を切り替えるだけで実現できます。

Blade でのページ遷移ボタン制御(Laravel 標準の考え方)

Laravel では、ページングされたデータ(paginate() の戻り値)に対して {{ $users->links() }} を書くだけで、 「前へ」「次へ」などのページ遷移リンクを自動生成できます。

このとき Laravel は、次の状態を 自動的に判断 しています。

  • 先頭ページの場合 → 「前へ」ボタンは disabled(無効)
  • 最終ページの場合 → 「次へ」ボタンは disabled(無効)
  • 途中のページの場合 → 両方のボタンが 有効

つまり、開発者が 「今は何ページ目か」「次はあるか」 といった判定ロジックを書く必要はありません。


自分でボタンを実装する場合

標準の links() を使わず、 自分で「前へ」「次へ」ボタンを描画したい場合でも、 Laravel が用意しているメソッドを使えば簡単です。

// 前へ
@if ($users->onFirstPage())
    <button disabled>前へ</button>
@else
    <a href="{{ $users->previousPageUrl() }}">前へ</a>
@endif

// 次へ
@if ($users->hasMorePages())
    <a href="{{ $users->nextPageUrl() }}">次へ</a>
@else
    <button disabled>次へ</button>
@endif

ここで使われているメソッドの意味は次の通りです。

  • onFirstPage():現在が先頭ページかどうかを返す
  • hasMorePages():次のページが存在するかどうかを返す

なぜ「有効 / 無効」を自分で考えなくてよいのか

paginate() は、 データ取得時に次の情報を すでに計算済み だからです。

  • 全件数
  • 現在のページ番号
  • 最終ページ番号

そのため Blade 側では、 「聞くだけ」で正しい UI 状態を判断できます。

重要ポイント
ページングの状態管理は Laravel が担当し、
Blade は表示に集中できます。

5. リレーション(関連テーブルの扱い)

例:
「ユーザー(users)が複数の注文(orders)を持つ」= 1対多

// Userモデル側(1人のユーザーは複数の注文を持つ)
class User extends Model
{
    public function orders()
    {
        return $this->hasMany(Order::class); // users.id → orders.user_id
    }
}

// 注文を一緒に取る(N+1問題を避けるため eager load)
$users = User::query()
    ->with('orders') // 関連をまとめて取得
    ->limit(10)
    ->get();
N+1問題
ループの中で毎回DBに問い合わせると、SQLが大量に発行されて遅くなります。
それを避けるために with() をよく使います。

6. トランザクション(まとめて成功/失敗を制御)

use Illuminate\Support\Facades\DB;
use App\Models\User;

DB::transaction(function () {
    // ここで例外が出なければ全部コミットされる

    $user = User::find(1);
    if ($user === null) {
        throw new \RuntimeException('user not found');
    }

    $user->email = 'tx@example.com';
    $user->update_at = now();
    $user->save();

    // さらに別テーブル更新など…
});

7. Eloquent のメリット / デメリット

メリット

  • SQLを書かずに CRUDができ、実装が速い
  • モデル・リレーションが整理され、業務コードが読みやすい
  • バリデーションやイベント、スコープなど拡張が豊富
  • 開発者が多く、Laravelの標準レールに乗りやすい

デメリット

  • 複雑な集計や最適化が必要なSQLは 書きづらい/遅くなりやすい
  • 慣れないと 裏で発行されるSQLが見えにくい
  • 大量データ処理ではメモリを食いやすい(オブジェクト化するため)
  • リレーションの扱いを間違えると N+1 で遅くなる

8. DBファサード(DB::)とどちらがよく使われる?

Laravel では DB操作に大きく2つあります。

手段 特徴 向いている場面
Eloquent モデル中心(オブジェクト) CRUD中心、リレーション、普通の業務画面
DBファサード(DB::) SQL中心(クエリ/生SQL) 複雑な集計、JOIN多用、パフォーマンス重視、SQLを直接書きたい
どちらが「よく使われる」か
一般的な Laravel の業務開発では、
CRUD・画面系は Eloquent が中心になりやすいです。
ただし、集計・帳票・重い検索は DB::(またはQuery Builder)に寄せることが多いです。

9. 結論(初心者向けの覚え方)

  • 「1テーブル=1モデル」っぽい処理 → Eloquent
  • 「SQLを組み立てたい/集計が重い」 → DB::(またはQuery Builder)
  • どちらもLaravel標準で、混ぜて使ってOK

DBごとの「プログラミング時の違い」まとめ

ここでは「どのDBが良いか」ではなく、
アプリケーションコード(PHP / Laravel)を書くときに、何が違ってくるのか
という視点で整理しています。
DB SQLの書き方の違い 型・制約の厳しさ トランザクション・ロック アプリ側で気をつける点
SQLite LIMIT / OFFSET が使える
RETURNING は最近まで非対応
・SQL方言は少なめ
・型がかなり緩い
INTEGER に文字列も入ってしまう
・トランザクションはある
・書き込み時はDB全体ロック
・アプリ側で型チェック必須
・並列更新が起きる設計は避ける
PostgreSQL RETURNING が使える
・サブクエリ・CTEが強力
・型チェックが非常に厳格
・間違った型は即エラー
・MVCCで並列処理に強い
・ロック粒度が細かい
・SQLと型を正確に書く必要あり
・本番向けコードに向く
MySQL LIMIT が使える
RETURNING は基本不可
・型は比較的ゆるい
・暗黙変換が多い
・InnoDBならトランザクション可
・ロックは比較的素直
・曖昧なSQLでも動いてしまう
・本番前に厳密チェック推奨
Oracle LIMIT が使えない(ROWNUM等)
・SQL方言が多い
・型・制約が非常に厳格 ・トランザクション制御が明確
・ロールバックが強力
・Oracle専用SQLを書く必要あり
・移植性が下がる
SQL Server TOP 構文を使う
・T-SQL 独自構文あり
・型は比較的厳格 ・トランザクション制御が明確
・ロック戦略を意識する必要あり
・T-SQL前提のSQLになる
・ORM任せにしない理解が必要
重要なまとめ(プログラマ視点)
  • SQLite / MySQL は「動いてしまうコード」が多い
  • PostgreSQL / Oracle は「間違いを早くエラーにしてくれる」
  • DBが変わると 同じSQLがそのまま動かない ことがある
  • ORM(Eloquent)を使っても、DB差異はゼロにならない
「SQLiteで動いたから本番でもOK」とは限りません。
本番DBが PostgreSQL / MySQL の場合は、
型・トランザクション・同時更新を意識したコードを書く必要があります。

9) まとめ(最短で覚える)

  • DB I/O は CRUD(SELECT/INSERT/UPDATE/DELETE)だけ
  • 接続は .env の DB_CONNECTION から始まる
  • 値はプレースホルダで渡す(安全)
  • 複数更新はトランザクションで守る
  • SQLを書かない方法:Query Builder / Eloquent

DBのデータを貯めるクラスは DTO と呼ぶのか?

結論から言います。
DBのデータを保存・更新するためのクラスは、通常 DTO とは呼びません。
DTO と DB用クラスは「役割が違う別物」です。

1) 用語を整理(ここが一番大事)

名前 主な役割 DBとの関係
DTO
(Data Transfer Object)
データを運ぶだけ
形を整える・型を固定する
直接DBを触らない
Entity / Model DBの1行を表す
保存・更新の主体
DBと1対1で対応する
Repository DB I/Oをまとめる
SQLを書く場所
DBアクセス専門
重要な一言:
「DBに保存するためのクラス = DTO」ではありません。

2) DTO は「保存しない」「SQLを知らない」

DTO は次のような特徴を持ちます。

DTOの典型例(外部API / 画面用)

<?php

namespace App\Dto;

/**
 * これは DTO(保存しない)
 */
final class UserDto
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email
    ) {}
}
このクラスは INSERT も UPDATE も SELECT もしません
ただ「データの形」を保証するだけです。

3) DBに保存するクラスは何と呼ぶ?

DBに保存することを前提としたクラスは、一般に:
  • Entity(DDD文脈)
  • Model(Laravel/Eloquent文脈)
と呼ばれます。

4) DBデータを「自分の使いたい形」で保存するのはどこ?

ここで役割分担が効いてきます。

処理 担当
入力データをまとめる DTO
DB用の形に変換する Service
INSERT / UPDATE / SELECT Repository
DBの1行として表現 Entity / Model

5) INSERT後の auto increment(ID)はどうやって取得する?

はい、取得できます
そして「それを書く場所」も明確に決まっています。

5-1. SQL + Repository で取得する場合(SQLite / MySQL)

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    /**
     * INSERTして、自動採番IDを返す
     */
    public function insert(string $title, int $price): int
    {
        DB::insert(
            'INSERT INTO books (title, price) VALUES (?, ?)',
            [$title, $price]
        );

        // SQLite / MySQL で使える
        return (int) DB::getPdo()->lastInsertId();
    }
}
lastInsertId() は「直前の INSERT で発行されたID」を返します。

5-2. Query Builder を使う場合

<?php

use Illuminate\Support\Facades\DB;

class BookRepository
{
    public function insert(array $data): int
    {
        // insertGetId は自動採番IDを返す
        return DB::table('books')->insertGetId([
            'title' => $data['title'],
            'price' => $data['price'],
        ]);
    }
}

5-3. Eloquent(Model)を使う場合(最も簡単)

<?php

use App\Models\Book;

class BookService
{
    public function createBook(string $title, int $price): Book
    {
        // INSERTされる
        $book = Book::create([
            'title' => $title,
            'price' => $price,
        ]);

        // $book->id に自動採番IDが入っている
        return $book;
    }
}
Eloquentの場合:
INSERT後、自動で ID が Model にセットされます。
自分で lastInsertId() を呼ぶ必要はありません。

6) 「DTO → DB保存」の正しい流れ(全体像)

画面 / API入力
   ↓
DTO(入力データの形を固定)
   ↓
Service(業務ルール・変換)
   ↓
Repository(INSERT / UPDATE)
   ↓
DB(auto increment ID 発行)
   ↓
Entity / Model(IDを持った状態)

7) よくある誤解(初心者が必ずハマる)

DTO は「運ぶだけ」。
DBに触らせると責務が崩れます。

8) まとめ(言葉の整理)

  • DTO:データを運ぶだけ(保存しない)
  • Entity / Model:DBの1行を表す
  • Repository:DB I/Oを書く場所
  • auto increment の取得は Repository / Model が担当

この区別ができるようになると、
「どこに何を書くか」で迷わなくなります。

SELECTしたDBデータを Entity(Model)に詰める

ここでは、
DBから SELECT したデータを「Entity(Model)」として扱う
という処理を、方法別に説明します。

1) まず整理:Entityとは何か?

Entity(Laravelでは Model)は、
「DBの1行を、そのままオブジェクトとして表したもの」です。
// books テーブルの1行
id | title        | price
---+--------------+------
 1 | Laravel入門  | 2500

↓

// Entity(Model)
Book {
  id: 1,
  title: "Laravel入門",
  price: 2500
}
重要:
Entity は「保存も更新も SELECT もできる」クラスです。
DTO のように「運ぶだけ」ではありません。

2) SQLでSELECTして、自分でEntityに詰める(低レベル)

まず「仕組みが一番分かりやすい」方法からです。
DB::select() は stdClass の配列を返します。

2-1. Entityクラス(Eloquentを使わない素Entity)

<?php

namespace App\Entities;

/**
 * 素の Entity(Eloquentではない)
 */
class BookEntity
{
    public function __construct(
        public int $id,
        public string $title,
        public int $price
    ) {}
}

2-2. RepositoryでSELECT → Entityに詰める

<?php

namespace App\Repositories;

use Illuminate\Support\Facades\DB;
use App\Entities\BookEntity;

class BookRepository
{
    /**
     * IDで1件取得して Entity を返す
     */
    public function findById(int $id): ?BookEntity
    {
        // ① SELECT(結果は stdClass の配列)
        $rows = DB::select(
            'SELECT id, title, price FROM books WHERE id = ?',
            [$id]
        );

        // ② 見つからなかった場合
        if (count($rows) === 0) {
            return null;
        }

        // ③ 1行取り出す
        $row = $rows[0];

        // ④ Entity に詰め替える
        return new BookEntity(
            id: (int) $row->id,
            title: (string) $row->title,
            price: (int) $row->price
        );
    }
}
ここで起きていること:
  • DB → stdClass(Laravelの仕様)
  • stdClass → Entity(自分のクラス)
  • ここで「型を固定」できる

3) Query Builderで SELECT → Entity

Query Builder を使うと SQL文字列は書かなくて済みますが、
Entityに詰める考え方は同じです。

<?php

use Illuminate\Support\Facades\DB;
use App\Entities\BookEntity;

class BookRepository
{
    public function findById(int $id): ?BookEntity
    {
        // ① 1件取得(stdClass)
        $row = DB::table('books')->where('id', $id)->first();

        if ($row === null) {
            return null;
        }

        // ② Entityに詰め替え
        return new BookEntity(
            id: (int) $row->id,
            title: (string) $row->title,
            price: (int) $row->price
        );
    }
}

4) Eloquent(Model)で SELECT → Entity(最も一般的)

Laravel では通常、
Eloquent Model = Entity
と考えて問題ありません。

4-1. Model定義

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    protected $table = 'books';

    protected $fillable = ['title', 'price'];
}

4-2. SELECTしてModelを取得する

<?php

use App\Models\Book;

class BookRepository
{
    /**
     * Eloquent Model をそのまま返す
     */
    public function findById(int $id): ?Book
    {
        // Book はすでに Entity
        return Book::find($id);
    }
}
この時点で:
  • $book->id
  • $book->title
  • $book->price
がすべて使えます。

5) Entity → DTO に変換するのはどこ?

実務では、
Entityをそのまま画面やAPIに返さないことも多いです。

<?php

use App\Models\Book;
use App\Dto\BookDto;

class BookService
{
    public function getBookDto(int $id): ?BookDto
    {
        $book = Book::find($id);

        if ($book === null) {
            return null;
        }

        // Entity → DTO
        return new BookDto(
            id: $book->id,
            title: $book->title,
            price: $book->price
        );
    }
}

6) DTO / Entity / Repository の役割を再確認

役割 やること やらないこと
DTO データを運ぶ DBアクセス
Entity / Model DBの1行を表す 画面ロジック
Repository SELECT / INSERT / UPDATE 画面表示
Service Entity⇔DTO変換・業務ルール 直接SQLを書く
答え合わせ:
はい、今までの説明では「INSERT後のID取得」は書いていましたが、
「SELECTしてEntityに詰める」例は不足していました。
ここが補完版です。

ファイルI/O(PHP / Laravel)— 読み書き・大容量・バイナリ・例外・パス結合・テンポラリ・アップロード/ダウンロード

この章では、初心者でも「何をすると何が起きるか」が分かるように、
小さいテキスト / 大きいファイル / バイナリの読み書き、
エラーハンドリングパス結合テンポラリーファイル
HTML → Controller → 保存 → ダウンロードの流れを、コードとコメントで丁寧に説明します。

0) まず前提:PHPのファイルI/Oは「2種類」ある

方法 特徴 向く用途
素のPHP関数
file_get_contents / fopen など
PHP標準。細かい制御ができる 学習・簡単な処理・ストリーム処理
Laravel Storage
Storage::disk()
ローカル/クラウドを同じ書き方で扱える 実務のファイル保存(推奨)
この章では「まず素のPHPで仕組みを理解」し、その後「Laravelでの実務形」を示します。

1) パス結合(OS差を吸収する)

Windows は \、Linux/Mac は / なので、
文字列連結でパスを作ると事故ります。

1-1. PHPの基本:DIRECTORY_SEPARATOR を使う

<?php

$baseDir = __DIR__;
$fileName = 'sample.txt';

// OSに合う区切り文字を使ってパスを作る
$path = $baseDir . DIRECTORY_SEPARATOR . $fileName;

// 例:Windows なら C:\...\sample.txt
// 例:Linux/Mac なら /.../sample.txt

1-2. Laravelの基本:base_path() / storage_path() を使う(推奨)

<?php

// プロジェクト配下
$path1 = base_path('data/sample.txt');

// storage配下
$path2 = storage_path('app/data/sample.txt');

// public配下
$path3 = public_path('download/sample.txt');
Laravelでは、storage_path() / base_path() を使うと
OS差を気にせずパスが作れます。

2) 小さいテキストファイル(読み込み / 書き込み)

2-1. 読み込み:file_get_contents(簡単)

<?php

$path = storage_path('app/data/hello.txt');

try {
    // ① ファイル全体を一気に読む(小さいファイル向け)
    $text = file_get_contents($path);

    // ② 読めなかった場合、false が返るので判定する
    if ($text === false) {
        throw new RuntimeException("読み込みに失敗しました: {$path}");
    }

    // ③ ここで $text を使う
    // echo $text;

} catch (Throwable $e) {
    // ④ エラー処理(ログなど)
    // logger($e->getMessage());
    throw $e;
}

2-2. 書き込み:file_put_contents(簡単)

<?php

$path = storage_path('app/data/hello.txt');

try {
    $content = "こんにちは\n";

    // ① ファイルへ書き込む(上書き)
    $bytes = file_put_contents($path, $content);

    // ② 失敗すると false
    if ($bytes === false) {
        throw new RuntimeException("書き込みに失敗しました: {$path}");
    }

    // ③ $bytes は書き込んだバイト数
} catch (Throwable $e) {
    throw $e;
}

2-3. 追記したい場合(APPEND)

<?php

$path = storage_path('app/data/hello.txt');

file_put_contents($path, "追記\n", FILE_APPEND);

3) 大きいファイル(ストリームで読む / 書く)

大きいファイルは file_get_contents で一括読み込みしない方が安全です。
理由:メモリを大量に使って落ちる可能性があるため。
代わりに fopen + while の「少しずつ読む」方式(ストリーム)を使います。

3-1. 大きいテキストを行単位で読む(fgets)

<?php

$path = storage_path('app/data/big.txt');

$handle = null;

try {
    // ① 読み込みモードで開く
    $handle = fopen($path, 'r');

    if ($handle === false) {
        throw new RuntimeException("ファイルを開けません: {$path}");
    }

    // ② 1行ずつ読む(メモリを食わない)
    while (($line = fgets($handle)) !== false) {
        // ③ ここで1行ずつ処理する
        // 例:echo $line;
    }

    // ④ fgets が false で終わる理由が「EOF」か「エラー」かを確認
    if (!feof($handle)) {
        throw new RuntimeException("読み込み中にエラーが発生しました: {$path}");
    }

} catch (Throwable $e) {
    throw $e;
} finally {
    // ⑤ 必ず閉じる
    if (is_resource($handle)) {
        fclose($handle);
    }
}

3-2. 大きいファイルをバイト単位でコピーする(読み→書き)

<?php

$src = storage_path('app/data/big.bin');
$dst = storage_path('app/data/big_copy.bin');

$in = null;
$out = null;

try {
    $in = fopen($src, 'rb');   // rb: バイナリ読み込み
    if ($in === false) {
        throw new RuntimeException("入力ファイルを開けません: {$src}");
    }

    $out = fopen($dst, 'wb');  // wb: バイナリ書き込み(上書き)
    if ($out === false) {
        throw new RuntimeException("出力ファイルを開けません: {$dst}");
    }

    // ① 1MBずつ読む(大きさは用途で調整)
    $chunkSize = 1024 * 1024;

    while (!feof($in)) {
        $chunk = fread($in, $chunkSize);
        if ($chunk === false) {
            throw new RuntimeException("読み込みに失敗しました: {$src}");
        }

        $written = fwrite($out, $chunk);
        if ($written === false) {
            throw new RuntimeException("書き込みに失敗しました: {$dst}");
        }
    }

} catch (Throwable $e) {
    throw $e;
} finally {
    if (is_resource($in)) fclose($in);
    if (is_resource($out)) fclose($out);
}

4) バイナリファイル(画像など)の読み書き

バイナリは文字コード変換などをしないために
必ず 'rb' / 'wb' を使います。

4-1. バイナリ読み込み(小さいなら file_get_contents でも可)

<?php

$path = storage_path('app/images/sample.jpg');

$bytes = file_get_contents($path);
if ($bytes === false) {
    throw new RuntimeException("画像を読めません: {$path}");
}

// $bytes は「生のバイト列」

4-2. バイナリ書き込み(保存)

<?php

$dst = storage_path('app/images/out.jpg');

// $bytes をどこかから取得した想定
$bytes = "....";

$written = file_put_contents($dst, $bytes);
if ($written === false) {
    throw new RuntimeException("画像を書き込めません: {$dst}");
}

5) Laravelの実務形:Storage を使う(推奨)

Storage を使うと、保存先が
ローカル(disk=local)でもS3でも同じ書き方になります。

5-1. テキスト保存 / 読み込み

<?php

use Illuminate\Support\Facades\Storage;

class FileService
{
    public function saveText(string $path, string $text): void
    {
        // ① storage/app 配下に保存される
        Storage::disk('local')->put($path, $text);
    }

    public function readText(string $path): string
    {
        // ② 存在確認(初心者向けに明示)
        if (!Storage::disk('local')->exists($path)) {
            throw new RuntimeException("ファイルが存在しません: {$path}");
        }

        return Storage::disk('local')->get($path);
    }
}

5-2. 大きいファイル:ストリームで書く(Storage)

<?php

use Illuminate\Support\Facades\Storage;

class FileService
{
    public function copyLargeFile(string $srcPath, string $dstPath): void
    {
        // ① 読み込みストリーム取得
        $readStream = Storage::disk('local')->readStream($srcPath);
        if ($readStream === false) {
            throw new RuntimeException("readStream失敗: {$srcPath}");
        }

        try {
            // ② 書き込み(ストリームで保存)
            $ok = Storage::disk('local')->put($dstPath, $readStream);
            if ($ok === false) {
                throw new RuntimeException("put失敗: {$dstPath}");
            }
        } finally {
            // ③ ストリームは閉じる
            if (is_resource($readStream)) {
                fclose($readStream);
            }
        }
    }
}

6) OS差を超えたテンポラリーファイルの扱い方

テンポラリーファイルは「一時的に置くファイル」です。
OSごとに temp フォルダの場所が違うため、
OSが提供する関数を使うのが安全です。

6-1. sys_get_temp_dir()(OSの一時フォルダ)

<?php

$tempDir = sys_get_temp_dir(); // OSごとのtempフォルダ

// tempDir の例:
// Windows: C:\Users\...\AppData\Local\Temp
// Linux:   /tmp

6-2. tempnam()(衝突しない一時ファイル名を作る)

<?php

$tempDir = sys_get_temp_dir();

// ① プレフィックス付きで一時ファイルを作る(ファイルは実際に作られる)
$tempPath = tempnam($tempDir, 'laravel_');

if ($tempPath === false) {
    throw new RuntimeException("テンポラリファイル作成に失敗しました");
}

// ② 使い終わったら削除(消し忘れ防止)
try {
    file_put_contents($tempPath, "temporary data");
} finally {
    @unlink($tempPath);
}
注意:
tempファイルは「作りっぱなし」が一番危険です。
try/finally で削除を保証しましょう。

7) ファイルアップロード:HTML → Controller → 保存(Laravel)

7-1. HTML(Blade)側:アップロードフォーム

<!-- resources/views/upload.blade.php -->

<form method="post" action="/upload" enctype="multipart/form-data">
  @csrf

  <label>ファイルを選択</label><br>
  <input type="file" name="file">

  <button type="submit">アップロード</button>
</form>
重要:
アップロードフォームは enctype="multipart/form-data" が必須です。
無いとファイルが送られません。

7-2. ルーティング

<?php
// routes/web.php

use App\Http\Controllers\UploadController;

Route::get('/upload', [UploadController::class, 'showForm']);
Route::post('/upload', [UploadController::class, 'upload']);

7-3. Controller側:受け取り → バリデーション → 保存

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class UploadController extends Controller
{
    public function showForm()
    {
        // 1) アップロード画面を表示
        return view('upload');
    }

    public function upload(Request $request)
    {
        // 2) 受け取ったファイルがあるか確認
        //    name="file" の input と一致させる
        if (!$request->hasFile('file')) {
            return back()->with('message', 'ファイルが選択されていません');
        }

        // 3) UploadedFile を取得
        $file = $request->file('file');

        // 4) アップロードに失敗していないか確認
        if (!$file->isValid()) {
            return back()->with('message', 'アップロードに失敗しました');
        }

        // 5) 保存先のパスを決める(storage/app/uploads/ に保存する例)
        //    store() は自動でファイル名をユニークにしてくれる
        $savedPath = $file->store('uploads', 'local');

        // 6) 保存先パスをユーザーに返す(実務ではDBに保存することも多い)
        return back()->with('message', "保存しました: {$savedPath}");
    }
}

8) ダウンロード:保存済みファイルを返す(Laravel)

8-1. ルーティング

<?php
// routes/web.php

use App\Http\Controllers\DownloadController;

Route::get('/download/{path}', [DownloadController::class, 'download'])
  ->where('path', '.*');
注意:
上のように自由なパスをURLから受け取る場合は、
パストラバーサル対策(../ を許さない等)が必要です。
初心者向けとして、次のControllerでは安全チェックを書きます。

8-2. Controller側:存在確認 → 安全確認 → ダウンロード

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Storage;

class DownloadController extends Controller
{
    public function download(string $path)
    {
        // 1) 危険なパスを弾く(../ などを禁止)
        if (str_contains($path, '..')) {
            abort(400, '不正なパスです');
        }

        // 2) この例では uploads/ 配下だけ許可する
        if (!str_starts_with($path, 'uploads/')) {
            abort(403, '許可されていないパスです');
        }

        // 3) 存在確認
        if (!Storage::disk('local')->exists($path)) {
            abort(404, 'ファイルが見つかりません');
        }

        // 4) ダウンロードレスポンスを返す
        //    第2引数は「ダウンロード時のファイル名」
        return Storage::disk('local')->download($path, basename($path));
    }
}
これで完成:
HTMLでアップロード → storage に保存 → URLでダウンロードができます。

9) よくあるエラーと対処(初心者向け)

abort() は業務でも使ってよいのか?

結論から言うと、
abort() は業務コードでも「正しい場所であれば」問題なく使えます。
ただし、どこでも使ってよいわけではありません。

1) abort() とは何か?

abort() は Laravel が用意している
「HTTPエラーを即座に返して、処理を中断する」ための関数です。

// 例:400 Bad Request を返して処理を止める
abort(400, '不正なリクエストです');

この1行で、内部的には次のことが起きています。

2) abort() は「例外の一種」

実は abort() は、
HttpException を投げているだけです。
// イメージ的には、これとほぼ同じ
throw new HttpException(400, '不正なリクエストです');

つまり、
「処理をこれ以上続けてはいけない」
という意思表示を、HTTPレベルで行う仕組みです。

3) 業務で「使ってよい」ケース

abort() を使ってよい場所

実務で普通に見る例

<?php

public function download(string $path)
{
    // URLとして不正 → 即中断
    if (str_contains($path, '..')) {
        abort(400, '不正なパスです');
    }

    // 権限がない → 即中断
    if (!auth()->user()->can('download-file')) {
        abort(403, '権限がありません');
    }

    // 見つからない → 即中断
    if (!Storage::disk('local')->exists($path)) {
        abort(404, 'ファイルが見つかりません');
    }

    // ここまで来たら正常系
}
これは 業務でも「正しい使い方」です。

4) 業務で「使うべきでない」ケース

abort() を使ってはいけない場所

NG例(よくある失敗)

<?php

class OrderService
{
    public function create(array $data)
    {
        if ($data['qty'] <= 0) {
            // ❌ HTTPの都合を業務ロジックに持ち込んでいる
            abort(400, '数量が不正です');
        }

        // ...
    }
}
Service層は「HTTPを知らない」べきです。
ここでは 例外(throw) を使います。

5) 正しい設計:throw → Controller で abort に変換

Service側(業務ロジック)

<?php

class InvalidQuantityException extends \RuntimeException {}

class OrderService
{
    public function create(array $data)
    {
        if ($data['qty'] <= 0) {
            // 業務ルール違反は例外で表現
            throw new InvalidQuantityException('数量は1以上である必要があります');
        }
    }
}

Controller側(HTTP層)

<?php

public function store(Request $request, OrderService $service)
{
    try {
        $service->create($request->all());
    } catch (InvalidQuantityException $e) {
        // 業務例外 → HTTPエラーに変換
        abort(400, $e->getMessage());
    }

    return response()->json(['status' => 'ok']);
}
重要な考え方:
  • Service:業務ルールの世界
  • Controller:HTTPの世界
  • abort():HTTPの世界の道具

6) 業務での判断基準(覚え方)

この質問で判断してください:

「これは HTTPリクエストとして不正 なのか?」
→ YES:abort() を使ってよい
→ NO :例外を投げて上に伝える

今回の ../ チェックは、
完全に HTTP / セキュリティ層の問題なので、
abort(400)正解 です。

Laravel 多言語化(i18n)— 設定・フォルダ・ファイル名・コードでの扱い方(初心者向け)

この章では Laravel の多言語化(i18n)を、
「どこに何ファイルを置くのか」「設定はどこか」「コードでどう呼ぶのか」
「言語切替をどう実装するか」まで、順番に丁寧に説明します。

0) 多言語化でやること(全体像)

多言語化とは何をするのか
  • 画面やメッセージの文字列を「コードに直書き」しない
  • 言語ごとの翻訳ファイルにまとめる
  • ユーザーの言語(locale)に応じて表示を切り替える

1) Laravel の翻訳ファイルはどこに置く?

1-1. 基本フォルダ

lang/
  en/
    messages.php
    validation.php
  ja/
    messages.php
    validation.php
Laravel の翻訳ファイルは、通常プロジェクト直下の lang フォルダに置きます。
言語ごとにフォルダを分けるのが基本です(例:ja, en)。

1-2. ファイル名の考え方

ファイル名は「分類」です。
例:messages.php は一般メッセージ、validation.php はバリデーション用。
実務では用途ごとに増やします(例:auth.php, screen.php, errors.php)。

2) 翻訳ファイルの書き方(PHP配列)

2-1. lang/ja/messages.php

<?php
// lang/ja/messages.php

return [
    'app_name' => 'サンプルアプリ',

    'welcome' => 'ようこそ :name さん',

    'buttons' => [
        'save' => '保存',
        'cancel' => 'キャンセル',
    ],
];

2-2. lang/en/messages.php

<?php
// lang/en/messages.php

return [
    'app_name' => 'Sample App',

    'welcome' => 'Welcome, :name',

    'buttons' => [
        'save' => 'Save',
        'cancel' => 'Cancel',
    ],
];
ポイント:
  • キー(例:app_name)は言語に依存しない “ID” として使う
  • :name のような プレースホルダ を置ける
  • 入れ子もOK(例:buttons.save

3) 設定ファイル(locale はどこで決まる?)

3-1. config/app.php

<?php
// config/app.php の一部(概念)

'locale' => 'ja',          // デフォルト言語
'fallback_locale' => 'en', // 翻訳が無い場合の代替言語
意味:
  • locale:何も指定しない場合の言語
  • fallback_locale:翻訳キーが見つからない場合に使う言語

3-2. .env で変更する(実務でよくやる)

# .env
APP_LOCALE=ja
APP_FALLBACK_LOCALE=en
Laravelのバージョンや構成によっては、config/app.php 側で env を参照する形になっています。
基本は「最終的に config/app.php の locale が決まる」と覚えてください。

4) コードで翻訳を取得する方法

4-1. PHP側(Controller/Service)で取得

<?php

// ① __() ヘルパーで翻訳取得
$title = __('messages.app_name');

// ② プレースホルダ置換
$welcome = __('messages.welcome', ['name' => 'Mao']);

// ③ 入れ子キー
$save = __('messages.buttons.save');

4-2. Blade側(画面)で取得

<!-- Bladeテンプレート -->

<h1>{{ __('messages.app_name') }}</h1>

<p>{{ __('messages.welcome', ['name' => $userName]) }}</p>

<button>{{ __('messages.buttons.save') }}</button>
初心者向け覚え方:
BladeでもPHPでも __('ファイル.キー') で取れる。

5) locale(言語)を切り替える方法

「今どの言語で表示するか」は App::setLocale() で切り替えられます。
ただし実務では「毎リクエストで自動的に設定される仕組み」にします。

5-1. まずは単発で切り替える(理解用)

<?php

use Illuminate\Support\Facades\App;

App::setLocale('en');  // 英語にする
$text = __('messages.app_name');

5-2. 実務:Middlewareで毎回 locale を設定する

よくあるパターンは次のどれかです。
  • URLで切替:/ja/..., /en/...
  • クエリで切替:?lang=ja
  • セッションで保持:選択した言語を覚える
  • ブラウザの Accept-Language を見る
初心者向けとして、ここでは「クエリ → セッション保持」の例を書きます。

5-3. Middleware 実装例(lang を決める)

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;

class SetLocale
{
    public function handle(Request $request, Closure $next)
    {
        // 1) 対応する言語一覧を決める(それ以外は許可しない)
        $supported = ['ja', 'en'];

        // 2) クエリ ?lang=xx がある場合はそれを候補にする
        $lang = $request->query('lang');

        // 3) クエリが無ければセッションの値を使う
        if ($lang === null) {
            $lang = $request->session()->get('lang');
        }

        // 4) それでも無ければデフォルト言語(config/app.php)
        if ($lang === null) {
            $lang = config('app.locale');
        }

        // 5) 許可されていない言語は弾いてデフォルトに戻す
        if (!in_array($lang, $supported, true)) {
            $lang = config('app.locale');
        }

        // 6) Laravelに「このリクエストの言語はこれ」と伝える
        App::setLocale($lang);

        // 7) 次回以降のためにセッションに保存(ユーザーの選択を覚える)
        $request->session()->put('lang', $lang);

        // 8) 次の処理へ
        return $next($request);
    }
}

5-4. Middleware の登録(どこに書く?)

Laravel の構成(バージョン)によって登録場所が違いますが、基本は「HTTPミドルウェアに登録」です。
多くのプロジェクトでは app/Http/Kernel.php に登録します。
<?php
// app/Http/Kernel.php(例)

protected $middlewareGroups = [
    'web' => [
        // ... 既存の middleware
        \App\Http\Middleware\SetLocale::class,
    ],
];

6) Blade側で言語切替リンクを出す(例)

<!-- 例:同じページで言語だけ変える -->
<a href="?lang=ja">日本語</a> |
<a href="?lang=en">English</a>

7) 翻訳キーが無い場合はどうなる?(fallback)

翻訳キーが見つからない場合は、次の順番で探します。
  1. 現在の locale(例:ja)
  2. fallback_locale(例:en)
  3. それでも無ければキー文字列がそのまま表示される

8) バリデーションメッセージの多言語化(よく使う)

Laravel には最初から lang/ja/validation.php のようなファイルがあり、
入力チェックのメッセージを多言語化できます。
<?php
// lang/ja/validation.php(例の一部)

return [
    'required' => ':attribute は必須です。',
    'email' => ':attribute はメール形式で入力してください。',
];

9) 初心者がハマる点(実務の注意)

これで「どこにファイルを置き」「どこで設定し」「どう呼び出し」「どう切り替えるか」まで一通り揃いました。

INSERTに失敗したら「画面にエラーダイアログ」で原因と対策を出す(Laravel)

仕様:ユーザー登録(users テーブルへ INSERT)を行い、
INSERTに失敗したら、画面上にエラーダイアログを表示し、
原因対策をユーザーに分かる言葉で表示する。

重要:DBエラーメッセージ(生のSQL例外)をそのまま画面に出さない。
理由:セキュリティ(内部構造が漏れる)と、ユーザーに理解できないため。

1) 実装の全体像(どこで何をするか)

役割 ここでやること
Blade(HTML) 表示だけ セッションに入ったエラーを見てダイアログを出す
Controller HTTPの窓口 Serviceの例外を捕まえて「画面用のエラー」にして戻す
Service 業務ルール DB例外を「原因」「対策」に翻訳して業務例外として投げる
Repository DB I/O INSERTを実行する(SQL / Query Builder / Eloquent)
覚え方 DBの例外(生) → Serviceで「意味のある例外」に変換 → Controllerで画面に返す → Bladeで表示

2) Blade(HTML):フォームとエラーダイアログ

ポイント:
  • old() で入力値を保持(失敗しても入力し直しが楽)
  • session('ui_error') があるときだけダイアログ表示
  • 原因と対策を分けて表示
<!-- resources/views/users/create.blade.php -->

<h3>ユーザー登録</h3>

<form method="post" action="/users">
  @csrf

  <label>名前</label><br>
  <input type="text" name="name" value="{{ old('name') }}">
  <br><br>

  <label>メールアドレス</label><br>
  <input type="email" name="email" value="{{ old('email') }}">
  <br><br>

  <button type="submit">登録</button>
</form>

<!-- ============ エラーダイアログ(モーダル) ============ -->
@if(session('ui_error'))
  <dialog open class="dialog">
    <h3>登録に失敗しました</h3>

    <p>
      <b>原因:</b>{{ session('ui_error.reason') }}
    </p>

    <p>
      <b>対策:</b>{{ session('ui_error.action') }}
    </p>

    <form method="dialog">
      <button>閉じる</button>
    </form>
  </dialog>
@endif
<dialog> はブラウザ標準の簡易モーダルです。
JSを最小限にできます(必要ならJSで見た目を整えられます)。

3) routes:画面表示と登録処理

<?php
// routes/web.php

use App\Http\Controllers\UserController;

Route::get('/users/create', [UserController::class, 'create']);
Route::post('/users', [UserController::class, 'store']);

4) 業務例外(原因と対策を持つ例外クラス)

ここが重要です。
DB例外は「技術的な失敗」なので、そのまま見せると意味が分かりません。
そこで Service が「ユーザーに説明可能な文」に変換して、
原因(reason)対策(action) を持った例外として投げます。
<?php
// app/Exceptions/UserRegisterException.php

namespace App\Exceptions;

use RuntimeException;

class UserRegisterException extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly string $action
    ) {
        // 親の例外メッセージにも reason を入れておく(ログ用)
        parent::__construct($reason);
    }
}

5) Repository:INSERT(DB I/Oのみ担当)

Repositoryは「DBアクセスだけ」。
ここに「ユーザー向けの原因・対策文」を書かない(責務が混ざる)。
<?php
// app/Repositories/UserRepository.php

namespace App\Repositories;

use Illuminate\Support\Facades\DB;

class UserRepository
{
    /**
     * users テーブルへ INSERT
     * 成功したら何も返さない(失敗したら例外が投げられる)
     */
    public function insert(string $name, string $email): void
    {
        DB::insert(
            'INSERT INTO users (name, email) VALUES (?, ?)',
            [$name, $email]
        );
    }
}

6) Service:DB例外 → 原因/対策の業務例外に変換

Serviceは「業務ルール」を扱う層です。
DBからの例外を受け取り、
ユーザー向けに意味が分かる原因/対策に変換して投げ直します。
<?php
// app/Services/UserService.php

namespace App\Services;

use Throwable;
use App\Repositories\UserRepository;
use App\Exceptions\UserRegisterException;

class UserService
{
    public function __construct(
        private UserRepository $repo
    ) {}

    /**
     * ユーザー登録(業務処理)
     */
    public function register(string $name, string $email): void
    {
        try {
            // 1) DBへ INSERT を依頼
            $this->repo->insert($name, $email);

            // 2) ここまで来たら成功(何もせず戻る)
        } catch (Throwable $e) {

            // 3) 例:メールアドレス重複(UNIQUE制約違反)をユーザー向けに言い換える
            //    ※ 実務では「例外型」や「エラーコード」を見て判定するのが理想。
            if (str_contains($e->getMessage(), 'UNIQUE')) {
                throw new UserRegisterException(
                    reason: 'このメールアドレスは既に登録されています。',
                    action: '別のメールアドレスを入力してください。'
                );
            }

            // 4) それ以外は「内部エラー」としてまとめる(詳細を漏らさない)
            throw new UserRegisterException(
                reason: 'サーバー側で問題が発生しました。',
                action: '時間をおいて再度お試しください。'
            );
        }
    }
}
注意(実務の重要ポイント):
$e->getMessage() の文字列判定は説明用として簡易です。
本番ではDBドライバの例外型やエラーコードで判定した方が安全です。
ただ初心者は、まず「DB例外をユーザー向けに翻訳する」という発想が重要です。

7) Controller:業務例外を捕まえて画面に返す

Controllerは HTTP の窓口なので、
Service の業務例外を受け取り、
「画面用(セッション)」に詰めて戻します。
<?php
// app/Http/Controllers/UserController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\UserService;
use App\Exceptions\UserRegisterException;

class UserController extends Controller
{
    public function create()
    {
        // 1) 登録画面を表示
        return view('users.create');
    }

    public function store(Request $request, UserService $service)
    {
        try {
            // 2) 入力値を取得(ここでは最小限)
            $name = (string) $request->input('name');
            $email = (string) $request->input('email');

            // 3) 登録処理を呼ぶ(失敗すると例外が飛ぶ)
            $service->register($name, $email);

        } catch (UserRegisterException $e) {

            // 4) 入力値を保持して画面に戻す + 画面用エラーをセッションに入れる
            return back()
                ->withInput()
                ->with('ui_error', [
                    'reason' => $e->reason,
                    'action' => $e->action,
                ]);
        }

        // 5) 成功したら別メッセージを出す(ここでは同じ画面に戻す例)
        return redirect('/users/create')->with('message', '登録しました');
    }
}

8) なぜこの方式が「業務で強い」のか

メリット
  • DBの内部エラーをユーザーに見せない(セキュリティ)
  • ユーザーが次に何をすべきか(対策)が分かる
  • Service層に業務判断を集約できる(保守性)
  • Bladeは表示だけにできる(責務がきれい)

9) 補足:バリデーションエラーは別ルートで扱う

INSERT失敗(DB制約など)とは別に、
「未入力」「形式が違う」は通常バリデーションで弾きます。
それは $request->validate(...) に任せ、
DB失敗は今回のように try/catch で扱う、という分担が一般的です。

バリデーションで弾いたとき、エラーメッセージはどこに表示するのか?

結論から言います。
はい。バリデーションエラーは「元の画面」に戻り、
該当する入力項目の近くにエラーメッセージを表示する
のが、
Laravelでも業務システムでも標準的なやり方です。

1) なぜ「項目の近く」に出すのか?

ユーザー視点の理由
  • どこが間違っているのか一瞬で分かる
  • 画面全体を読まなくてよい
  • 修正すべき場所に視線が自然に行く
そのため、
入力チェック(バリデーション)エラーと、
INSERT失敗などの業務エラーは、
表示場所と扱い方を分けるのが定石です。

2) Laravelのバリデーションが自動でやってくれること

Laravelでは、$request->validate() を使うと、
バリデーションに失敗した瞬間に次のことが自動で行われます。

開発者は「エラーをどう返すか」を書く必要がありません。

3) Controller側:バリデーションの書き方

<?php
// Controller の一部

public function store(Request $request)
{
    // 1) バリデーション
    //    失敗したら、この時点で自動的に元の画面へ戻る
    $validated = $request->validate([
        'name'  => ['required', 'string', 'max:50'],
        'email' => ['required', 'email'],
    ]);

    // 2) ここに来るのは「入力が正しい」場合だけ
    //    DB登録などの処理を書く
}
重要:
バリデーション失敗時は returncatch も書きません。
Laravelが内部で処理を中断します。

4) Blade側:項目ごとにエラーメッセージを表示する

4-1. 基本形(1項目ずつ表示)

<!-- 名前入力 -->
<label>名前</label><br>
<input type="text" name="name" value="{{ old('name') }}">

@error('name')
  <div class="field-error">
    {{ $message }}
  </div>
@enderror

<br>

<!-- メールアドレス入力 -->
<label>メールアドレス</label><br>
<input type="email" name="email" value="{{ old('email') }}">

@error('email')
  <div class="field-error">
    {{ $message }}
  </div>
@enderror
@error('name') は、
「name に関するエラーがあるときだけ中身を表示する」
Blade専用の便利ディレクティブです。

5) $errors の正体(何が入っているのか)

Blade で使っている $errors は、
Laravel が自動で渡してくれる「エラーの入れ物」です。

// 中身のイメージ
$errors = [
  'name'  => ['名前は必須です'],
  'email' => ['メールアドレスの形式が正しくありません'],
];
@error('name') は、
内部的には $errors->has('name') を見ています。

6) 画面上部に「まとめて表示」する場合(補助的)

項目の近くに出すのが基本ですが、
画面上部に「エラー一覧」を出すこともあります(補助用)。

@if ($errors->any())
  <div class="error-summary">
    <ul>
      @foreach ($errors->all() as $error)
        <li>{{ $error }}</li>
      @endforeach
    </ul>
  </div>
@endif
注意:
一覧だけ表示して、項目の近くに出さないのはUX的に不親切です。
一覧は「補助」として使うのが一般的です。

7) バリデーションエラーと業務エラーの違い(整理)

種類 表示場所
バリデーションエラー 未入力・形式不正 入力項目のすぐ近く
業務エラー 重複登録・業務制約 画面全体(ダイアログなど)
システムエラー DB障害・例外 共通エラーページ

8) まとめ(覚えるポイント)

  • バリデーション失敗時は「元の画面」に戻る
  • エラーは 該当項目の近くに表示する
  • @error('項目名') を使うのが定石
  • 業務エラー(INSERT失敗など)は別枠で扱う

このルールを守ると、
ユーザーにも開発者にも分かりやすい画面になります。


@error('項目名') に値はどうやって詰まるのか?(Controller側の処理)

結論から言います。
@error('phone') などに表示されるエラーメッセージは、
Controller(正確には Laravel の Validator) が自動で詰めています。

開発者は「ルール」と「メッセージ」を定義するだけで、
$errors@error まで自動的につながります。

1) 全体の流れ(何が起きているか)

HTMLフォーム送信
   ↓
Controller で validate()
   ↓
Validator が入力値をチェック
   ↓ NG
エラーメッセージ生成
   ↓
セッションに自動保存
   ↓
元の画面へリダイレクト
   ↓
Blade の @error('項目名') に表示される

2) 具体例:電話番号に英字が入っていた場合

仕様例:

3) HTML(Blade):電話番号入力欄

<!-- resources/views/user_create.blade.php -->

<label>電話番号</label><br>
<input type="text" name="phone" value="{{ old('phone') }}">

@error('phone')
  <div class="field-error">
    {{ $message }}
  </div>
@enderror
old('phone') により、
バリデーションエラー後も入力値が保持されます。

4) Controller側:validate() でルールを定義

<?php
// app/Http/Controllers/UserController.php(一部)

use Illuminate\Http\Request;

public function store(Request $request)
{
    // ① バリデーション定義
    //    phone は「数字とハイフンのみ」を許可
    $validated = $request->validate(
        [
            'phone' => [
                'required',
                'regex:/^[0-9\-]+$/',
            ],
        ],
        [
            // ② エラーメッセージ(ユーザー向け)
            'phone.required' => '電話番号は必須です。',
            'phone.regex'    => '電話番号は数字とハイフンのみで入力してください。',
        ]
    );

    // ③ ここに来るのは「バリデーションOK」の場合だけ
    //    DB登録などの処理を書く
}
重要:
validate() に失敗すると、
この関数の 下の行は一切実行されません
Laravelが自動で「元の画面へ戻る」処理を行います。

5) 何が @error('phone') に詰まるのか?

上記の validate() が失敗すると、
Laravel は内部的に次のようなエラーデータを作ります。

// 内部イメージ(開発者は直接触らない)
$errors = [
  'phone' => [
    '電話番号は数字とハイフンのみで入力してください。'
  ]
];

Blade の @error('phone') は、
この中から phone に対応する最初のメッセージ
$message として取り出しています。

6) regex の意味を分解(初心者向け)

regex:/^[0-9\-]+$/
部分 意味
^ 文字列の先頭
[0-9\-] 数字またはハイフン
+ 1文字以上
$ 文字列の末尾
つまり「最初から最後まで、数字とハイフン以外を含んではいけない」
という意味になります。

7) よくある質問:Controllerで $errors に直接詰めるの?

いいえ。
開発者が $errors に直接値を入れることは、
通常の業務では ありません

$errors は、
Laravel が「validate() に失敗したとき」に
自動で作って Blade に渡してくれるものです。

8) バリデーションエラーと業務エラーの再整理

種類 Controllerの書き方 表示方法
バリデーションエラー 英字混入、未入力 $request->validate() @error(項目の近く)
業務エラー 重複登録 try / catch ダイアログ

9) まとめ(ここを覚える)

  • @error は Controller が直接詰めているわけではない
  • validate() が失敗すると Laravel が自動でエラーを詰める
  • ルールとメッセージを書けば十分
  • 入力チェックと業務エラーは責務を分ける

この仕組みが分かると、
「どこで何を書くべきか」が一気に整理されます。

DBを使ったバリデーション(unique)の完全な書き方(Controller全文)

ここでは、
「電話番号は 数字とハイフンのみ で、
かつ users テーブルに未登録 でなければならない」
という仕様を、
Controller のクラス定義からメソッド全文で説明します。

1) 前提:この処理はどこに書くのか

このコードは次のファイルに書く想定です。
app/Http/Controllers/UserController.php

2) Controller クラスとメソッドの全体

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * ユーザー登録処理
     *
     * @param Request $request 画面から送られてきたHTTPリクエスト
     */
    public function store(Request $request)
    {
        // --------------------------------------------------
        // ① バリデーション(ここで入力チェックを行う)
        // --------------------------------------------------
        // validate() は Laravel が用意している入力チェック機能です。
        // 失敗した場合、このメソッドの処理はここで止まり、
        // 自動的に「元の画面」にリダイレクトされます。
        $validated = $request->validate(
            [
                // phone フィールドのルール定義
                'phone' => [
                    // 必須項目
                    'required',

                    // 数字とハイフンのみ許可
                    // ^  : 文字列の先頭
                    // [0-9\-] : 数字またはハイフン
                    // +  : 1文字以上
                    // $  : 文字列の末尾
                    'regex:/^[0-9\-]+$/',

                    // users テーブルの phone カラムに
                    // 同じ値が存在してはいけない
                    'unique:users,phone',
                ],
            ],
            [
                // ------------------------------
                // ② エラーメッセージ(ユーザー向け)
                // ------------------------------
                // どのルールで失敗したかに応じて、
                // Blade の @error('phone') に表示される
                'phone.required' => '電話番号は必須です。',
                'phone.regex'    => '電話番号は数字とハイフンのみで入力してください。',
                'phone.unique'   => 'この電話番号はすでに登録されています。',
            ]
        );

        // --------------------------------------------------
        // ③ ここに来るのは「すべてのバリデーションがOK」の場合だけ
        // --------------------------------------------------
        // $validated には、チェック済みの安全な値が入っています。
        // 例:
        // $validated['phone']

        // ここで DB への INSERT 処理などを行う
        // (この例では省略)
    }
}

3) validate() が裏でやっていること(重要)

$request->validate() は、
見た目以上に多くのことを自動でやっています。
  1. リクエストの入力値をすべて取得
  2. ルールに従ってチェック
  3. 失敗したら:
    • エラーメッセージを生成
    • セッションに保存
    • 元の画面へリダイレクト
  4. 成功したら:
    • チェック済みデータを配列で返す

4) Blade 側でどう表示されるか(復習)

<label>電話番号</label><br>
<input type="text" name="phone" value="{{ old('phone') }}">

@error('phone')
  <div class="field-error">
    {{ $message }}
  </div>
@enderror
  • phone.required に失敗 → 必須エラー表示
  • phone.regex に失敗 → 形式エラー表示
  • phone.unique に失敗 → 重複エラー表示

5) 初心者向けまとめ

  • Controller は クラス定義から書く
  • validate() は失敗すると自動で画面に戻る
  • DBを見るチェックも validate に書ける
  • @error は validate が自動で詰めた値を表示している

「どこでエラーが作られて、どこで表示されるか」が
これで一本の線でつながったはずです。

Validator::make() + after() のコードは削除したのか?

結論:
削除したわけではありません。
これは 「validate() では書けない場合の別ルート」として説明していました。

1) Laravelのバリデーションには「2系統」ある

方法 書き方 使いどころ
簡易型(基本) $request->validate() 形式チェック・unique・exists
拡張型(応用) Validator::make() + after() DB条件付き・複雑ロジック
つまり、
validate() の説明に進んだ時点で、after() のコードを「使わないルート」に切り替えただけです。

2) validate() で書ける場合(このとき after() は不要)

次のような条件は、validate() だけで書けます。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class UserController extends Controller
{
    public function store(Request $request)
    {
        // この場合、Validator::after() は不要
        $request->validate(
            [
                'phone' => [
                    'required',
                    'regex:/^[0-9\-]+$/',
                    'unique:users,phone',
                ],
            ],
            [
                'phone.required' => '電話番号は必須です。',
                'phone.regex'    => '電話番号は数字とハイフンのみで入力してください。',
                'phone.unique'   => 'この電話番号はすでに登録されています。',
            ]
        );

        // ここに来るのは OK の場合だけ
    }
}

3) validate() では書けない場合(ここで after() を使う)

次の条件は unique では書けません。

このときに初めて、あなたが提示したコードが必要になります。

4) Validator::make() + after() の「完全な形」(削除していない本体)

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;

class UserController extends Controller
{
    public function store(Request $request)
    {
        // ① まず基本バリデーション(DBを見ない)
        $validator = Validator::make(
            $request->all(),
            [
                'phone' => [
                    'required',
                    'regex:/^[0-9\-]+$/',
                ],
            ],
            [
                'phone.required' => '電話番号は必須です。',
                'phone.regex'    => '電話番号は数字とハイフンのみで入力してください。',
            ]
        );

        // ② DBを見て追加チェック(ここが after() の役割)
        $validator->after(function ($validator) use ($request) {

            $exists = DB::table('users')
                ->where('phone', $request->input('phone'))
                ->whereNull('deleted_at') // 論理削除を除外
                ->exists();

            if ($exists) {
                // phone フィールドにエラーを追加
                $validator->errors()->add(
                    'phone',
                    'この電話番号はすでに使用されています。'
                );
            }
        });

        // ③ ここでエラーがあれば自動的に元画面へ戻る
        $validator->validate();

        // ④ ここに来るのは「すべてOK」の場合だけ
    }
}

5) なぜ説明の途中で出てこなくなったのか

初心者向けドキュメントでは、
いきなり after() を出すと理解コストが高すぎるため、
基本ルート(validate)を優先して説明しました。

そのため、

6) 覚え方(超重要)

  • 書けるなら validate() で書く
  • 書けない条件が出たら Validator::make() + after()
  • 両者は「代替」ではなく「使い分け」

これを覚えると、
「どのバリデーション方法を使うべきか」が
状況に応じて判断できるようになります。

サンプル仕様:ユーザー検索(一覧)+ユーザー編集(2画面構成)

このサンプルは「入力 → 検索 → 一覧表示 → 行クリックで編集画面へ → 更新して戻る」という、管理画面で頻出の流れを Laravel(Blade)で学びます。
画面は ①一覧(検索+結果)②編集2画面 です。

1) 前提:users テーブル定義(この項目が存在すること)

カラム 用途 備考
id主キー整数 / Auto Increment
usernameユーザー名検索対象(前方一致)
emailメールアドレス編集対象
tel電話番号編集対象
address住所一覧に表示(編集可にしても良い)
update_at更新日時表示用(※一般的には updated_at
注意:
Laravel の標準タイムスタンプは created_at / updated_at です。
このサンプルでは要件として update_at を採用します(実務では命名ゆれに注意)。

2) ルーティング(URL と画面/処理の対応)

HTTP URL Controller@method 役割
GET /users UserController@index 検索フォーム+一覧表示(prefix 指定で前方一致検索)
GET /users/{id}/edit UserController@edit 編集画面(対象ユーザーを表示)
PUT /users/{id} UserController@update 更新処理(email / tel を更新して一覧へ戻す)
パラメータ仕様(一覧画面)
  • prefix(任意):username の前方一致(例:prefix=a → "a" で始まる username)
  • page(任意):ページネーション(導入する場合)

3) 画面仕様①:ユーザー一覧(検索+結果)

3-1. 画面に表示されるもの

3-2. 一覧テーブルの列(例)

id username email tel address update_at
1akiraakira@example.com090-xxxx-xxxx横浜市…2026-02-16 21:00
2annaanna@example.com080-xxxx-xxxx川崎市…2026-02-15 10:15
一覧の 行(または username)をクリックすると編集画面に遷移します。
例:/users/2/edit

3-3. 検索ルール

入力検証(最低限)
prefix は 0〜50文字程度に制限し、想定外の長文を弾きます(DoSっぽい入力対策)。
※クエリは Eloquent/QueryBuilder を使えばSQLインジェクションは基本的に防げます。

4) 画面仕様②:ユーザー編集(email / tel を更新)

4-1. 画面に表示されるもの

4-2. 更新ルール

✅ このサンプルで学ぶ “画面遷移の基本”
  1. 一覧は GET(検索条件はクエリパラメータ)
  2. 編集表示は GET(URLに id)
  3. 更新は PUT(フォーム送信)
  4. 成功したら Redirect(PRGパターン:二重送信防止)

5) エラーハンドリング(最低限)

Laravel(PHP)サンプル:ユーザー検索(一覧)+ユーザー編集(2画面・パターンA)

このサンプルは「ユーザー名の先頭文字で検索 → 一覧表示 → 行クリックで編集画面へ → email/telを更新して一覧へ戻る」という、業務システムでよくある基本フローを Laravel(Blade)で実装します。
UIは 2画面(①一覧+検索、②編集)で構成します。

1. 前提:users テーブル(必須カラム)

ユーザーテーブル(users)には、次の項目が存在するものとします。

カラム 用途 画面表示
id 主キー(内部識別子) ❌ 表示しない(編集URL生成・更新対象特定にのみ使用)
username ユーザー名(検索対象) ✅ 一覧に表示(クリックで編集へ)
email メールアドレス ✅ 一覧に表示 / ✅ 編集可能
tel 電話番号 ✅ 一覧に表示 / ✅ 編集可能
address 住所 ✅ 一覧に表示(このサンプルでは表示のみ)
update_at 更新日時(内部管理) ❌ 表示しない(内部管理用)
補足(命名について)
Laravelの標準は created_at / updated_at ですが、本サンプルは要件どおり update_at を使用します。
そのため、Eloquentの自動タイムスタンプ($timestamps)は無効化します。

2. 画面構成(2画面)

2-1. 画面①:ユーザー一覧(検索+結果)

2-2. 画面②:ユーザー編集(email/telのみ編集)

3. ルーティング(routes/web.php)

一覧・編集・更新の3つのルートを定義します。

<?php

use Illuminate\Support\Facades\Route;       // ルーティング定義に使う
use App\Http\Controllers\UserController;    // Controller を参照する

// 一覧(検索+結果表示)
Route::get('/users', [UserController::class, 'index'])
    ->name('users.index');

// 編集画面(表示)
Route::get('/users/{id}/edit', [UserController::class, 'edit'])
    ->whereNumber('id') // id は数値のみ
    ->name('users.edit');

// 更新(email/tel の更新)
Route::put('/users/{id}', [UserController::class, 'update'])
    ->whereNumber('id') // id は数値のみ
    ->name('users.update');

4. ファイル分割(業務レベルの構成)

Controller直書きにせず、業務コードとして層を分けます。

routes/
  web.php

app/
  Http/
    Controllers/
      UserController.php
    Requests/
      UserSearchRequest.php
      UserUpdateRequest.php
  Services/
    UserService.php
    I18nService.php
  Repositories/
    Contracts/
      UserRepositoryInterface.php
      I18nRepositoryInterface.php
    Eloquent/
      UserRepository.php
      I18nRepository.php
  Dtos/
    UserListItemDto.php
    UserEditDto.php
    UserUpdateDto.php
  Models/
    User.php
    I18nMessage.php
  Exceptions/
    RepositoryException.php
    ServiceException.php
    AppMessageKeyException.php
  Support/
    I18n/
      I18n.php          (Facade/Helper)
      I18nKeys.php      (キー定数)
      LocaleResolver.php
  Providers/
    AppServiceProvider.php

resources/
  views/
    users/
      index.blade.php
      edit.blade.php

database/
  migrations/
    2026_02_16_000001_create_users_table.php
    2026_02_16_000002_create_i18n_messages_table.php

5. DB(SQLite)設定

5-1. .env(SQLite接続)

SQLiteはDBファイルが存在しないと接続エラーになります。
事前に database/database.sqlite を作成してください。
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:PLEASE_GENERATE
APP_DEBUG=true
APP_URL=http://localhost

DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/your-project/database/database.sqlite

5-2. Migration(usersテーブル作成)

<?php

use Illuminate\Database\Migrations\Migration; // マイグレーション基底クラス
use Illuminate\Database\Schema\Blueprint;      // テーブル定義に使う
use Illuminate\Support\Facades\Schema;         // Schemaビルダー

return new class extends Migration {

    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {

            $table->id(); // id(主キー / auto increment)

            $table->string('username', 100); // username(検索対象)
            $table->string('email', 255);    // email(編集対象)
            $table->string('tel', 50)->nullable(); // tel(編集対象 / null可)
            $table->string('address', 255)->nullable(); // address(表示用 / null可)

            // 要件:update_at(一般的な updated_at ではない)
            $table->timestamp('update_at')->nullable();

            $table->index('username');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

6. Entity(Eloquent Model)

Eloquentモデル(Entity相当)として app/Models/User.php を定義します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'users';

    protected $fillable = [
        'username',
        'email',
        'tel',
        'address',
        'update_at',
    ];

    public $timestamps = false;
}

7. DTO(画面・層間のデータ受け渡し)

画面に不要な項目を混ぜないため、DTOで扱うカラムを明確にします。

7-1. UserListItemDto(一覧の1行分)

<?php

namespace App\Dtos;

class UserListItemDto
{
    public function __construct(
        public int $id,             // 画面表示はしないが、編集URL生成に必要
        public string $username,    // 一覧表示
        public string $email,       // 一覧表示
        public ?string $tel,        // 一覧表示(null可)
        public ?string $address     // 一覧表示(null可)
    ) {}
}

7-2. UserEditDto(編集画面表示用)

<?php

namespace App\Dtos;

class UserEditDto
{
    public function __construct(
        public int $id,
        public string $username,
        public string $email,
        public ?string $tel,
        public ?string $address
    ) {}
}

7-3. UserUpdateDto(更新入力用)

<?php

namespace App\Dtos;

class UserUpdateDto
{
    public function __construct(
        public int $id,
        public string $email,
        public ?string $tel
    ) {}
}

8. 例外(層ごとに例外を分ける)

Repository層とService層で例外クラスを分け、責務を明確化します。

8-1. RepositoryException

<?php

namespace App\Exceptions;

use RuntimeException;

class RepositoryException extends RuntimeException
{
}

8-2. ServiceException

<?php

namespace App\Exceptions;

use RuntimeException;

class ServiceException extends RuntimeException
{
}

8-3. AppMessageKeyException(メッセージ文字列は禁止:キーだけを運ぶ)

<?php

namespace App\Exceptions;

use RuntimeException;

// 「文字列」ではなく「メッセージキー」を保持する例外
class AppMessageKeyException extends RuntimeException
{
    public function __construct(
        public string $messageKey,
        int $code = 0,
        ?\Throwable $previous = null
    ) {
        // 例外メッセージ欄にもキーだけを入れる(ログ用途)
        parent::__construct($messageKey, $code, $previous);
    }
}

9. Repository(Interface + 実装)

9-1. UserRepositoryInterface(契約)

<?php

namespace App\Repositories\Contracts;

use App\Dtos\UserEditDto;
use App\Dtos\UserListItemDto;
use App\Dtos\UserUpdateDto;

interface UserRepositoryInterface
{
    /** @return UserListItemDto[] */
    public function searchByUsernamePrefix(?string $prefix, int $limit = 50): array;

    public function findEditDtoById(int $id): ?UserEditDto;

    public function updateContact(UserUpdateDto $dto): bool;
}

9-2. UserRepository(Eloquent実装)※例外は「キー」で投げる

<?php

namespace App\Repositories\Eloquent;

use App\Dtos\UserEditDto;
use App\Dtos\UserListItemDto;
use App\Dtos\UserUpdateDto;
use App\Exceptions\AppMessageKeyException;   // 文字列禁止:キー例外
use App\Models\User;
use App\Repositories\Contracts\UserRepositoryInterface;
use Illuminate\Support\Facades\Log;

class UserRepository implements UserRepositoryInterface
{
    public function searchByUsernamePrefix(?string $prefix, int $limit = 50): array
    {
        try {
            $query = User::query()
                ->select(['id', 'username', 'email', 'tel', 'address']);

            if ($prefix !== null && $prefix !== '') {
                $query->where('username', 'like', $prefix . '%');
            }

            $rows = $query->orderBy('username')->limit($limit)->get();

            $dtos = [];
            foreach ($rows as $u) {
                $dtos[] = new UserListItemDto(
                    id: (int)$u->id,
                    username: (string)$u->username,
                    email: (string)$u->email,
                    tel: $u->tel !== null ? (string)$u->tel : null,
                    address: $u->address !== null ? (string)$u->address : null
                );
            }
            return $dtos;

        } catch (\Throwable $e) {
            Log::error('[UserRepository] searchByUsernamePrefix failed', [
                'prefix' => $prefix,
                'limit' => $limit,
                'error' => $e->getMessage(),
            ]);

            // 文字列を禁止:キーだけ投げる
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_DB_QUERY_FAILED, 0, $e);
        }
    }

    public function findEditDtoById(int $id): ?UserEditDto
    {
        try {
            $u = User::query()
                ->select(['id', 'username', 'email', 'tel', 'address'])
                ->where('id', $id)
                ->first();

            if ($u === null) {
                return null;
            }

            return new UserEditDto(
                id: (int)$u->id,
                username: (string)$u->username,
                email: (string)$u->email,
                tel: $u->tel !== null ? (string)$u->tel : null,
                address: $u->address !== null ? (string)$u->address : null
            );

        } catch (\Throwable $e) {
            Log::error('[UserRepository] findEditDtoById failed', [
                'id' => $id,
                'error' => $e->getMessage(),
            ]);

            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_DB_QUERY_FAILED, 0, $e);
        }
    }

    public function updateContact(UserUpdateDto $dto): bool
    {
        try {
            $u = User::query()->where('id', $dto->id)->first();
            if ($u === null) {
                return false;
            }

            $u->email = $dto->email;
            $u->tel = $dto->tel;
            $u->update_at = now();
            $u->save();

            return true;

        } catch (\Throwable $e) {
            Log::error('[UserRepository] updateContact failed', [
                'id' => $dto->id,
                'error' => $e->getMessage(),
            ]);

            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_DB_UPDATE_FAILED, 0, $e);
        }
    }
}

10. Service(業務ロジック層)

Serviceも「文字列禁止」。例外はキーで受けて上位へ伝播します。

<?php

namespace App\Services;

use App\Dtos\UserEditDto;
use App\Dtos\UserUpdateDto;
use App\Exceptions\AppMessageKeyException;
use App\Repositories\Contracts\UserRepositoryInterface;
use Illuminate\Support\Facades\Log;

class UserService
{
    public function __construct(
        private readonly UserRepositoryInterface $userRepo
    ) {}

    public function search(?string $prefix): array
    {
        try {
            return $this->userRepo->searchByUsernamePrefix($prefix, 50);
        } catch (AppMessageKeyException $e) {
            Log::warning('[UserService] search failed', ['key' => $e->messageKey]);
            throw $e; // キーのまま上位へ
        } catch (\Throwable $e) {
            Log::error('[UserService] search unexpected', ['error' => $e->getMessage()]);
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_UNEXPECTED, 0, $e);
        }
    }

    public function getEditDto(int $id): ?UserEditDto
    {
        try {
            return $this->userRepo->findEditDtoById($id);
        } catch (AppMessageKeyException $e) {
            Log::warning('[UserService] getEditDto failed', ['key' => $e->messageKey]);
            throw $e;
        } catch (\Throwable $e) {
            Log::error('[UserService] getEditDto unexpected', ['error' => $e->getMessage()]);
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_UNEXPECTED, 0, $e);
        }
    }

    public function updateContact(UserUpdateDto $dto): bool
    {
        try {
            return $this->userRepo->updateContact($dto);
        } catch (AppMessageKeyException $e) {
            Log::warning('[UserService] updateContact failed', ['key' => $e->messageKey]);
            throw $e;
        } catch (\Throwable $e) {
            Log::error('[UserService] updateContact unexpected', ['error' => $e->getMessage()]);
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_UNEXPECTED, 0, $e);
        }
    }
}

11. DI(Repositoryのバインド)

Interfaceと実装を結びつけ、Controller/Service側は契約に依存します。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\Eloquent\UserRepository;

use App\Repositories\Contracts\I18nRepositoryInterface;
use App\Repositories\Eloquent\I18nRepository;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(UserRepositoryInterface::class, UserRepository::class);
        $this->app->bind(I18nRepositoryInterface::class, I18nRepository::class);
    }

    public function boot(): void
    {
        //
    }
}

12. FormRequest(入力バリデーション)

バリデーションメッセージも「文字列禁止」。キーで返し、I18nで解決します。

12-1. UserSearchRequest(検索)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserSearchRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'prefix' => ['nullable', 'string', 'max:50'],
        ];
    }

    // 文字列禁止:メッセージはキーを返す
    public function messages(): array
    {
        return [
            'prefix.string' => \App\Support\I18n\I18nKeys::V_PREFIX_STRING,
            'prefix.max'    => \App\Support\I18n\I18nKeys::V_PREFIX_MAX,
        ];
    }

    // 文字列禁止:属性名もキーにする
    public function attributes(): array
    {
        return [
            'prefix' => \App\Support\I18n\I18nKeys::A_PREFIX,
        ];
    }
}

12-2. UserUpdateRequest(更新)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255'],
            'tel'   => ['nullable', 'string', 'max:50'],
        ];
    }

    public function messages(): array
    {
        return [
            'email.required' => \App\Support\I18n\I18nKeys::V_EMAIL_REQUIRED,
            'email.email'    => \App\Support\I18n\I18nKeys::V_EMAIL_EMAIL,
            'email.max'      => \App\Support\I18n\I18nKeys::V_EMAIL_MAX,
            'tel.max'        => \App\Support\I18n\I18nKeys::V_TEL_MAX,
        ];
    }

    public function attributes(): array
    {
        return [
            'email' => \App\Support\I18n\I18nKeys::A_EMAIL,
            'tel'   => \App\Support\I18n\I18nKeys::A_TEL,
        ];
    }
}

13. Controller(HTTP責務:PRG、404、例外、フラッシュメッセージ)

Controllerは「キー」だけ受け取り、表示直前に I18n で文字列へ解決します。

<?php

namespace App\Http\Controllers;

use App\Dtos\UserUpdateDto;
use App\Http\Requests\UserSearchRequest;
use App\Http\Requests\UserUpdateRequest;
use App\Services\UserService;
use App\Support\I18n\I18n;                 // 文字列解決サービス
use App\Support\I18n\I18nKeys;             // キー定数
use App\Exceptions\AppMessageKeyException; // キー例外
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Illuminate\Support\Facades\Log;

class UserController extends Controller
{
    public function __construct(
        private readonly UserService $userService
    ) {}

    public function index(UserSearchRequest $request): View
    {
        $prefix = $request->input('prefix');

        try {
            $users = $this->userService->search($prefix);

            return view('users.index', [
                'prefix' => $prefix,
                'users'  => $users,
            ]);

        } catch (AppMessageKeyException $e) {

            Log::error('[UserController] index failed', [
                'key'    => $e->messageKey,
                'prefix' => $prefix,
            ]);

            return view('users.index', [
                'prefix'      => $prefix,
                'users'       => [],
                'globalError' => I18n::t($e->messageKey), // 表示直前に解決
            ]);

        } catch (\Throwable $e) {

            Log::error('[UserController] index unexpected', [
                'prefix' => $prefix,
                'error'  => $e->getMessage(),
            ]);

            return view('users.index', [
                'prefix'      => $prefix,
                'users'       => [],
                'globalError' => I18n::t(I18nKeys::E_UNEXPECTED),
            ]);
        }
    }

    public function edit(int $id): View
    {
        try {
            $dto = $this->userService->getEditDto($id);

            if ($dto === null) {
                abort(404, I18n::t(I18nKeys::E_USER_NOT_FOUND));
            }

            return view('users.edit', [
                'user' => $dto,
            ]);

        } catch (AppMessageKeyException $e) {

            Log::error('[UserController] edit failed', [
                'id'  => $id,
                'key' => $e->messageKey,
            ]);

            abort(500, I18n::t($e->messageKey));

        } catch (\Throwable $e) {

            Log::error('[UserController] edit unexpected', [
                'id'    => $id,
                'error' => $e->getMessage(),
            ]);

            abort(500, I18n::t(I18nKeys::E_UNEXPECTED));
        }
    }

    public function update(UserUpdateRequest $request, int $id): RedirectResponse
    {
        $email = $request->input('email');
        $tel   = $request->input('tel');

        try {
            $ok = $this->userService->updateContact(
                new UserUpdateDto(id: $id, email: $email, tel: $tel)
            );

            if (!$ok) {
                return redirect()
                    ->route('users.index')
                    ->with('error', I18n::t(I18nKeys::E_USER_NOT_FOUND));
            }

            return redirect()
                ->route('users.index')
                ->with('success', I18n::t(I18nKeys::S_USER_UPDATED));

        } catch (AppMessageKeyException $e) {

            Log::error('[UserController] update failed', [
                'id'  => $id,
                'key' => $e->messageKey,
            ]);

            return back()
                ->withInput()
                ->with('error', I18n::t($e->messageKey));

        } catch (\Throwable $e) {

            Log::error('[UserController] update unexpected', [
                'id'    => $id,
                'error' => $e->getMessage(),
            ]);

            return back()
                ->withInput()
                ->with('error', I18n::t(I18nKeys::E_UNEXPECTED));
        }
    }
}

14. Blade(画面テンプレート:2画面)

Bladeも「文字列禁止」。固定文言はすべて I18n のキー参照にします。

14-1. 一覧画面(resources/views/users/index.blade.php)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_INDEX) }}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif; margin: 24px; }
    .box{ border: 1px solid #ddd; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
    .ok{ border-left: 6px solid #22c55e; background: #f0fdf4; padding: 12px; border-radius: 8px; }
    .warn{ border-left: 6px solid #f59e0b; background: #fffbeb; padding: 12px; border-radius: 8px; }
    .err{ border-left: 6px solid #ef4444; background: #fef2f2; padding: 12px; border-radius: 8px; }
    table{ width: 100%; border-collapse: collapse; }
    th, td{ border-bottom: 1px solid #eee; padding: 10px; text-align: left; }
    a{ color: #2563eb; text-decoration: none; }
    a:hover{ text-decoration: underline; }
    .muted{ color: #666; font-size: 12px; }
    input{ padding: 8px 10px; width: 240px; }
    button{ padding: 8px 12px; cursor: pointer; }
  </style>
</head>
<body>

  <h1>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_INDEX) }}</h1>

  @if(session('success'))
    <div class="ok">{{ session('success') }}</div>
  @endif

  @if(session('error'))
    <div class="err">{{ session('error') }}</div>
  @endif

  @if(!empty($globalError))
    <div class="err">{{ $globalError }}</div>
  @endif

  <div class="box">
    <form method="GET" action="{{ route('users.index') }}">

      <label>
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_PREFIX) }}
        <input
          type="text"
          name="prefix"
          value="{{ old('prefix', $prefix) }}"
          placeholder="{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::P_PREFIX) }}"
        >
      </label>

      <button type="submit">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_SEARCH) }}</button>

      <a href="{{ route('users.index') }}" class="muted" style="margin-left:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_CLEAR) }}
      </a>

      @error('prefix')
        {{-- バリデーションが返す $message は「キー」なので、I18nで解決して表示 --}}
        <div class="warn" style="margin-top:10px;">{{ \App\Support\I18n\I18n::t($message) }}</div>
      @enderror

      <div class="muted" style="margin-top:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_PREFIX_RULE) }}
      </div>

    </form>
  </div>

  <div class="box">
    <h2>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_LIST) }}</h2>

    @if(empty($users) || count($users) === 0)
      <div class="warn">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_NO_RESULT) }}</div>
    @else
      <table>
        <thead>
          <tr>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_USERNAME) }}</th>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_EMAIL) }}</th>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_TEL) }}</th>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_ADDRESS) }}</th>
          </tr>
        </thead>
        <tbody>
          @foreach($users as $u)
            <tr>
              <td>
                <a href="{{ route('users.edit', ['id' => $u->id]) }}">{{ $u->username }}</a>
              </td>
              <td>{{ $u->email }}</td>
              <td>{{ $u->tel ?? '' }}</td>
              <td>{{ $u->address ?? '' }}</td>
            </tr>
          @endforeach
        </tbody>
      </table>

      <div class="muted" style="margin-top:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_CLICK_TO_EDIT) }}
      </div>
    @endif
  </div>

</body>
</html>

14-2. 編集画面(resources/views/users/edit.blade.php)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_EDIT) }}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif; margin: 24px; }
    .box{ border: 1px solid #ddd; padding: 16px; border-radius: 8px; margin-bottom: 16px; max-width: 640px; }
    .warn{ border-left: 6px solid #f59e0b; background: #fffbeb; padding: 12px; border-radius: 8px; }
    .err{ border-left: 6px solid #ef4444; background: #fef2f2; padding: 12px; border-radius: 8px; }
    label{ display:block; margin-top: 12px; }
    input{ padding: 8px 10px; width: 100%; box-sizing: border-box; }
    button{ padding: 8px 12px; cursor: pointer; margin-top: 14px; }
    .muted{ color: #666; font-size: 12px; }
  </style>
</head>
<body>

  <h1>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_EDIT) }}</h1>

  @if(session('error'))
    <div class="err">{{ session('error') }}</div>
  @endif

  <div class="box">

    <div>
      <div class="muted">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_USERNAME) }}</div>
      <div><b>{{ $user->username }}</b></div>
    </div>

    <div style="margin-top:12px;">
      <div class="muted">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_ADDRESS) }}</div>
      <div>{{ $user->address ?? '' }}</div>
    </div>

    <hr style="margin: 16px 0; border: none; border-top: 1px solid #eee;">

    <form method="POST" action="{{ route('users.update', ['id' => $user->id]) }}">
      @csrf
      @method('PUT')

      <label>
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_EMAIL) }}
        <input type="text" name="email" value="{{ old('email', $user->email) }}">
      </label>
      @error('email')
        <div class="warn">{{ \App\Support\I18n\I18n::t($message) }}</div>
      @enderror

      <label>
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_TEL) }}
        <input type="text" name="tel" value="{{ old('tel', $user->tel) }}">
      </label>
      @error('tel')
        <div class="warn">{{ \App\Support\I18n\I18n::t($message) }}</div>
      @enderror

      <button type="submit">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_SAVE) }}</button>

      <a href="{{ route('users.index') }}" style="margin-left:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_BACK_TO_LIST) }}
      </a>

      <div class="muted" style="margin-top:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_EDIT_RULE) }}
      </div>

    </form>
  </div>

</body>
</html>

15. 多言語化(「ソース内に文字列を一切ハードコードしない」方式)

前提(あなたの要求の定義)
本節では 「resources/lang/*.php に文字列を書くことも禁止」 とします。
つまり、アプリ(リポジトリ含む)に 表示文言そのものは1文字も置かず、すべて外部リソースから取得します。
そのため、Laravel標準の __('...')(lang配列)方式は使わず、I18nサービス + DB辞書に統一します。

15-1. 外部多言語辞書(DBテーブル:i18n_messages)

翻訳文言は DBに保持します(SQLiteでも可)。
アプリは キーロケール から文字列を取得し、表示します。
※ DBの中身(翻訳文章)は「運用データ」であり、ソースコードには含めません(要件:文字列ハードコード禁止)。

15-2. Migration(i18n_messages テーブル)

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {

    public function up(): void
    {
        Schema::create('i18n_messages', function (Blueprint $table) {

            $table->id();

            // 例: ja / en / th など
            $table->string('locale', 10);

            // 例: ui.titles.user_index / errors.db_query_failed など
            $table->string('msg_key', 200);

            // 実際の表示文字列(※ソースには置かない:DBデータとして運用)
            $table->text('msg_value');

            // 重複防止
            $table->unique(['locale', 'msg_key']);

            // 参照が多いので索引
            $table->index(['locale', 'msg_key']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('i18n_messages');
    }
};

15-3. Entity(I18nMessage Model)

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class I18nMessage extends Model
{
    protected $table = 'i18n_messages';

    protected $fillable = [
        'locale',
        'msg_key',
        'msg_value',
    ];

    public $timestamps = false;
}

15-4. I18nKeys(キーの定数化:文字列禁止のため)

「キー文字列」すら散らばらないように、キーは定数で一元管理します。
※キーそのものは文言ではないため、ここでは許可(ただし “定数化” して散らばりを防止)。

<?php

namespace App\Support\I18n;

final class I18nKeys
{
    // titles
    public const T_USER_INDEX   = 'ui.titles.user_index';
    public const T_USER_LIST    = 'ui.titles.user_list';
    public const T_USER_EDIT    = 'ui.titles.user_edit';

    // labels
    public const L_PREFIX   = 'ui.labels.prefix';
    public const L_USERNAME = 'ui.labels.username';
    public const L_EMAIL    = 'ui.labels.email';
    public const L_TEL      = 'ui.labels.tel';
    public const L_ADDRESS  = 'ui.labels.address';

    // placeholders
    public const P_PREFIX = 'ui.placeholders.prefix';

    // actions
    public const A_SEARCH       = 'ui.actions.search';
    public const A_CLEAR        = 'ui.actions.clear';
    public const A_SAVE         = 'ui.actions.save';
    public const A_BACK_TO_LIST = 'ui.actions.back_to_list';

    // columns
    public const C_USERNAME = 'ui.columns.username';
    public const C_EMAIL    = 'ui.columns.email';
    public const C_TEL      = 'ui.columns.tel';
    public const C_ADDRESS  = 'ui.columns.address';

    // notes
    public const N_PREFIX_RULE   = 'ui.notes.prefix_rule';
    public const N_NO_RESULT     = 'ui.notes.no_result';
    public const N_CLICK_TO_EDIT = 'ui.notes.click_to_edit';
    public const N_EDIT_RULE     = 'ui.notes.edit_rule';

    // success
    public const S_USER_UPDATED = 'success.user_updated';

    // errors
    public const E_USER_NOT_FOUND    = 'errors.user_not_found';
    public const E_DB_QUERY_FAILED   = 'errors.db_query_failed';
    public const E_DB_UPDATE_FAILED  = 'errors.db_update_failed';
    public const E_UNEXPECTED        = 'errors.unexpected';

    // validation attributes (属性名)
    public const A_PREFIX = 'validation.attributes.prefix';
    public const A_EMAIL  = 'validation.attributes.email';
    public const A_TEL    = 'validation.attributes.tel';

    // validation rules (メッセージ)
    public const V_PREFIX_STRING  = 'validation.rules.prefix.string';
    public const V_PREFIX_MAX     = 'validation.rules.prefix.max';
    public const V_EMAIL_REQUIRED = 'validation.rules.email.required';
    public const V_EMAIL_EMAIL    = 'validation.rules.email.email';
    public const V_EMAIL_MAX      = 'validation.rules.email.max';
    public const V_TEL_MAX        = 'validation.rules.tel.max';
}

15-5. I18nRepository(DBから取得:文字列はDBデータのみ)

<?php

namespace App\Repositories\Contracts;

interface I18nRepositoryInterface
{
    // キーとロケールでメッセージを取得(無ければ null)
    public function findValue(string $locale, string $msgKey): ?string;
}
<?php

namespace App\Repositories\Eloquent;

use App\Models\I18nMessage;
use App\Repositories\Contracts\I18nRepositoryInterface;

class I18nRepository implements I18nRepositoryInterface
{
    public function findValue(string $locale, string $msgKey): ?string
    {
        $row = I18nMessage::query()
            ->select(['msg_value'])
            ->where('locale', $locale)
            ->where('msg_key', $msgKey)
            ->first();

        return $row ? (string)$row->msg_value : null;
    }
}

15-6. I18nService / Helper(キーから文字列を解決)

表示直前に I18n::t(KEY) を呼び、DB辞書から文字列を解決します。
もし辞書に存在しない場合は「キーをそのまま返す」などの運用ポリシーにできます(※ここでは例示のみ。文言は一切埋め込まない)。

<?php

namespace App\Services;

use App\Repositories\Contracts\I18nRepositoryInterface;
use Illuminate\Support\Facades\Cache;

class I18nService
{
    public function __construct(
        private readonly I18nRepositoryInterface $repo
    ) {}

    public function translate(string $locale, string $key): string
    {
        // 高頻度アクセスなのでキャッシュ(キー+ロケール単位)
        $cacheKey = 'i18n:' . $locale . ':' . $key;

        return Cache::remember($cacheKey, 300, function () use ($locale, $key) {

            // DBから取得(文字列はDBデータのみ)
            $val = $this->repo->findValue($locale, $key);

            // 無い場合:キーを返す(文字列を埋め込まないためのフォールバック)
            return $val ?? $key;
        });
    }
}
<?php

namespace App\Support\I18n;

use App\Services\I18nService;

// Blade/Controller から呼びやすい静的ヘルパ
final class I18n
{
    public static function t(string $key): string
    {
        // ロケール決定(例:config/app.php, session, header 等)
        $locale = app(LocaleResolver::class)->resolve();

        return app(I18nService::class)->translate($locale, $key);
    }
}

15-7. LocaleResolver(ロケール決定)

<?php

namespace App\Support\I18n;

use Illuminate\Http\Request;

class LocaleResolver
{
    public function __construct(
        private readonly Request $request
    ) {}

    public function resolve(): string
    {
        // 例:?lang=ja / ?lang=en を優先(業務では session / user設定 / header 等に合わせる)
        $lang = (string)$this->request->query('lang', '');

        // 許可された言語だけに限定(値は config へ寄せることが多い)
        if ($lang === 'ja' || $lang === 'en') {
            return $lang;
        }

        // 既定は ja(※文言ではないのでOK)
        return 'ja';
    }
}

15-8. 「バリデーション文言がキーのまま」になる点の扱い(この仕様での正解)

16. 実行手順(SQLiteで起動)

# 1) SQLite DBファイルを作成(空でOK)
touch database/database.sqlite

# 2) .env の DB_DATABASE をプロジェクトの絶対パスに合わせる

# 3) マイグレーション実行
php artisan migrate

# 4) i18n_messages に翻訳データを投入(※運用データとして投入。ソースに埋め込まない)
#    例:CSVインポート、管理画面、別DBから同期、翻訳サービス連携など

# 5) 開発サーバ起動
php artisan serve

# 6) ブラウザでアクセス(例:?lang=ja / ?lang=en)
# http://localhost:8000/users?lang=ja
# http://localhost:8000/users?lang=en
✅ 仕様チェック(要件の満たし方)
  • 検索入力に ausername LIKE 'a%' で一覧表示(前方一致)
  • 一覧の列は username/email/tel/address のみ表示(id/update_at は非表示)
  • usernameクリックで /users/{id}/edit に遷移
  • 編集できるのは email/tel のみ
  • 表示文言は DB(i18n_messages)から取得し、ソース内に文字列を持たない(キー参照のみ)
  • DBはSQLite(.env + sqliteファイル作成 + migrate)

Laravel(PHP)サンプル:ユーザー検索(一覧)+ユーザー編集(2画面・パターンA)

このサンプルは「ユーザー名の先頭文字で検索 → 一覧表示 → 行クリックで編集画面へ → email/telを更新して一覧へ戻る」という、業務システムでよくある基本フローを Laravel(Blade)で実装します。
UIは 2画面(①一覧+検索、②編集)で構成します。

1. 前提:users テーブル(必須カラム)

ユーザーテーブル(users)には、次の項目が存在するものとします。

カラム 用途 画面表示
id 主キー(内部識別子) ❌ 表示しない(編集URL生成・更新対象特定にのみ使用)
username ユーザー名(検索対象) ✅ 一覧に表示(クリックで編集へ)
email メールアドレス ✅ 一覧に表示 / ✅ 編集可能
tel 電話番号 ✅ 一覧に表示 / ✅ 編集可能
address 住所 ✅ 一覧に表示(このサンプルでは表示のみ)
update_at 更新日時(内部管理) ❌ 表示しない(内部管理用)
補足(命名について)
Laravelの標準は created_at / updated_at ですが、本サンプルは要件どおり update_at を使用します。
そのため、Eloquentの自動タイムスタンプ($timestamps)は無効化します。

2. 画面構成(2画面)

2-1. 画面①:ユーザー一覧(検索+結果)

2-2. 画面②:ユーザー編集(email/telのみ編集)

3. ルーティング(routes/web.php)

一覧・編集・更新の3つのルートを定義します。

<?php

use Illuminate\Support\Facades\Route;       // ルーティング定義に使う
use App\Http\Controllers\UserController;    // Controller を参照する

// 一覧(検索+結果表示)
Route::get('/users', [UserController::class, 'index'])
    ->name('users.index');

// 編集画面(表示)
Route::get('/users/{id}/edit', [UserController::class, 'edit'])
    ->whereNumber('id') // id は数値のみ
    ->name('users.edit');

// 更新(email/tel の更新)
Route::put('/users/{id}', [UserController::class, 'update'])
    ->whereNumber('id') // id は数値のみ
    ->name('users.update');

4. ファイル分割(業務レベルの構成)

Controller直書きにせず、業務コードとして層を分けます。

routes/
  web.php

app/
  Http/
    Controllers/
      UserController.php
    Requests/
      UserSearchRequest.php
      UserUpdateRequest.php
  Services/
    UserService.php
    I18nService.php
  Repositories/
    Contracts/
      UserRepositoryInterface.php
      I18nRepositoryInterface.php
    Eloquent/
      UserRepository.php
      I18nRepository.php
  Dtos/
    UserListItemDto.php
    UserEditDto.php
    UserUpdateDto.php
  Models/
    User.php
    I18nMessage.php
  Exceptions/
    RepositoryException.php
    ServiceException.php
    AppMessageKeyException.php
  Support/
    I18n/
      I18n.php          (Facade/Helper)
      I18nKeys.php      (キー定数)
      LocaleResolver.php
  Providers/
    AppServiceProvider.php

resources/
  views/
    users/
      index.blade.php
      edit.blade.php

database/
  migrations/
    2026_02_16_000001_create_users_table.php
    2026_02_16_000002_create_i18n_messages_table.php

5. DB(SQLite)設定

5-1. .env(SQLite接続)

SQLiteはDBファイルが存在しないと接続エラーになります。
事前に database/database.sqlite を作成してください。
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:PLEASE_GENERATE
APP_DEBUG=true
APP_URL=http://localhost

DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/your-project/database/database.sqlite

5-2. Migration(usersテーブル作成)

<?php

use Illuminate\Database\Migrations\Migration; // マイグレーション基底クラス
use Illuminate\Database\Schema\Blueprint;      // テーブル定義に使う
use Illuminate\Support\Facades\Schema;         // Schemaビルダー

return new class extends Migration {

    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {

            $table->id(); // id(主キー / auto increment)

            $table->string('username', 100); // username(検索対象)
            $table->string('email', 255);    // email(編集対象)
            $table->string('tel', 50)->nullable(); // tel(編集対象 / null可)
            $table->string('address', 255)->nullable(); // address(表示用 / null可)

            // 要件:update_at(一般的な updated_at ではない)
            $table->timestamp('update_at')->nullable();

            $table->index('username');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

6. Entity(Eloquent Model)

Eloquentモデル(Entity相当)として app/Models/User.php を定義します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'users';

    protected $fillable = [
        'username',
        'email',
        'tel',
        'address',
        'update_at',
    ];

    public $timestamps = false;
}

7. DTO(画面・層間のデータ受け渡し)

画面に不要な項目を混ぜないため、DTOで扱うカラムを明確にします。

7-1. UserListItemDto(一覧の1行分)

<?php

namespace App\Dtos;

class UserListItemDto
{
    public function __construct(
        public int $id,             // 画面表示はしないが、編集URL生成に必要
        public string $username,    // 一覧表示
        public string $email,       // 一覧表示
        public ?string $tel,        // 一覧表示(null可)
        public ?string $address     // 一覧表示(null可)
    ) {}
}

7-2. UserEditDto(編集画面表示用)

<?php

namespace App\Dtos;

class UserEditDto
{
    public function __construct(
        public int $id,
        public string $username,
        public string $email,
        public ?string $tel,
        public ?string $address
    ) {}
}

7-3. UserUpdateDto(更新入力用)

<?php

namespace App\Dtos;

class UserUpdateDto
{
    public function __construct(
        public int $id,
        public string $email,
        public ?string $tel
    ) {}
}

8. 例外(層ごとに例外を分ける)

Repository層とService層で例外クラスを分け、責務を明確化します。

8-1. RepositoryException

<?php

namespace App\Exceptions;

use RuntimeException;

class RepositoryException extends RuntimeException
{
}

8-2. ServiceException

<?php

namespace App\Exceptions;

use RuntimeException;

class ServiceException extends RuntimeException
{
}

8-3. AppMessageKeyException(メッセージ文字列は禁止:キーだけを運ぶ)

<?php

namespace App\Exceptions;

use RuntimeException;

// 「文字列」ではなく「メッセージキー」を保持する例外
class AppMessageKeyException extends RuntimeException
{
    public function __construct(
        public string $messageKey,
        int $code = 0,
        ?\Throwable $previous = null
    ) {
        // 例外メッセージ欄にもキーだけを入れる(ログ用途)
        parent::__construct($messageKey, $code, $previous);
    }
}

9. Repository(Interface + 実装)

9-1. UserRepositoryInterface(契約)

<?php

namespace App\Repositories\Contracts;

use App\Dtos\UserEditDto;
use App\Dtos\UserListItemDto;
use App\Dtos\UserUpdateDto;

interface UserRepositoryInterface
{
    /** @return UserListItemDto[] */
    public function searchByUsernamePrefix(?string $prefix, int $limit = 50): array;

    public function findEditDtoById(int $id): ?UserEditDto;

    public function updateContact(UserUpdateDto $dto): bool;
}

9-2. UserRepository(Eloquent実装)※例外は「キー」で投げる

<?php

namespace App\Repositories\Eloquent;

use App\Dtos\UserEditDto;
use App\Dtos\UserListItemDto;
use App\Dtos\UserUpdateDto;
use App\Exceptions\AppMessageKeyException;   // 文字列禁止:キー例外
use App\Models\User;
use App\Repositories\Contracts\UserRepositoryInterface;
use Illuminate\Support\Facades\Log;

class UserRepository implements UserRepositoryInterface
{
    public function searchByUsernamePrefix(?string $prefix, int $limit = 50): array
    {
        try {
            $query = User::query()
                ->select(['id', 'username', 'email', 'tel', 'address']);

            if ($prefix !== null && $prefix !== '') {
                $query->where('username', 'like', $prefix . '%');
            }

            $rows = $query->orderBy('username')->limit($limit)->get();

            $dtos = [];
            foreach ($rows as $u) {
                $dtos[] = new UserListItemDto(
                    id: (int)$u->id,
                    username: (string)$u->username,
                    email: (string)$u->email,
                    tel: $u->tel !== null ? (string)$u->tel : null,
                    address: $u->address !== null ? (string)$u->address : null
                );
            }
            return $dtos;

        } catch (\Throwable $e) {
            Log::error('[UserRepository] searchByUsernamePrefix failed', [
                'prefix' => $prefix,
                'limit' => $limit,
                'error' => $e->getMessage(),
            ]);

            // 文字列を禁止:キーだけ投げる
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_DB_QUERY_FAILED, 0, $e);
        }
    }

    public function findEditDtoById(int $id): ?UserEditDto
    {
        try {
            $u = User::query()
                ->select(['id', 'username', 'email', 'tel', 'address'])
                ->where('id', $id)
                ->first();

            if ($u === null) {
                return null;
            }

            return new UserEditDto(
                id: (int)$u->id,
                username: (string)$u->username,
                email: (string)$u->email,
                tel: $u->tel !== null ? (string)$u->tel : null,
                address: $u->address !== null ? (string)$u->address : null
            );

        } catch (\Throwable $e) {
            Log::error('[UserRepository] findEditDtoById failed', [
                'id' => $id,
                'error' => $e->getMessage(),
            ]);

            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_DB_QUERY_FAILED, 0, $e);
        }
    }

    public function updateContact(UserUpdateDto $dto): bool
    {
        try {
            $u = User::query()->where('id', $dto->id)->first();
            if ($u === null) {
                return false;
            }

            $u->email = $dto->email;
            $u->tel = $dto->tel;
            $u->update_at = now();
            $u->save();

            return true;

        } catch (\Throwable $e) {
            Log::error('[UserRepository] updateContact failed', [
                'id' => $dto->id,
                'error' => $e->getMessage(),
            ]);

            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_DB_UPDATE_FAILED, 0, $e);
        }
    }
}

10. Service(業務ロジック層)

Serviceも「文字列禁止」。例外はキーで受けて上位へ伝播します。

<?php

namespace App\Services;

use App\Dtos\UserEditDto;
use App\Dtos\UserUpdateDto;
use App\Exceptions\AppMessageKeyException;
use App\Repositories\Contracts\UserRepositoryInterface;
use Illuminate\Support\Facades\Log;

class UserService
{
    public function __construct(
        private readonly UserRepositoryInterface $userRepo
    ) {}

    public function search(?string $prefix): array
    {
        try {
            return $this->userRepo->searchByUsernamePrefix($prefix, 50);
        } catch (AppMessageKeyException $e) {
            Log::warning('[UserService] search failed', ['key' => $e->messageKey]);
            throw $e; // キーのまま上位へ
        } catch (\Throwable $e) {
            Log::error('[UserService] search unexpected', ['error' => $e->getMessage()]);
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_UNEXPECTED, 0, $e);
        }
    }

    public function getEditDto(int $id): ?UserEditDto
    {
        try {
            return $this->userRepo->findEditDtoById($id);
        } catch (AppMessageKeyException $e) {
            Log::warning('[UserService] getEditDto failed', ['key' => $e->messageKey]);
            throw $e;
        } catch (\Throwable $e) {
            Log::error('[UserService] getEditDto unexpected', ['error' => $e->getMessage()]);
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_UNEXPECTED, 0, $e);
        }
    }

    public function updateContact(UserUpdateDto $dto): bool
    {
        try {
            return $this->userRepo->updateContact($dto);
        } catch (AppMessageKeyException $e) {
            Log::warning('[UserService] updateContact failed', ['key' => $e->messageKey]);
            throw $e;
        } catch (\Throwable $e) {
            Log::error('[UserService] updateContact unexpected', ['error' => $e->getMessage()]);
            throw new AppMessageKeyException(\App\Support\I18n\I18nKeys::E_UNEXPECTED, 0, $e);
        }
    }
}

11. DI(Repositoryのバインド)

Interfaceと実装を結びつけ、Controller/Service側は契約に依存します。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\Eloquent\UserRepository;

use App\Repositories\Contracts\I18nRepositoryInterface;
use App\Repositories\Eloquent\I18nRepository;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(UserRepositoryInterface::class, UserRepository::class);
        $this->app->bind(I18nRepositoryInterface::class, I18nRepository::class);
    }

    public function boot(): void
    {
        //
    }
}

12. FormRequest(入力バリデーション)

バリデーションメッセージも「文字列禁止」。キーで返し、I18nで解決します。

12-1. UserSearchRequest(検索)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserSearchRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'prefix' => ['nullable', 'string', 'max:50'],
        ];
    }

    // 文字列禁止:メッセージはキーを返す
    public function messages(): array
    {
        return [
            'prefix.string' => \App\Support\I18n\I18nKeys::V_PREFIX_STRING,
            'prefix.max'    => \App\Support\I18n\I18nKeys::V_PREFIX_MAX,
        ];
    }

    // 文字列禁止:属性名もキーにする
    public function attributes(): array
    {
        return [
            'prefix' => \App\Support\I18n\I18nKeys::A_PREFIX,
        ];
    }
}

12-2. UserUpdateRequest(更新)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255'],
            'tel'   => ['nullable', 'string', 'max:50'],
        ];
    }

    public function messages(): array
    {
        return [
            'email.required' => \App\Support\I18n\I18nKeys::V_EMAIL_REQUIRED,
            'email.email'    => \App\Support\I18n\I18nKeys::V_EMAIL_EMAIL,
            'email.max'      => \App\Support\I18n\I18nKeys::V_EMAIL_MAX,
            'tel.max'        => \App\Support\I18n\I18nKeys::V_TEL_MAX,
        ];
    }

    public function attributes(): array
    {
        return [
            'email' => \App\Support\I18n\I18nKeys::A_EMAIL,
            'tel'   => \App\Support\I18n\I18nKeys::A_TEL,
        ];
    }
}

13. Controller(HTTP責務:PRG、404、例外、フラッシュメッセージ)

Controllerは「キー」だけ受け取り、表示直前に I18n で文字列へ解決します。

<?php

namespace App\Http\Controllers;

use App\Dtos\UserUpdateDto;
use App\Http\Requests\UserSearchRequest;
use App\Http\Requests\UserUpdateRequest;
use App\Services\UserService;
use App\Support\I18n\I18n;                 // 文字列解決サービス
use App\Support\I18n\I18nKeys;             // キー定数
use App\Exceptions\AppMessageKeyException; // キー例外
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Illuminate\Support\Facades\Log;

class UserController extends Controller
{
    public function __construct(
        private readonly UserService $userService
    ) {}

    public function index(UserSearchRequest $request): View
    {
        $prefix = $request->input('prefix');

        try {
            $users = $this->userService->search($prefix);

            return view('users.index', [
                'prefix' => $prefix,
                'users'  => $users,
            ]);

        } catch (AppMessageKeyException $e) {

            Log::error('[UserController] index failed', [
                'key'    => $e->messageKey,
                'prefix' => $prefix,
            ]);

            return view('users.index', [
                'prefix'      => $prefix,
                'users'       => [],
                'globalError' => I18n::t($e->messageKey), // 表示直前に解決
            ]);

        } catch (\Throwable $e) {

            Log::error('[UserController] index unexpected', [
                'prefix' => $prefix,
                'error'  => $e->getMessage(),
            ]);

            return view('users.index', [
                'prefix'      => $prefix,
                'users'       => [],
                'globalError' => I18n::t(I18nKeys::E_UNEXPECTED),
            ]);
        }
    }

    public function edit(int $id): View
    {
        try {
            $dto = $this->userService->getEditDto($id);

            if ($dto === null) {
                abort(404, I18n::t(I18nKeys::E_USER_NOT_FOUND));
            }

            return view('users.edit', [
                'user' => $dto,
            ]);

        } catch (AppMessageKeyException $e) {

            Log::error('[UserController] edit failed', [
                'id'  => $id,
                'key' => $e->messageKey,
            ]);

            abort(500, I18n::t($e->messageKey));

        } catch (\Throwable $e) {

            Log::error('[UserController] edit unexpected', [
                'id'    => $id,
                'error' => $e->getMessage(),
            ]);

            abort(500, I18n::t(I18nKeys::E_UNEXPECTED));
        }
    }

    public function update(UserUpdateRequest $request, int $id): RedirectResponse
    {
        $email = $request->input('email');
        $tel   = $request->input('tel');

        try {
            $ok = $this->userService->updateContact(
                new UserUpdateDto(id: $id, email: $email, tel: $tel)
            );

            if (!$ok) {
                return redirect()
                    ->route('users.index')
                    ->with('error', I18n::t(I18nKeys::E_USER_NOT_FOUND));
            }

            return redirect()
                ->route('users.index')
                ->with('success', I18n::t(I18nKeys::S_USER_UPDATED));

        } catch (AppMessageKeyException $e) {

            Log::error('[UserController] update failed', [
                'id'  => $id,
                'key' => $e->messageKey,
            ]);

            return back()
                ->withInput()
                ->with('error', I18n::t($e->messageKey));

        } catch (\Throwable $e) {

            Log::error('[UserController] update unexpected', [
                'id'    => $id,
                'error' => $e->getMessage(),
            ]);

            return back()
                ->withInput()
                ->with('error', I18n::t(I18nKeys::E_UNEXPECTED));
        }
    }
}

14. Blade(画面テンプレート:2画面)

Bladeも「文字列禁止」。固定文言はすべて I18n のキー参照にします。

14-1. 一覧画面(resources/views/users/index.blade.php)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_INDEX) }}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif; margin: 24px; }
    .box{ border: 1px solid #ddd; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
    .ok{ border-left: 6px solid #22c55e; background: #f0fdf4; padding: 12px; border-radius: 8px; }
    .warn{ border-left: 6px solid #f59e0b; background: #fffbeb; padding: 12px; border-radius: 8px; }
    .err{ border-left: 6px solid #ef4444; background: #fef2f2; padding: 12px; border-radius: 8px; }
    table{ width: 100%; border-collapse: collapse; }
    th, td{ border-bottom: 1px solid #eee; padding: 10px; text-align: left; }
    a{ color: #2563eb; text-decoration: none; }
    a:hover{ text-decoration: underline; }
    .muted{ color: #666; font-size: 12px; }
    input{ padding: 8px 10px; width: 240px; }
    button{ padding: 8px 12px; cursor: pointer; }
  </style>
</head>
<body>

  <h1>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_INDEX) }}</h1>

  @if(session('success'))
    <div class="ok">{{ session('success') }}</div>
  @endif

  @if(session('error'))
    <div class="err">{{ session('error') }}</div>
  @endif

  @if(!empty($globalError))
    <div class="err">{{ $globalError }}</div>
  @endif

  <div class="box">
    <form method="GET" action="{{ route('users.index') }}">

      <label>
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_PREFIX) }}
        <input
          type="text"
          name="prefix"
          value="{{ old('prefix', $prefix) }}"
          placeholder="{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::P_PREFIX) }}"
        >
      </label>

      <button type="submit">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_SEARCH) }}</button>

      <a href="{{ route('users.index') }}" class="muted" style="margin-left:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_CLEAR) }}
      </a>

      @error('prefix')
        {{-- バリデーションが返す $message は「キー」なので、I18nで解決して表示 --}}
        <div class="warn" style="margin-top:10px;">{{ \App\Support\I18n\I18n::t($message) }}</div>
      @enderror

      <div class="muted" style="margin-top:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_PREFIX_RULE) }}
      </div>

    </form>
  </div>

  <div class="box">
    <h2>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_LIST) }}</h2>

    @if(empty($users) || count($users) === 0)
      <div class="warn">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_NO_RESULT) }}</div>
    @else
      <table>
        <thead>
          <tr>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_USERNAME) }}</th>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_EMAIL) }}</th>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_TEL) }}</th>
            <th>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::C_ADDRESS) }}</th>
          </tr>
        </thead>
        <tbody>
          @foreach($users as $u)
            <tr>
              <td>
                <a href="{{ route('users.edit', ['id' => $u->id]) }}">{{ $u->username }}</a>
              </td>
              <td>{{ $u->email }}</td>
              <td>{{ $u->tel ?? '' }}</td>
              <td>{{ $u->address ?? '' }}</td>
            </tr>
          @endforeach
        </tbody>
      </table>

      <div class="muted" style="margin-top:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_CLICK_TO_EDIT) }}
      </div>
    @endif
  </div>

</body>
</html>

14-2. 編集画面(resources/views/users/edit.blade.php)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_EDIT) }}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif; margin: 24px; }
    .box{ border: 1px solid #ddd; padding: 16px; border-radius: 8px; margin-bottom: 16px; max-width: 640px; }
    .warn{ border-left: 6px solid #f59e0b; background: #fffbeb; padding: 12px; border-radius: 8px; }
    .err{ border-left: 6px solid #ef4444; background: #fef2f2; padding: 12px; border-radius: 8px; }
    label{ display:block; margin-top: 12px; }
    input{ padding: 8px 10px; width: 100%; box-sizing: border-box; }
    button{ padding: 8px 12px; cursor: pointer; margin-top: 14px; }
    .muted{ color: #666; font-size: 12px; }
  </style>
</head>
<body>

  <h1>{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::T_USER_EDIT) }}</h1>

  @if(session('error'))
    <div class="err">{{ session('error') }}</div>
  @endif

  <div class="box">

    <div>
      <div class="muted">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_USERNAME) }}</div>
      <div><b>{{ $user->username }}</b></div>
    </div>

    <div style="margin-top:12px;">
      <div class="muted">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_ADDRESS) }}</div>
      <div>{{ $user->address ?? '' }}</div>
    </div>

    <hr style="margin: 16px 0; border: none; border-top: 1px solid #eee;">

    <form method="POST" action="{{ route('users.update', ['id' => $user->id]) }}">
      @csrf
      @method('PUT')

      <label>
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_EMAIL) }}
        <input type="text" name="email" value="{{ old('email', $user->email) }}">
      </label>
      @error('email')
        <div class="warn">{{ \App\Support\I18n\I18n::t($message) }}</div>
      @enderror

      <label>
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::L_TEL) }}
        <input type="text" name="tel" value="{{ old('tel', $user->tel) }}">
      </label>
      @error('tel')
        <div class="warn">{{ \App\Support\I18n\I18n::t($message) }}</div>
      @enderror

      <button type="submit">{{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_SAVE) }}</button>

      <a href="{{ route('users.index') }}" style="margin-left:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::A_BACK_TO_LIST) }}
      </a>

      <div class="muted" style="margin-top:10px;">
        {{ \App\Support\I18n\I18n::t(\App\Support\I18n\I18nKeys::N_EDIT_RULE) }}
      </div>

    </form>
  </div>

</body>
</html>

15. 多言語化(「ソース内に文字列を一切ハードコードしない」方式)

前提(あなたの要求の定義)
本節では 「resources/lang/*.php に文字列を書くことも禁止」 とします。
つまり、アプリ(リポジトリ含む)に 表示文言そのものは1文字も置かず、すべて外部リソースから取得します。
そのため、Laravel標準の __('...')(lang配列)方式は使わず、I18nサービス + DB辞書に統一します。

15-1. 外部多言語辞書(DBテーブル:i18n_messages)

翻訳文言は DBに保持します(SQLiteでも可)。
アプリは キーロケール から文字列を取得し、表示します。
※ DBの中身(翻訳文章)は「運用データ」であり、ソースコードには含めません(要件:文字列ハードコード禁止)。

15-2. Migration(i18n_messages テーブル)

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {

    public function up(): void
    {
        Schema::create('i18n_messages', function (Blueprint $table) {

            $table->id();

            // 例: ja / en / th など
            $table->string('locale', 10);

            // 例: ui.titles.user_index / errors.db_query_failed など
            $table->string('msg_key', 200);

            // 実際の表示文字列(※ソースには置かない:DBデータとして運用)
            $table->text('msg_value');

            // 重複防止
            $table->unique(['locale', 'msg_key']);

            // 参照が多いので索引
            $table->index(['locale', 'msg_key']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('i18n_messages');
    }
};

15-3. Entity(I18nMessage Model)

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class I18nMessage extends Model
{
    protected $table = 'i18n_messages';

    protected $fillable = [
        'locale',
        'msg_key',
        'msg_value',
    ];

    public $timestamps = false;
}

15-4. I18nKeys(キーの定数化:文字列禁止のため)

「キー文字列」すら散らばらないように、キーは定数で一元管理します。
※キーそのものは文言ではないため、ここでは許可(ただし “定数化” して散らばりを防止)。

<?php

namespace App\Support\I18n;

final class I18nKeys
{
    // titles
    public const T_USER_INDEX   = 'ui.titles.user_index';
    public const T_USER_LIST    = 'ui.titles.user_list';
    public const T_USER_EDIT    = 'ui.titles.user_edit';

    // labels
    public const L_PREFIX   = 'ui.labels.prefix';
    public const L_USERNAME = 'ui.labels.username';
    public const L_EMAIL    = 'ui.labels.email';
    public const L_TEL      = 'ui.labels.tel';
    public const L_ADDRESS  = 'ui.labels.address';

    // placeholders
    public const P_PREFIX = 'ui.placeholders.prefix';

    // actions
    public const A_SEARCH       = 'ui.actions.search';
    public const A_CLEAR        = 'ui.actions.clear';
    public const A_SAVE         = 'ui.actions.save';
    public const A_BACK_TO_LIST = 'ui.actions.back_to_list';

    // columns
    public const C_USERNAME = 'ui.columns.username';
    public const C_EMAIL    = 'ui.columns.email';
    public const C_TEL      = 'ui.columns.tel';
    public const C_ADDRESS  = 'ui.columns.address';

    // notes
    public const N_PREFIX_RULE   = 'ui.notes.prefix_rule';
    public const N_NO_RESULT     = 'ui.notes.no_result';
    public const N_CLICK_TO_EDIT = 'ui.notes.click_to_edit';
    public const N_EDIT_RULE     = 'ui.notes.edit_rule';

    // success
    public const S_USER_UPDATED = 'success.user_updated';

    // errors
    public const E_USER_NOT_FOUND    = 'errors.user_not_found';
    public const E_DB_QUERY_FAILED   = 'errors.db_query_failed';
    public const E_DB_UPDATE_FAILED  = 'errors.db_update_failed';
    public const E_UNEXPECTED        = 'errors.unexpected';

    // validation attributes (属性名)
    public const A_PREFIX = 'validation.attributes.prefix';
    public const A_EMAIL  = 'validation.attributes.email';
    public const A_TEL    = 'validation.attributes.tel';

    // validation rules (メッセージ)
    public const V_PREFIX_STRING  = 'validation.rules.prefix.string';
    public const V_PREFIX_MAX     = 'validation.rules.prefix.max';
    public const V_EMAIL_REQUIRED = 'validation.rules.email.required';
    public const V_EMAIL_EMAIL    = 'validation.rules.email.email';
    public const V_EMAIL_MAX      = 'validation.rules.email.max';
    public const V_TEL_MAX        = 'validation.rules.tel.max';
}

15-5. I18nRepository(DBから取得:文字列はDBデータのみ)

<?php

namespace App\Repositories\Contracts;

interface I18nRepositoryInterface
{
    // キーとロケールでメッセージを取得(無ければ null)
    public function findValue(string $locale, string $msgKey): ?string;
}
<?php

namespace App\Repositories\Eloquent;

use App\Models\I18nMessage;
use App\Repositories\Contracts\I18nRepositoryInterface;

class I18nRepository implements I18nRepositoryInterface
{
    public function findValue(string $locale, string $msgKey): ?string
    {
        $row = I18nMessage::query()
            ->select(['msg_value'])
            ->where('locale', $locale)
            ->where('msg_key', $msgKey)
            ->first();

        return $row ? (string)$row->msg_value : null;
    }
}

15-6. I18nService / Helper(キーから文字列を解決)

表示直前に I18n::t(KEY) を呼び、DB辞書から文字列を解決します。
もし辞書に存在しない場合は「キーをそのまま返す」などの運用ポリシーにできます(※ここでは例示のみ。文言は一切埋め込まない)。

<?php

namespace App\Services;

use App\Repositories\Contracts\I18nRepositoryInterface;
use Illuminate\Support\Facades\Cache;

class I18nService
{
    public function __construct(
        private readonly I18nRepositoryInterface $repo
    ) {}

    public function translate(string $locale, string $key): string
    {
        // 高頻度アクセスなのでキャッシュ(キー+ロケール単位)
        $cacheKey = 'i18n:' . $locale . ':' . $key;

        return Cache::remember($cacheKey, 300, function () use ($locale, $key) {

            // DBから取得(文字列はDBデータのみ)
            $val = $this->repo->findValue($locale, $key);

            // 無い場合:キーを返す(文字列を埋め込まないためのフォールバック)
            return $val ?? $key;
        });
    }
}
<?php

namespace App\Support\I18n;

use App\Services\I18nService;

// Blade/Controller から呼びやすい静的ヘルパ
final class I18n
{
    public static function t(string $key): string
    {
        // ロケール決定(例:config/app.php, session, header 等)
        $locale = app(LocaleResolver::class)->resolve();

        return app(I18nService::class)->translate($locale, $key);
    }
}

15-7. LocaleResolver(ロケール決定)

<?php

namespace App\Support\I18n;

use Illuminate\Http\Request;

class LocaleResolver
{
    public function __construct(
        private readonly Request $request
    ) {}

    public function resolve(): string
    {
        // 例:?lang=ja / ?lang=en を優先(業務では session / user設定 / header 等に合わせる)
        $lang = (string)$this->request->query('lang', '');

        // 許可された言語だけに限定(値は config へ寄せることが多い)
        if ($lang === 'ja' || $lang === 'en') {
            return $lang;
        }

        // 既定は ja(※文言ではないのでOK)
        return 'ja';
    }
}

15-8. 「バリデーション文言がキーのまま」になる点の扱い(この仕様での正解)

16. 実行手順(SQLiteで起動)

# 1) SQLite DBファイルを作成(空でOK)
touch database/database.sqlite

# 2) .env の DB_DATABASE をプロジェクトの絶対パスに合わせる

# 3) マイグレーション実行
php artisan migrate

# 4) i18n_messages に翻訳データを投入(※運用データとして投入。ソースに埋め込まない)
#    例:CSVインポート、管理画面、別DBから同期、翻訳サービス連携など

# 5) 開発サーバ起動
php artisan serve

# 6) ブラウザでアクセス(例:?lang=ja / ?lang=en)
# http://localhost:8000/users?lang=ja
# http://localhost:8000/users?lang=en
✅ 仕様チェック(要件の満たし方)
  • 検索入力に ausername LIKE 'a%' で一覧表示(前方一致)
  • 一覧の列は username/email/tel/address のみ表示(id/update_at は非表示)
  • usernameクリックで /users/{id}/edit に遷移
  • 編集できるのは email/tel のみ
  • 表示文言は DB(i18n_messages)から取得し、ソース内に文字列を持たない(キー参照のみ)
  • DBはSQLite(.env + sqliteファイル作成 + migrate)

Laravel(PHP)サンプル:注文・請求ルールエンジン(業務ロジック中心)

このサンプルは「通信(HTTP)」そのものよりも、Laravel/PHPで業務ロジックをどう安全に書くかに焦点を当てます。
入力(注文JSON)を受け取り、税・割引・送料を計算して請求結果を返す、よくある基幹/業務系の計算エンジン例です。

1. このサンプルで学ぶこと(TypeScript版 → Laravel/PHP版への置き換え)

TypeScript版で扱っていた「文法要素」を、Laravel/PHPでの業務実装の形に置き換えます。
「文法の説明」ではなく、実際の業務ルールを実装する中で、言語機能をどう使うかを理解するのが目的です。

2. 業務の概要

ユーザーから渡された「注文データ(JSON)」を元に、次を計算します。

3. 入力データの考え方(Laravelでの受け取り)

入力は「JSON相当」です。実務では HTTP Request の body(JSON)に相当し、Laravelでは Controller が FormRequest で検証した後に業務ロジックへ渡します。

重要なポイント
  • items は配列(注文行)なので foreach で処理する
  • customerRank / region / taxPHP enum で安全に扱う
  • 計算途中の値は「変更しない前提」で変数に入れ、処理を関数/Serviceに閉じ込める(副作用を減らす)
  • coupon は任意(optional)として、無いケースを必ず考慮する

4. 主な業務ルール(計算仕様)

4-1. 税計算(TaxCategory)

4-2. 割引(CustomerRank / Coupon)

4-3. 送料(Region / FreeShipping)

💡 なぜ enum を使うのか(Laravel/PHPでも同じ理由)
税区分・会員ランク・配送地域は「任意文字列」ではなく、業務上の決まった選択肢です。
PHPの enum を使うことで、タイポや想定外の値を入力段階(バリデーション/変換)で弾けるようになり、安全な業務コードになります。

5. 入力 / 出力データ仕様(コメント付き)

5-1. 入力(注文データ)

業務ロジックに渡される入力データです。実務では HTTP リクエストの body(JSON)に相当します。

// ※ 説明用のためコメント付き(実際の JSON ではコメント不可)
{
  "customerRank": "GOLD",        // 顧客ランク(enum: BRONZE / SILVER / GOLD)
  "region": "TOKYO",             // 配送地域(enum: TOKYO / OTHER)

  "items": [                     // 注文行の配列
    {
      "sku": "A001",              // 商品コード
      "unitPrice": 980,           // 単価(number → PHPではint想定)
      "qty": 2,                   // 数量(number → PHPではint想定)
      "tax": "STANDARD"           // 税区分(enum)
    },
    {
      "sku": "B777",
      "unitPrice": 1500,
      "qty": 1,
      "tax": "REDUCED"
    }
  ],

  "coupon": "WELCOME10"          // クーポンコード(任意・optional)
}
💡 入力データのポイント(Laravel/PHP側の実装方針)
  • items は配列なので foreach で処理する
  • customerRank / region / tax は enum 化して安全に扱う(例:CustomerRank::from($value)
  • coupon は無い場合があるので、null の分岐を必ず入れる

5-2. 出力(計算結果)

業務ロジックが返す計算結果です。APIレスポンスや画面表示用データとしてそのまま使えます。

// 計算結果(出力)
{
  "subtotal": 3460,        // 税抜小計(全行の subtotal 合計)
  "taxTotal": 292,         // 税額合計
  "discount": 346,         // 割引合計(会員 + クーポン)
  "shipping": 500,         // 送料
  "grandTotal": 3906       // 最終請求金額
}
💡 計算の流れ(Laravel/PHPの実装イメージ)
  1. 各行の subtotal = unitPrice * qty を計算
  2. 税区分(enum)から税率を決めて税額を算出(match など)
  3. 小計を array_reduce で集計(または素直に合計変数で加算)
  4. 会員割引・クーポン割引を適用して割引合計を算出
  5. 地域送料を計算し、送料無料条件(小計 >= 10,000)なら 0 にする
  6. grandTotal = subtotal + taxTotal - discount + shipping

6. Laravelでの実装配置(仕様としての推奨)

本サンプルは「業務ロジック中心」なので、Controller直書きにせず、層を分けます。

app/
  Http/
    Controllers/
      BillingController.php          // HTTP受け口(薄く)
    Requests/
      BillingCalcRequest.php         // 入力バリデーション
  Services/
    BillingRuleEngine.php           // 税/割引/送料/合計の計算本体(業務ロジック)
  Dtos/
    OrderInputDto.php               // 入力DTO(enumに変換済み)
    OrderItemDto.php
    BillingResultDto.php            // 出力DTO
  Enums/
    CustomerRank.php
    Region.php
    TaxCategory.php
    CouponCode.php                  // 任意(WELCOME10など)
✅ 仕様の狙い
  • 入力は FormRequest で検証し、DTO/enumに変換してから Service へ渡す
  • 計算ロジックは Service(RuleEngine)に集約し、再利用・テストしやすくする
  • enum を使い、業務上の区分値を「文字列のまま放置」しない

Laravel(PHP)実装コード:注文・請求ルールエンジン(APIで計算結果を返す)

ここでは「注文JSON → 税/割引/送料 → 請求結果JSON」を返す最小構成の実装例を示します。
業務ロジックは Service(RuleEngine)に集約し、Controller は薄く保ちます。
※ユーザー向けメッセージはソースにベタ書きせず、すべて lang ファイルから取得します。

0. 前提

1. ルーティング(routes/api.php)

計算用APIを 1 本用意します(POSTで注文JSONを受け取り、計算結果JSONを返す)。

<?php
// routes/api.php

use Illuminate\Support\Facades\Route;                 // ルーティング機能
use App\Http\Controllers\BillingController;           // Controller

// 注文・請求の計算(JSON入力 → JSON出力)
Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->name('billing.calc');

2. ファイル構成(業務レベルの分割)

app/
  Enums/
    CustomerRank.php
    Region.php
    TaxCategory.php
    CouponCode.php
  Dtos/
    OrderItemDto.php
    OrderInputDto.php
    BillingResultDto.php
  Services/
    BillingRuleEngine.php
  Http/
    Controllers/
      BillingController.php
    Requests/
      BillingCalcRequest.php

resources/
  lang/
    ja/
      messages.php
      validation.php
    en/
      messages.php
      validation.php

3. enum(業務上の区分値)

3-1. 顧客ランク(app/Enums/CustomerRank.php)

<?php
// app/Enums/CustomerRank.php

namespace App\Enums;

// 顧客ランク(固定の選択肢)
// 文字列を自由入力させるとタイポや想定外値が混ざるため enum にする。
enum CustomerRank: string
{
    case BRONZE = 'BRONZE';  // ブロンズ
    case SILVER = 'SILVER';  // シルバー
    case GOLD   = 'GOLD';    // ゴールド
}

3-2. 配送地域(app/Enums/Region.php)

<?php
// app/Enums/Region.php

namespace App\Enums;

// 配送地域(送料ルールで使用)
enum Region: string
{
    case TOKYO = 'TOKYO';  // 東京
    case OTHER = 'OTHER';  // その他
}

3-3. 税区分(app/Enums/TaxCategory.php)

<?php
// app/Enums/TaxCategory.php

namespace App\Enums;

// 税区分(税率ルールで使用)
enum TaxCategory: string
{
    case STANDARD = 'STANDARD'; // 10%
    case REDUCED  = 'REDUCED';  // 8%
    case EXEMPT   = 'EXEMPT';   // 0%
}

3-4. クーポン(app/Enums/CouponCode.php)

<?php
// app/Enums/CouponCode.php

namespace App\Enums;

// クーポンコード(任意入力だが、許可リストとしてenumにしておくと安全)
enum CouponCode: string
{
    case WELCOME10 = 'WELCOME10'; // 小計の10%割引
}

4. DTO(入力/出力の形を固定する)

Controller から Service へ「配列のまま」渡さず、DTOに詰め替えて渡します。
これにより、業務ロジック層が「入力の形がブレる」事故を防げます。

4-1. 注文明細(app/Dtos/OrderItemDto.php)

<?php
// app/Dtos/OrderItemDto.php

namespace App\Dtos;

use App\Enums\TaxCategory;

// 注文行DTO(1行分)
// 業務ロジックはこのDTOだけを信じて計算する(Request配列を信用しない)。
final class OrderItemDto
{
    public function __construct(
        public readonly string $sku,         // 商品コード
        public readonly int $unitPrice,      // 単価(整数想定)
        public readonly int $qty,            // 数量(整数想定)
        public readonly TaxCategory $tax     // 税区分(enum)
    ) {}
}

4-2. 注文入力(app/Dtos/OrderInputDto.php)

<?php
// app/Dtos/OrderInputDto.php

namespace App\Dtos;

use App\Enums\CustomerRank;
use App\Enums\Region;
use App\Enums\CouponCode;

// 注文入力DTO(全体)
// coupon は任意(null)なので ?CouponCode として持つ。
final class OrderInputDto
{
    /**
     * @param OrderItemDto[] $items 注文明細配列
     */
    public function __construct(
        public readonly CustomerRank $customerRank, // 顧客ランク(enum)
        public readonly Region $region,             // 地域(enum)
        public readonly array $items,               // 明細配列
        public readonly ?CouponCode $coupon         // クーポン(任意)
    ) {}
}

4-3. 計算結果(app/Dtos/BillingResultDto.php)

<?php
// app/Dtos/BillingResultDto.php

namespace App\Dtos;

// 計算結果DTO(出力)
// APIレスポンスや画面表示にそのまま使える形にする。
final class BillingResultDto
{
    public function __construct(
        public readonly int $subtotal,     // 税抜小計
        public readonly int $taxTotal,     // 税額合計
        public readonly int $discount,     // 割引合計
        public readonly int $shipping,     // 送料
        public readonly int $grandTotal    // 最終請求金額
    ) {}

    // JSON返却しやすいように配列化メソッドを用意する(業務でよくやる)
    public function toArray(): array
    {
        return [
            'subtotal'   => $this->subtotal,
            'taxTotal'   => $this->taxTotal,
            'discount'   => $this->discount,
            'shipping'   => $this->shipping,
            'grandTotal' => $this->grandTotal,
        ];
    }
}

5. 業務ロジック本体(Service:app/Services/BillingRuleEngine.php)

税率・割引率・送料などのルールは、定数として 1 か所に集約します(マジックナンバー禁止)。

<?php
// app/Services/BillingRuleEngine.php

namespace App\Services;

use App\Dtos\OrderInputDto;          // 入力DTO
use App\Dtos\BillingResultDto;       // 出力DTO
use App\Enums\CustomerRank;          // 顧客ランクenum
use App\Enums\Region;                // 地域enum
use App\Enums\TaxCategory;           // 税区分enum
use App\Enums\CouponCode;            // クーポンenum

// ルールエンジン(業務ロジック)
// Controller は薄く、計算はすべてここに寄せる。
final class BillingRuleEngine
{
    // 税率(百分率ではなく小数で保持すると計算が分かりやすい)
    private const TAX_STANDARD = 0.10; // 10%
    private const TAX_REDUCED  = 0.08; // 8%
    private const TAX_EXEMPT   = 0.00; // 0%

    // 会員割引率
    private const DISCOUNT_GOLD = 0.05; // 5%

    // クーポン割引率
    private const COUPON_WELCOME10 = 0.10; // 10%

    // 送料
    private const SHIPPING_TOKYO = 500;
    private const SHIPPING_OTHER = 800;

    // 送料無料の閾値(税抜小計で判定)
    private const FREE_SHIPPING_THRESHOLD = 10000;

    // 計算メイン(DTO入力 → DTO出力)
    public function calculate(OrderInputDto $input): BillingResultDto
    {
        // ① 各行の税抜小計を計算しつつ、税額も合計する
        $lineSubtotals = []; // 行小計を集める配列(後でreduceで合計する例)
        $taxTotal = 0;       // 税額合計

        // 注文明細を順に処理する(TypeScriptの for...of 相当)
        foreach ($input->items as $item) {

            // 行小計(税抜):単価×数量
            $subtotal = $item->unitPrice * $item->qty;

            // 行小計を配列に積む(集計用)
            $lineSubtotals[] = $subtotal;

            // 税率を税区分から決定する(matchで安全に分岐)
            $rate = match ($item->tax) {
                TaxCategory::STANDARD => self::TAX_STANDARD,
                TaxCategory::REDUCED  => self::TAX_REDUCED,
                TaxCategory::EXEMPT   => self::TAX_EXEMPT,
            };

            // 税額(端数処理は要件次第。ここでは四捨五入ではなく「切り捨て」にする例)
            // ※実務では「端数処理ポリシー」を仕様で固定すること。
            $lineTax = (int) floor($subtotal * $rate);

            // 税額合計に加算する
            $taxTotal += $lineTax;
        }

        // ② 小計合計(reduceで集計する例:TypeScript reduce の置き換え)
        $subtotalTotal = array_reduce(
            $lineSubtotals,
            function (int $carry, int $value): int {
                // carry は今までの合計、value は現在の要素
                return $carry + $value;
            },
            0 // 初期値(合計0)
        );

        // ③ 割引計算(会員 + クーポン)
        $memberDiscount = $this->calcMemberDiscount($input->customerRank, $subtotalTotal);
        $couponDiscount = $this->calcCouponDiscount($input->coupon, $subtotalTotal);

        // 割引合計(要件:会員割+クーポン割を合算)
        $discountTotal = $memberDiscount + $couponDiscount;

        // ④ 送料計算(地域 + 送料無料条件)
        $shipping = $this->calcShipping($input->region, $subtotalTotal);

        // ⑤ 最終請求金額
        $grandTotal = $subtotalTotal + $taxTotal - $discountTotal + $shipping;

        // DTOで返す(ControllerはこのDTOを配列化してJSONにするだけ)
        return new BillingResultDto(
            subtotal: $subtotalTotal,
            taxTotal: $taxTotal,
            discount: $discountTotal,
            shipping: $shipping,
            grandTotal: $grandTotal
        );
    }

    // 会員割引の計算(ルールを関数に分離して読みやすくする)
    private function calcMemberDiscount(CustomerRank $rank, int $subtotal): int
    {
        // GOLD以外は割引なし
        if ($rank !== CustomerRank::GOLD) {
            return 0;
        }

        // GOLD:小計の5%
        return (int) floor($subtotal * self::DISCOUNT_GOLD);
    }

    // クーポン割引の計算(couponは任意なので null を考慮)
    private function calcCouponDiscount(?CouponCode $coupon, int $subtotal): int
    {
        // クーポンが無ければ割引なし
        if ($coupon === null) {
            return 0;
        }

        // クーポン別に分岐(今後増える前提でmatchにする)
        $rate = match ($coupon) {
            CouponCode::WELCOME10 => self::COUPON_WELCOME10,
        };

        // 小計に割引率を掛ける
        return (int) floor($subtotal * $rate);
    }

    // 送料計算(送料無料条件もここで吸収)
    private function calcShipping(Region $region, int $subtotal): int
    {
        // 送料無料条件:小計が閾値以上
        if ($subtotal >= self::FREE_SHIPPING_THRESHOLD) {
            return 0;
        }

        // 地域ごとの基本送料
        return match ($region) {
            Region::TOKYO => self::SHIPPING_TOKYO,
            Region::OTHER => self::SHIPPING_OTHER,
        };
    }
}

6. 入力バリデーション(FormRequest:app/Http/Requests/BillingCalcRequest.php)

入力の形(必須/任意)と、enumとして許可される値をここで確定させます。
これにより Service は「正しい入力だけ来る」前提で書けます(業務コードの王道)。

<?php
// app/Http/Requests/BillingCalcRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;           // FormRequest
use Illuminate\Validation\Rule;                       // ルール定義
use App\Enums\CustomerRank;                           // enum
use App\Enums\Region;                                 // enum
use App\Enums\TaxCategory;                            // enum
use App\Enums\CouponCode;                             // enum

final class BillingCalcRequest extends FormRequest
{
    // 認可(このサンプルは誰でもOK)
    public function authorize(): bool
    {
        return true;
    }

    // バリデーションルール
    public function rules(): array
    {
        return [
            // 顧客ランク(必須、enumのいずれか)
            'customerRank' => ['required', Rule::enum(CustomerRank::class)],

            // 配送地域(必須、enumのいずれか)
            'region' => ['required', Rule::enum(Region::class)],

            // 注文明細(必須、配列、最低1行)
            'items' => ['required', 'array', 'min:1'],

            // items[*].sku(必須、文字列、最大長)
            'items.*.sku' => ['required', 'string', 'max:50'],

            // items[*].unitPrice(必須、整数、0以上)
            'items.*.unitPrice' => ['required', 'integer', 'min:0'],

            // items[*].qty(必須、整数、1以上)
            'items.*.qty' => ['required', 'integer', 'min:1'],

            // items[*].tax(必須、enumのいずれか)
            'items.*.tax' => ['required', Rule::enum(TaxCategory::class)],

            // coupon(任意、指定される場合はenumのいずれか)
            'coupon' => ['nullable', Rule::enum(CouponCode::class)],
        ];
    }

    // 属性名(:attribute)を多言語化で出したい場合は lang/validation.php の attributes を使う
    // ここでは上書きしない(業務では共通化が多い)
}

7. Controller(HTTPは薄く:app/Http/Controllers/BillingController.php)

Controller は「入力をDTO/enumに変換 → Service呼び出し → JSON返却」だけにします。
エラー時メッセージはlangから取得し、ソースに直書きしません。

<?php
// app/Http/Controllers/BillingController.php

namespace App\Http\Controllers;

use App\Http\Requests\BillingCalcRequest;     // バリデーション済み入力
use App\Services\BillingRuleEngine;           // 業務ロジック
use App\Dtos\OrderInputDto;                   // 入力DTO
use App\Dtos\OrderItemDto;                    // 入力DTO(明細)
use App\Enums\CustomerRank;                   // enum変換
use App\Enums\Region;                         // enum変換
use App\Enums\TaxCategory;                    // enum変換
use App\Enums\CouponCode;                     // enum変換
use Illuminate\Http\JsonResponse;             // JSONレスポンス型
use Illuminate\Support\Facades\Log;           // ログ

final class BillingController extends Controller
{
    // ServiceをDI(テスト容易性のため)
    public function __construct(
        private readonly BillingRuleEngine $engine
    ) {}

    // 計算API
    public function calc(BillingCalcRequest $request): JsonResponse
    {
        // ここに来た時点で入力はバリデーション済み
        // ※ enum 不正値は 422(Validation error)になる

        try {
            // 入力配列を取り出す(バリデーション済み)
            $data = $request->validated();

            // customerRank を enum に変換(Rule::enumで保証されるが、DTOとして明示)
            $rank = CustomerRank::from($data['customerRank']);

            // region を enum に変換
            $region = Region::from($data['region']);

            // coupon は任意(nullの可能性)
            $coupon = isset($data['coupon']) && $data['coupon'] !== null
                ? CouponCode::from($data['coupon'])
                : null;

            // items を DTO 配列へ変換
            $items = [];

            foreach ($data['items'] as $row) {

                // tax を enum に変換
                $tax = TaxCategory::from($row['tax']);

                // 明細DTOを作る(業務ロジックはDTOのみを信用する)
                $items[] = new OrderItemDto(
                    sku: (string) $row['sku'],
                    unitPrice: (int) $row['unitPrice'],
                    qty: (int) $row['qty'],
                    tax: $tax
                );
            }

            // 入力DTOを作る
            $inputDto = new OrderInputDto(
                customerRank: $rank,
                region: $region,
                items: $items,
                coupon: $coupon
            );

            // 業務ロジック実行
            $resultDto = $this->engine->calculate($inputDto);

            // 正常レスポンス(メッセージは不要。必要なら lang から返す)
            return response()->json($resultDto->toArray(), 200);

        } catch (\Throwable $e) {

            // 原因調査用ログ(ユーザーには詳細を出さない)
            Log::error('[BillingController] calc failed', [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);

            // ユーザー向けの汎用エラー文言(langから取得)
            return response()->json([
                'message' => __('messages.errors.unexpected'),
            ], 500);
        }
    }
}

8. 多言語化(resources/lang)

「ユーザーへ返す文言」はソースに直書きせず、__('...') で lang から取得します。
※langファイルは「文字列を置く場所」なので、そこに文字列があるのは正常です(ハードコード先がlangというだけ)。

8-1. messages.php(ja:resources/lang/ja/messages.php)

<?php
// resources/lang/ja/messages.php

return [
    'errors' => [
        // 想定外エラー(500時に返す)
        'unexpected' => '予期しないエラーが発生しました。',
    ],
];

8-2. messages.php(en:resources/lang/en/messages.php)

<?php
// resources/lang/en/messages.php

return [
    'errors' => [
        // Unexpected error (returned on HTTP 500)
        'unexpected' => 'An unexpected error occurred.',
    ],
];

8-3. validation.php(ja:resources/lang/ja/validation.php)

バリデーションメッセージ/属性名もlangで管理します(Laravel標準の仕組み)。

<?php
// resources/lang/ja/validation.php

return [
    'required' => ':attribute は必須です。',
    'array'    => ':attribute の形式が不正です。',
    'min'      => ':attribute は :min 以上で指定してください。',
    'integer'  => ':attribute は整数で指定してください。',
    'string'   => ':attribute は文字列で指定してください。',
    'max'      => ':attribute は :max 文字以内で指定してください。',
    'enum'     => ':attribute の値が不正です。',

    // :attribute に入る表示名(業務ではここを整備するのが大事)
    'attributes' => [
        'customerRank'    => '顧客ランク',
        'region'          => '配送地域',
        'items'           => '注文明細',
        'items.*.sku'     => '商品コード',
        'items.*.unitPrice' => '単価',
        'items.*.qty'     => '数量',
        'items.*.tax'     => '税区分',
        'coupon'          => 'クーポン',
    ],
];

9. 動作確認(curl例)

curl -X POST http://localhost:8000/api/billing/calc \
  -H "Content-Type: application/json" \
  -d '{
    "customerRank":"GOLD",
    "region":"TOKYO",
    "items":[
      {"sku":"A001","unitPrice":980,"qty":2,"tax":"STANDARD"},
      {"sku":"B777","unitPrice":1500,"qty":1,"tax":"REDUCED"}
    ],
    "coupon":"WELCOME10"
  }'
{
  "subtotal": 3460,
  "taxTotal": 292,
  "discount": 519,
  "shipping": 500,
  "grandTotal": 3733
}
※上の数値は「端数処理=切り捨て」の例です。
実務では税計算・割引計算の端数処理(切り捨て/四捨五入/切り上げ)を仕様で固定してください。

10. 仕様チェック(要件の満たし方)

Laravel ルーティング文法の詳細:Route::post(...)->name(...) を読み解く

1. 対象コード

<?php
// routes/api.php

use Illuminate\Support\Facades\Route;       // ルーティング定義に使う Facade
use App\Http\Controllers\BillingController; // 呼び出したい Controller クラス

Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->name('billing.calc');

2. まず結論:あなたの理解で合っています

ユーザー(クライアント)が POST /api/billing/calc にリクエストを送ると、
Laravel は App\Http\Controllers\BillingController クラスの calc() メソッドを呼び出します。
(※ routes/api.php は通常 /api プレフィックスが付くため /api/billing/calc になります)

3. Route::post() の文法を分解

3-1. Route とは何か

Illuminate\Support\Facades\RouteFacade(ファサード) です。
ルーティング機能(Route登録)を簡単な書き方で呼び出せるようにした入口です。

3-2. ::post は「POSTメソッド専用のルート登録」

Route::post(...) は、HTTPメソッドが POST のときだけ一致するルートを登録します。
つまり、同じパスでも GET で叩いた場合はこのルートには一致しません(405等になります)。

3-3. 第1引数 '/billing/calc' は「URLパス(URI)」

ここで指定しているのはドメインを除いた「パス部分」です。

注意: /api が付くかどうかは、Laravelのルート設定(RouteServiceProvider等)によります。
多くの標準構成では routes/api.php/api 付きです。

3-4. 第2引数 [BillingController::class, 'calc'] は「呼び出す処理(アクション)」

第2引数は「このルートに一致したとき、何を実行するか」を表します。
Laravelでは以下の書き方が一般的です。

(A)Controllerを呼ぶ書き方:[クラス, 'メソッド名']
[BillingController::class, 'calc']

したがって、あなたの質問に対する答えはこうです:

POST /api/billing/calc が来たら、Laravelは Controller を生成(DI)し、calc() を実行します。
(B)無名関数(クロージャ)で書くこともできる
Route::post('/billing/calc', function () {
    return ['ok' => true];
});

ただし業務コードでは Controller に寄せるのが一般的です(責務分離・テスト容易性のため)。

4. ->name('billing.calc') の意味(ルート名)

4-1. ルート名とは

->name('billing.calc') は、このルートに 「名前(ルート名)」を付けています。
ルート名を付けると、URLの文字列をベタ書きせずに「名前」からURLを生成できるようになります。

4-2. ルート名を使うメリット(業務で重要)

4-3. ルート名の使い方(例)

(A)URLを生成する(routeヘルパ)
// ルート名からURLを生成
$url = route('billing.calc');
(B)リダイレクトする(redirect)
// ルート名へリダイレクト(GET向き)
return redirect()->route('billing.calc');
(C)Bladeでフォームのactionに使う
<form method="POST" action="{{ route('billing.calc') }}">
  @csrf
  ...
</form>
注意: 今回のルートは POST 用なので、ブラウザから直接URLを開く(GET)用途ではありません。
主に「フォーム送信」や「APIクライアント」から呼びます。

5. ルーティングの書き方:よく使うパターン一覧

5-1. HTTPメソッド別

Route::get('/path', ...);     // GET
Route::post('/path', ...);    // POST
Route::put('/path', ...);     // PUT(更新)
Route::patch('/path', ...);   // PATCH(部分更新)
Route::delete('/path', ...);  // DELETE

5-2. ルートパラメータ(URLに変数を含める)

// /users/10 の「10」を {id} として受け取る
Route::get('/users/{id}', [UserController::class, 'show'])
    ->whereNumber('id')        // idは数値のみ
    ->name('users.show');

5-3. ミドルウェア(認証など)

Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->middleware('auth')       // 認証が必要
    ->name('billing.calc');

5-4. ルートグループ(prefix / middleware / namespace)

Route::prefix('billing')->group(function () {
    Route::post('/calc', [BillingController::class, 'calc'])
        ->name('billing.calc');
});

6. まとめ(この1行がやっていること)

なぜ routes/api.php のルートには /api が付くのか(コードで理解する)

結論(先に要点)
routes/api.php に書いたルートに /api が付く理由は、
Laravelが「api.php を読み込むときに prefix('api') を付けて登録している」からです。
ファイル名が api.php だから自動的に付くのではなく、読み込み側のコードがそうしているのが本質です。

1. あなたが書いているコード(api.php 側)

まず、あなたが実際に書いているコードを確認します。

<?php
// routes/api.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\BillingController;

// 注文・請求の計算(JSON入力 → JSON出力)
Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->name('billing.calc');

この時点では、あなたは /billing/calc としか書いていません。
にもかかわらず、実際のURLは /api/billing/calc になります。

2. 「/api」を付けている正体のコード(Laravel側)

2-1. Laravel 10 までの典型例:RouteServiceProvider

Laravel 10 以前(またはそれに近い構成)では、次のファイルがルート読み込みの中心です。

<?php
// app/Providers/RouteServiceProvider.php

namespace App\Providers;

use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;

class RouteServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->routes(function () {

            // 👇 ここが重要
            Route::prefix('api')
                ->middleware('api')
                ->group(base_path('routes/api.php'));

            Route::middleware('web')
                ->group(base_path('routes/web.php'));
        });
    }
}
ここがポイント
routes/api.php は、
Route::prefix('api')->group(...)
の中で読み込まれています。

つまり、Laravelは内部的に次のようなことをしているのと同じです。

// Laravelが内部でやっているイメージ

Route::prefix('api')->group(function () {

    // ↓ あなたが api.php に書いたコードが、ここに「展開」される
    Route::post('/billing/calc', [BillingController::class, 'calc'])
        ->name('billing.calc');

});

その結果、実際に登録されるURLは次のようになります。

POST /api/billing/calc

3. Laravel 11 の場合(bootstrap/app.php)

Laravel 11 では RouteServiceProvider が廃止され、 ルート定義は bootstrap/app.php に集約されています。

<?php
// bootstrap/app.php(一部抜粋)

use Illuminate\Support\Facades\Route;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__.'/../routes/api.php',
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
    )
    ->create();

この withRouting() の内部実装で、Laravelは 「api ルートには api プレフィックスを付ける」 という処理を行っています。

重要(Laravel 11)
書き方は変わりましたが、意味は同じです。
routes/api.php は内部的に prefix('api') 付きで登録されます。

4. もし自分で書くとしたら(完全に同じ意味のコード)

あなたが「/api を付ける理由」を完全に理解するために、 同じことを自分で書くとどうなるかを示します。

// routes/web.php に書いたと仮定

Route::prefix('api')->group(function () {

    Route::post('/billing/calc', [BillingController::class, 'calc'])
        ->name('billing.calc');

});

これは routes/api.php に書いた場合と まったく同じURL になります。

5. まとめ(重要ポイントだけ)

実務での理解ポイント
「このURLはどこで決まっているのか?」と迷ったら、
ルートを書いているファイルではなく、そのファイルを読み込んでいる場所を見る、
これがLaravelルーティング理解のコツです。

->name('billing.calc') とは何か(ルート名の本当の意味)

まず結論
->name('billing.calc') は、
「このルートに 人間が使う識別名(ルート名) を付ける」ための指定です。

URL文字列(/api/billing/calc)を直接使う代わりに、
billing.calc という名前を通して URL を参照・生成できるようになります。

1. ルート名がない場合の問題点

まず、->name() を付けない場合を考えます。

// ルート名なし
Route::post('/billing/calc', [BillingController::class, 'calc']);

この場合、URL を使う側(Controller / Blade / JS)は URL文字列を直接書くしかありません。

// URLを直書き(変更に弱い)
$url = '/api/billing/calc';
もし将来 URL を /billing/calculate に変更した場合、
このURLを使っているコードをすべて探して修正する必要があります。

2. ->name() を付けた場合に何が変わるのか

Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->name('billing.calc');

この1行で、Laravelの内部には次のような「対応表」が登録されます。

ルート名: billing.calc
↓
HTTPメソッド: POST
URI: /api/billing/calc
処理: BillingController@calc
重要:ルート名は URL そのものではありません
ルート名 billing.calc は、
「このルートを指すためのキー(識別子)」です。
実際のURL(/api/billing/calc)とは別物です。

3. ルート名を使うと何ができるのか

3-1. URLを「生成」できる(routeヘルパ)

// ルート名からURLを生成
$url = route('billing.calc');

// 実際に生成される値
// https://example.com/api/billing/calc
ここが重要
route('billing.calc') は、
「今のルーティング定義を元に、正しいURLをLaravelに計算させる」仕組みです。
開発者がURL文字列を覚えておく必要はありません。

3-2. Bladeテンプレートで使える

<form method="POST" action="{{ route('billing.calc') }}">
  @csrf
  ...
</form>
Bladeでの利点
テンプレートにURLを直書きしないため、
URL構造が変わってもテンプレート修正が不要になります。

3-3. リダイレクトで使える

// 処理後に同じルートへ戻したい場合
return redirect()->route('billing.calc');

4. ルート名があるからできる「安全な変更」

例えば、仕様変更で URL を次のように変えたとします。

// 変更前
Route::post('/billing/calc', ...)->name('billing.calc');

// 変更後(URLだけ変更)
Route::post('/billing/calculate', ...)->name('billing.calc');
何が起きるか
  • route('billing.calc') を使っている箇所は 一切修正不要
  • Controller / Blade / テストコードはそのまま
  • URL変更の影響を「ルーティング定義1箇所」に閉じ込められる

5. ルート名の命名規則(業務での慣習)

よくある命名ルール
  • billing.calc:機能 + 動作
  • users.index / users.edit / users.update
  • ドット区切りで「意味の階層」を表す

6. まとめ(この1行の本質)

一言で言うと
ルート名とは「URLを直接書かずに済ませるための業務用ラベル」です。

Route::name() は何が得なのか(URL比較→何が起きるかまで)

1. まず最初に:name を付けても「URLは変わりません」

ここが最重要ポイントです。
name を付けても付けなくても、ユーザーが叩くURLは同じです。
変わるのは「アプリの中でそのURLをどう扱うか」です。

ケース ルート定義 登録されるURL(実際に叩くURL) アプリ内での呼び出し方
name なし Route::post('/billing/calc', ...); POST /api/billing/calc URLを使う側が "/api/billing/calc"直接書くしかない
name あり Route::post('/billing/calc', ...)->name('billing.calc'); POST /api/billing/calc同じ URLを使う側は route('billing.calc')URLを生成できる
重要:
name は「URLを変える機能」ではありません。
name は「そのルートに “別名(ID)” を付ける機能」です。
そのIDを使うと、Laravelが正しいURL文字列を作ってくれます。

2. 「何が得するのか?」を、起きる事態で説明します

2-1. 事態①:URLが仕様変更される(業務で頻発)

例:API設計変更で、URLをこう変えることになったとします。

変更前 変更後
POST /api/billing/calc POST /api/billing/calculate
❌ name なしで起きること(面倒な事態)
アプリ内のあちこちにURL文字列が散らばっているため、全修正が必要

例:Controller で直書きしている場合
$url = '/api/billing/calc'; // ← ここを変更しないと壊れる
Http::post($url, $payload);
例:Blade の form で直書きしている場合
<form method="POST" action="/api/billing/calc">...</form>
例:JavaScript で直書きしている場合
fetch('/api/billing/calc', { method: 'POST', ... });
結果: "/api/billing/calc" を探して全部修正(漏れるとバグ)
✅ name ありで起きること(得する事態)
URLはルーティング定義1か所だけが知っているので、修正はそこだけ

ルート定義だけ変更する
// 変更前
Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->name('billing.calc');

// 変更後(URLだけ変更、nameは同じ)
Route::post('/billing/calculate', [BillingController::class, 'calc'])
    ->name('billing.calc');
呼び出し側は一切変えない(全部これで動く)
$url = route('billing.calc'); // ← 常に「最新のURL」に解決される
Http::post($url, $payload);
結果: 仕様変更に強くなる(修正箇所が1か所に閉じる)

2-2. 事態②:環境によってURLの先頭が変わる(ローカル/本番/サブディレクトリ)

例えば次のような環境差が現実に起きます。

環境 ベースURL例 最終的なAPI URL例
ローカル http://localhost:8000 http://localhost:8000/api/billing/calc
本番 https://example.com https://example.com/api/billing/calc
サブディレクトリ配下 https://example.com/app https://example.com/app/api/billing/calc
name なし(直書き)の危険
"/api/billing/calc" のような直書きは、環境差(/app など)でズレやすいです。
「この環境だけ動かない」が起きやすくなります。
name あり(route生成)の強み
route('billing.calc') は、Laravelが「今の環境設定(APP_URL等)」に合わせて正しいURLを生成します。
つまり、環境差をLaravelに吸収させられます

2-3. 事態③:パラメータ付きURLで特に差が出る

id のようなパラメータがあると、直書きはさらに事故りやすいです。

// 例:ユーザー詳細
Route::get('/users/{id}', [UserController::class, 'show'])
    ->name('users.show');
name ありだと、URL組み立てをLaravelに任せられる
// 正しいURLを自動生成:/api/users/10 のようになる
$url = route('users.show', ['id' => 10]);
直書きだと、"/api/users/".$id のように手作業で組み立ててミスしやすくなります。

3. まとめ:name を付けた場合に「得すること」

あなたが引っかかっている点への答え
「name を付けた場合(URLを直接書かない世界)」とは、
URL(/api/billing/calc)という文字列をコード中に書かず、代わりに billing.calc という名前で参照する、という意味です。

「使う側の関数内で何が変わるのか」:name なし vs name あり(全体像)

あなたが混乱しているポイントはここです:
URLそのもの(/api/billing/calc)は、name を付けても付けなくても同じ
変わるのは「使う側のコード(Controller / Blade / JS など)が、URLをどう指定するか」です。
つまり、使う側の関数の中の書き方が変わるという話です。

0. 前提:このAPIの「実際のURL」はこうなる

ルート定義ファイル あなたが書くパス 実際に叩くURL(結果)
routes/api.php Route::post('/billing/calc', ...) POST /api/billing/calc

1. ケースA:->name() なし(使う側がURL文字列を持つ)

1-1. ルート定義(nameなし)

// routes/api.php
Route::post('/billing/calc', [BillingController::class, 'calc']);
このときの状況
Laravelには「billing.calc」という名前が登録されません。
だから、使う側(Controller/Blade/JS)は /api/billing/calc というURL文字列を、
自分で書いて持つしかない状態になります。

1-2. 使う側の関数(例:Blade画面からAPIを叩くController)

例として「注文入力画面を表示するController」が、APIのエンドポイントURLをビューへ渡す場面を想定します。

<?php
// app/Http/Controllers/OrderPageController.php

namespace App\Http\Controllers;

use Illuminate\View\View;

class OrderPageController extends Controller
{
    public function show(): View
    {
        // ❌ name がないので、URLを直書きするしかない
        $billingCalcUrl = '/api/billing/calc';

        return view('order.page', [
            'billingCalcUrl' => $billingCalcUrl,
        ]);
    }
}

1-3. Blade(JSがURLを受け取ってfetchする)

<!-- resources/views/order/page.blade.php -->

<script>
  // Controllerから渡されたURL(直書き由来)
  const billingCalcUrl = @json($billingCalcUrl);

  async function calcBilling(payload){
    const res = await fetch(billingCalcUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
    return await res.json();
  }
</script>

1-4. そして「URL変更」が起きると何が起きるか

仕様変更: /api/billing/calc/api/billing/calculate

この場合、OrderPageController::show() の中に直書きした
'/api/billing/calc' を探して修正しなければ壊れます。
もし他のControllerやBladeやJSにも直書きがあれば、それらも全部探して直します。

2. ケースB:->name('billing.calc') あり(使う側は「名前」だけを持つ)

2-1. ルート定義(nameあり)

// routes/api.php
Route::post('/billing/calc', [BillingController::class, 'calc'])
    ->name('billing.calc');
このときの状況
Laravelに「billing.calc」という名前が登録されます。
だから、使う側(Controller/Blade/JS)は URL文字列を直書きせずに、
route('billing.calc') で「今の正しいURL」をLaravelに作らせることができます。

2-2. 使う側の関数(同じ OrderPageController でもこう変わる)

<?php
// app/Http/Controllers/OrderPageController.php

namespace App\Http\Controllers;

use Illuminate\View\View;

class OrderPageController extends Controller
{
    public function show(): View
    {
        // ✅ URLを直書きしない。ルート名からLaravelに生成させる。
        //    (環境によって http://localhost:8000/api/... なども正しく解決される)
        $billingCalcUrl = route('billing.calc');

        return view('order.page', [
            'billingCalcUrl' => $billingCalcUrl,
        ]);
    }
}

2-3. Blade(JS側は同じでもOK)

Blade/JSは「URL文字列」をもらって使うだけなので、ここは変わらないことが多いです。
変わるのはURL文字列の作り方(生成方法)です。

<!-- resources/views/order/page.blade.php -->

<script>
  // Controllerが route('billing.calc') で生成したURLが入る
  const billingCalcUrl = @json($billingCalcUrl);

  async function calcBilling(payload){
    const res = await fetch(billingCalcUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });
    return await res.json();
  }
</script>

2-4. そして「URL変更」が起きると何が起きるか

仕様変更: /api/billing/calc/api/billing/calculate

修正する場所はルート定義の1行だけです。
// routes/api.php(ここだけ変更する)
Route::post('/billing/calculate', [BillingController::class, 'calc'])
    ->name('billing.calc'); // ← nameは同じにしておく
すると、使う側の route('billing.calc') が自動的に新URLを生成するので、
OrderPageController::show() も Blade も JS も修正不要になります。

3. まとめ:あなたが知りたかった「使う側の関数内の違い」

観点 name なし name あり
使う側の関数の中でどう書く? $url = '/api/billing/calc';(直書き) $url = route('billing.calc');(生成)
URL変更が起きたら? 直書き箇所を全検索して修正(漏れるとバグ) ルート定義1か所を修正(使う側は修正不要)
「得」って何? 得はない(小規模なら成立) 変更耐性・保守性・修正漏れ防止
一言で
->name() を付けると、使う側の関数は「URL文字列」ではなく「名前」を持ち、
URLは Laravel がその都度生成します。
これが業務で強い理由です(URL変更・環境差・修正漏れに強い)。

Laravelで「extends が必要/推奨」なクラス一覧(定義例つき)

Laravelでは「フレームワークに認識される役割」を持つクラス(Controller / FormRequest / Model / Migration / Seeder / ServiceProvider 等)は、 特定の基底クラスを extends するのが基本です。
一方、Repository / Service / DTO などはフレームワークに直接認識させる必要がないため、 通常は extends 不要(プレーンPHPクラス)です。

1. extends が必要(またはほぼ必須)なもの

種類 extends 元 理由(何が起きるクラスか)
Controller App\Http\Controllers\Controller ミドルウェア、認可、共通処理などをController層で扱える
FormRequest Illuminate\Foundation\Http\FormRequest バリデーション・authorize を Laravel の仕組みに載せる
Eloquent Model(Entity相当) Illuminate\Database\Eloquent\Model EloquentのCRUD・クエリビルダ・属性管理を使う
Migration Illuminate\Database\Migrations\Migration artisan migrate が up/down を呼ぶ対象になる
Seeder Illuminate\Database\Seeder artisan db:seed が run() を呼ぶ対象になる
Factory Illuminate\Database\Eloquent\Factories\Factory モデル生成(テスト・ダミーデータ)をLaravel標準で扱える
ServiceProvider Illuminate\Support\ServiceProvider DIバインド、boot処理などをLaravel起動時に差し込める
Console Command Illuminate\Console\Command artisan コマンドとして handle() が実行される
Job extends不要のことも多い(Trait中心) Laravelはインターフェイス/traitで動かすため、extends必須ではない
Event / Listener 通常 extends 不要 POPOでも良い(Laravelは契約と自動解決で動く)

2. extends が不要(プレーンPHPクラスでOK)なもの

種類 extends 理由
Service(業務ロジック層) 不要 DIで解決されるだけ。Laravelの基底クラス機能は不要
Repository(DBアクセス抽象化) 不要 Interface + 実装をDIで差し替えればよい
DTO 不要 データの運搬用。継承する意味がない
View(Bladeファイル) なし PHPクラスではなくテンプレート
Exception(独自例外) RuntimeException 等を extends 例外は基本的に extends する(Throwableにする必要がある)
Interface(契約) extendsなし(interface間継承は可能) 実装側が implements する

3. 「関数の定義部分」つき:最小のクラス定義例

3-1. Controller(extends が必要)

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller; // 基底Controller
use Illuminate\Http\JsonResponse;    // JSONレスポンス型
use Illuminate\Http\Request;         // リクエスト

class BillingController extends Controller
{
    // POST /api/billing/calc を処理するアクション
    public function calc(Request $request): JsonResponse
    {
        // ...処理...
        return response()->json(['ok' => true]);
    }
}

3-2. FormRequest(extends が必要)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class BillingCalcRequest extends FormRequest
{
    // 認可(ログイン必須などをここで判定できる)
    public function authorize(): bool
    {
        return true;
    }

    // バリデーションルール
    public function rules(): array
    {
        return [
            'region' => ['required', 'string'],
            'items'  => ['required', 'array'],
        ];
    }
}

3-3. Eloquent Model(Entity相当:extends が必要)

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'users';

    // 必要なら上書き(例:Laravel標準のupdated_atを使わない等)
    public $timestamps = false;
}

3-4. Migration(extends が必要)

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {

    // テーブル作成
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('username');
            $table->string('email');
            $table->string('tel')->nullable();
            $table->string('address')->nullable();
            $table->timestamp('update_at')->nullable();
        });
    }

    // ロールバック
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

3-5. ServiceProvider(extends が必要)

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\Eloquent\UserRepository;

class AppServiceProvider extends ServiceProvider
{
    // DI登録(起動時に呼ばれる)
    public function register(): void
    {
        $this->app->bind(UserRepositoryInterface::class, UserRepository::class);
    }

    // 起動後処理(必要なら)
    public function boot(): void
    {
        //
    }
}

3-6. Seeder(extends が必要)

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class UsersSeeder extends Seeder
{
    // db:seed で呼ばれる
    public function run(): void
    {
        DB::table('users')->insert([
            'username' => 'alice',
            'email'    => 'alice@example.com',
            'tel'      => '090-0000-0000',
            'address'  => 'Tokyo',
            'update_at' => now(),
        ]);
    }
}

3-7. Exception(extends が必要:Throwableにするため)

<?php

namespace App\Exceptions;

use RuntimeException;

class ServiceException extends RuntimeException
{
}

3-8. Service(extends 不要:プレーンPHP)

<?php

namespace App\Services;

class BillingService
{
    // 業務ロジック(計算処理)
    public function calc(array $input): array
    {
        // ...計算...
        return ['grandTotal' => 0];
    }
}

3-9. Repository(extends 不要:プレーンPHP)

<?php

namespace App\Repositories\Eloquent;

use App\Repositories\Contracts\UserRepositoryInterface;

class UserRepository implements UserRepositoryInterface
{
    public function findById(int $id): ?array
    {
        // ...DBアクセス...
        return null;
    }
}

3-10. DTO(extends 不要:プレーンPHP)

<?php

namespace App\Dtos;

class UserUpdateDto
{
    public function __construct(
        public int $id,
        public string $email,
        public ?string $tel
    ) {}
}

4. ひとこと(業務の勘所)

extends が必要なのは「Laravelが自動で呼ぶ/機能を付与する」クラスです。
逆に Service / Repository / DTO は「自分たちの業務コード」なので、基本は継承させず単純に保つほうが読みやすく保守しやすいです。

Laravel の主要クラス種類を「いつ・なぜ使うか」で理解する

Laravel には多くの「クラスの種類」がありますが、
初心者が一番つまずくのは 「結局、どの局面でどれを使えばいいのか分からない」点です。
ここでは 実際の業務の流れに沿って、
「この局面ではこのクラス」という形で説明します。


全体像(まずこれだけ覚えてください)

クラス種類 一言で 使うタイミング
Controller入口HTTPリクエストを受ける
FormRequest入力チェック係入力値を検証したいとき
Eloquent ModelDBの1行DBとデータをやり取りするとき
Migration表設計書テーブル構造を作る・変更する
Seeder初期データ投入テスト用・初期データを入れる
Factoryダミーデータ工場大量のテストデータが欲しい
ServiceProvider初期設定係アプリ起動時に設定したい
Console CommandCLI処理php artisan で何かしたい
Job裏でやる仕事時間がかかる処理
Event / Listener合図と反応何か起きたら別処理を動かす

1. Controller(必須・最初に通る)

役割: ブラウザやAPIからのリクエストを最初に受け取る場所。
「受付係」です。

<?php
class UserController extends Controller
{
    // GET /users
    public function index()
    {
        return view('users.index');
    }
}
画面表示・API入口は 必ず Controller から始まります。

2. FormRequest(入力チェック専用)

役割: フォームやAPI入力が正しいかを検証する。
Controller に if 文を大量に書かないための仕組み。

<?php
class UserStoreRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
        ];
    }
}
「入力チェックが必要になったら FormRequest」

3. Eloquent Model(Entity相当)

役割: DBテーブルの1行を PHP オブジェクトとして扱う。

<?php
class User extends Model
{
    protected $table = 'users';
}
「DBの users テーブルを触るなら User モデル」

4. Migration(DB設計そのもの)

役割: テーブル構造をコードで管理する。

Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('email');
});
「テーブル定義は Migration にしか書かない」

5. Seeder(初期データ)

役割: テストや初期状態用のデータ投入。

DB::table('users')->insert([
    'email' => 'test@example.com',
]);

6. Factory(ダミーデータ生成)

役割: テスト用データを大量生成。

User::factory()->count(10)->create();
Seeder と一緒に使われることが多い

7. ServiceProvider(アプリ起動時の設定)

役割: アプリ起動時に一度だけ行う設定。

public function register()
{
    $this->app->bind(
        UserRepositoryInterface::class,
        UserRepository::class
    );
}

8. Console Command(CLI処理)

役割: バッチ処理・管理用コマンド。

class CleanupCommand extends Command
{
    protected $signature = 'cleanup:users';

    public function handle()
    {
        // バッチ処理
    }
}

9. Job(時間がかかる処理)

役割: メール送信などを裏で実行。

class SendMailJob implements ShouldQueue
{
    public function handle()
    {
        // 重い処理
    }
}
「レスポンスを早く返したいときに Job」

10. Event / Listener(何か起きたら反応)

役割: 「〇〇が起きたら××する」を分離。

// Event
class UserRegistered {}

// Listener
class SendWelcomeMail
{
    public function handle(UserRegistered $event)
    {
        // 登録後メール
    }
}
Controller に全部書かないための仕組み

最後に(初心者向けの覚え方)

最初は Controller + FormRequest + Model だけで十分です。
他は「必要になったら足す」で問題ありません。

SQLite3(Laravel)で「テーブル作成 → 初期データ投入」までの流れ(Mermaid + コード例)

重要(業務の常識)
Laravel では通常、HTTPリクエストのたびに「テーブルが無ければ作る」はやりません。
理由:同時アクセス時の競合・性能・予期しないスキーマ変更・本番事故の原因になるため。
その代わり、起動(=環境セットアップ/テスト開始)時に Migration/Seeder を実行します。
ここでは「テスト環境(またはローカル環境)」で、PHP起動~テーブル作成~初期データ投入までを一連で説明します。

1) 全体の流れ(Mermaid)

1-1. 実行の流れ(テスト/ローカルでよくあるパターン)

sequenceDiagram autonumber participant Dev as 開発者/CI participant PHP as PHP起動(phpunit / artisan / php-fpm) participant L as Laravel Bootstrap participant DB as SQLiteファイル(database.sqlite) participant M as Migration(スキーマ作成) participant S as Seeder(初期データ投入) participant App as アプリ(Controller/Service) Dev->>PHP: php artisan test / phpunit / 起動コマンド実行 PHP->>L: Laravelアプリを起動(bootstrap/app.php) L->>DB: sqliteファイル存在確認(なければ作成) L->>M: migrate / migrate:fresh を実行(users, inventories 作成) M->>DB: CREATE TABLE ... L->>S: --seed 指定なら Seeder 実行 S->>DB: INSERT 初期データ L->>App: テスト/画面/API処理が開始できる状態になる

1-2. 「どのクラス(関数)が動くか」の対応表

段階 主役クラス 主役メソッド 何をする
Laravel起動 bootstrap/app.php 等 (フレームワーク内部) .env読込・サービスプロバイダ登録・DB設定準備
DB準備 SQLiteファイル (OS/FS) database/database.sqlite が必要(なければ作る)
テーブル作成 Migration up() CREATE TABLE を実行
初期データ投入 Seeder run() INSERT を実行
利用開始 Controller/Service/Repo 各アクション/各業務関数 出来上がったDBを前提に通常処理

2) SQLite 設定(.env と DBファイル)

2-1. .env(SQLiteを使う)

DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/your-project/database/database.sqlite

2-2. SQLiteファイルを作る(無いと接続エラー)

# プロジェクト直下で
touch database/database.sqlite

3) テーブル定義(Migrationコード例)

今回の要件では update_at(一般的な updated_at ではない)を使うため、
Migrationでは Laravel標準の $table->timestamps() は使わず、update_at を自前で作ります。

3-1. users テーブル

カラム: id, userName, tel, email, address, update_at

<?php
// database/migrations/2026_02_16_000001_create_users_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {

    // テーブル作成(migrate で呼ばれる)
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {

            $table->id();                         // id(主キー・auto increment)
            $table->string('userName', 100);      // userName(ユーザー名)
            $table->string('tel', 50)->nullable();// tel(電話、null可)
            $table->string('email', 255);         // email
            $table->string('address', 255)->nullable(); // address(住所、null可)

            // 要件:update_at(通常の updated_at ではない)
            $table->timestamp('update_at')->nullable();

            // 検索されがちな項目は index を張る(任意)
            $table->index('userName');
            $table->index('email');
        });
    }

    // ロールバック(migrate:rollback で呼ばれる)
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

3-2. 在庫テーブル(ここでは inventories と命名)

カラム: id, 商品コード, 在庫量, 次回入庫日, 入荷単位個数, 入荷値段, 販売価格, update_at

<?php
// database/migrations/2026_02_16_000002_create_inventories_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {

    public function up(): void
    {
        Schema::create('inventories', function (Blueprint $table) {

            $table->id();                                  // id
            $table->string('product_code', 50);            // 商品コード
            $table->integer('stock_qty');                  // 在庫量(整数)
            $table->date('next_arrival_date')->nullable(); // 次回入庫日(null可)
            $table->integer('arrival_unit_qty');           // 入荷単位個数(例:12個単位)
            $table->integer('arrival_price');              // 入荷値段(仕入れ価格・整数)
            $table->integer('sale_price');                 // 販売価格(整数)
            $table->timestamp('update_at')->nullable();     // 更新日時(独自)

            $table->unique('product_code');                // 商品コードはユニーク想定
            $table->index('next_arrival_date');            // 任意(検索用)
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('inventories');
    }
};

4) 初期データ投入(Seederコード例)

4-1. DatabaseSeeder(Seederの入口)

<?php
// database/seeders/DatabaseSeeder.php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    // db:seed / --seed で最初に呼ばれる
    public function run(): void
    {
        // 個別Seederを呼ぶ(必要な分だけ)
        $this->call([
            UsersSeeder::class,
            InventoriesSeeder::class,
        ]);
    }
}

4-2. users 初期データ

<?php
// database/seeders/UsersSeeder.php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class UsersSeeder extends Seeder
{
    // 初期データ投入(INSERT)
    public function run(): void
    {
        DB::table('users')->insert([
            [
                'userName'  => 'alice',
                'tel'       => '090-1111-1111',
                'email'     => 'alice@example.com',
                'address'   => 'Tokyo',
                'update_at' => now(),
            ],
            [
                'userName'  => 'bob',
                'tel'       => null,
                'email'     => 'bob@example.com',
                'address'   => 'Osaka',
                'update_at' => now(),
            ],
        ]);
    }
}

4-3. inventories 初期データ

<?php
// database/seeders/InventoriesSeeder.php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class InventoriesSeeder extends Seeder
{
    public function run(): void
    {
        DB::table('inventories')->insert([
            [
                'product_code'      => 'A001',
                'stock_qty'         => 25,
                'next_arrival_date' => '2026-03-01',
                'arrival_unit_qty'  => 10,
                'arrival_price'     => 800,
                'sale_price'        => 1200,
                'update_at'         => now(),
            ],
            [
                'product_code'      => 'B777',
                'stock_qty'         => 0,
                'next_arrival_date' => '2026-02-20',
                'arrival_unit_qty'  => 12,
                'arrival_price'     => 1500,
                'sale_price'        => 2200,
                'update_at'         => now(),
            ],
        ]);
    }
}

5) 実行コマンド(「作成→投入」を一発で)

5-1. ローカル/テストで定番:作り直して seed まで

# DBを作り直して(全テーブルDROP→作成)seedまで実行
php artisan migrate:fresh --seed
これが「起動時にテーブルが無ければ作る・初期データを入れる」の実務的な答えです。
= HTTP処理の中でやらず、環境セットアップ(テスト開始/起動前)で1回やる。

6) 「PHP起動時(テスト開始時)に自動でやる」例:PHPUnit setUp(テスト環境)

テストでは「テスト開始時にDBを必ず新しくして初期データを入れる」ことが多いです。
その場合、テストクラスの setUp で artisan を呼ぶことがあります(例)。

<?php
// tests/Feature/BootstrapDbTest.php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Support\Facades\Artisan;

class BootstrapDbTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // ① テーブル作り直し(fresh)
        // ② 初期データ投入(--seed)
        Artisan::call('migrate:fresh', ['--seed' => true]);
    }

    public function test_example(): void
    {
        $this->assertTrue(true);
    }
}
注意
これは「テスト用」の考え方です。
本番でリクエストのたびに migrate/seed を走らせるのは避けてください。

7) 参考:Model(Entity相当)を置く場合(update_atなのでtimestamps無効化)

7-1. User モデル

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'users';

    // Laravel標準の created_at / updated_at を使わない
    public $timestamps = false;

    protected $fillable = [
        'userName', 'tel', 'email', 'address', 'update_at',
    ];
}

7-2. Inventory モデル

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Inventory extends Model
{
    protected $table = 'inventories';

    public $timestamps = false;

    protected $fillable = [
        'product_code',
        'stock_qty',
        'next_arrival_date',
        'arrival_unit_qty',
        'arrival_price',
        'sale_price',
        'update_at',
    ];
}

8) まとめ(初心者向け)

Laravelにおける「初期テーブル作成」と「Webページ表示」の違いと操作方法

Laravelでは、 ① データベース(テーブル・初期データ)を準備する操作② Webページ(画面・API)を表示するためにサーバーを起動する操作完全に別の役割 です。
それぞれ目的が異なり、実行する php artisan コマンドも異なります。

1. 全体像(結論を先に)

目的 何が起きるか 叩くコマンド サーバー起動
初期テーブル作成 Migrationが実行され、テーブルが作成される php artisan migrate ❌ 起動しない
テーブル作成+初期データ テーブル再作成後、Seederで初期データ投入 php artisan migrate:fresh --seed ❌ 起動しない
Webページ表示 Webサーバーが起動し、画面/APIが利用可能 php artisan serve ✅ 起動する
テスト実行 サーバーを起動せず内部的にHTTP処理を実行 php artisan test ❌ 起動しない

2. 初期テーブル作成は「環境準備」の作業

SQLiteを使う場合、最初にDBファイルとテーブルを準備する必要があります。
これは Webアクセスとは無関係な「セットアップ作業」です。

2-1. SQLiteファイルを作成(最初の1回のみ)

touch database/database.sqlite

2-2. テーブルを作成する

php artisan migrate

2-3. テーブル作成+初期データ投入(テストでよく使う)

php artisan migrate:fresh --seed
意味
既存テーブルをすべて削除し、Migrationで作り直した後、
Seederの run() により初期データが INSERT されます。

3. Webページを表示するのは「サーバー起動」

テーブル作成が終わった後に、
実際に画面やAPIを利用するために Webサーバーを起動します。

3-1. Webサーバー起動コマンド

php artisan serve
注意
php artisan serve は「表示専用」です。
テーブルが無い状態で画面を開くと、DBエラーになります。

4. よくある誤解と正しい理解

❌ 誤解①:serveを叩くとテーブルが作られる

→ 作られません。
serve は Webサーバーを起動するだけです。

❌ 誤解②:migrateを叩かないとWebが見れない

→ ページ自体は表示されますが、DBアクセス時にエラーになります。

✅ 正しい流れ

  1. php artisan migrate:fresh --seed(環境準備)
  2. php artisan serve(Web表示)

5. テスト環境の場合(サーバーは起動しない)

php artisan test

6. まとめ(覚えるべきコマンドはこれだけ)

① DB初期化(作成+初期データ)
php artisan migrate:fresh --seed

② Webページ表示
php artisan serve

③ テスト実行
php artisan test
重要な考え方
Migration / Seeder は「起動前の準備作業」。
serve は「アプリを公開する操作」。
両者は役割もタイミングも完全に別です。

Laravel(SQLite + Migration + Web)の一般的なプロジェクト構成ツリー

このセクションでは、Migration(テーブル定義)や Seeder(初期データ)を含めた
実務でよく使われる Laravel プロジェクト全体のディレクトリ構成を示します。
「どのファイルが・いつ・何のために使われるか」が分かるよう、役割も併記しています。

1. プロジェクト全体ツリー(重要部分のみ)

your-project/
├─ app/
│  ├─ Console/
│  │  └─ Kernel.php                # artisanコマンドのスケジューリング定義
│  │
│  ├─ Exceptions/
│  │  └─ Handler.php               # 例外の共通処理(500/404など)
│  │
│  ├─ Http/
│  │  ├─ Controllers/
│  │  │  ├─ Controller.php         # 全Controllerの基底クラス
│  │  │  ├─ UserController.php     # 画面/API用Controller
│  │  │  └─ BillingController.php  # 請求計算API用Controller
│  │  │
│  │  ├─ Middleware/
│  │  │  └─ Authenticate.php       # 認証などの前処理
│  │  │
│  │  └─ Requests/
│  │     ├─ UserUpdateRequest.php  # 入力バリデーション
│  │     └─ BillingRequest.php
│  │
│  ├─ Models/
│  │  ├─ User.php                  # usersテーブルのEntity(Eloquent)
│  │  └─ Inventory.php             # inventoriesテーブルのEntity
│  │
│  ├─ Services/
│  │  ├─ UserService.php           # 業務ロジック層
│  │  └─ BillingService.php
│  │
│  ├─ Repositories/
│  │  ├─ Contracts/
│  │  │  └─ UserRepositoryInterface.php
│  │  └─ Eloquent/
│  │     └─ UserRepository.php     # DBアクセス実装
│  │
│  └─ Providers/
│     └─ AppServiceProvider.php    # DIバインド(Interface → 実装)
│
├─ bootstrap/
│  └─ app.php                      # Laravel起動点(最初に読まれる)
│
├─ config/
│  ├─ app.php                      # アプリ基本設定
│  └─ database.php                 # DB設定(sqlite等)
│
├─ database/
│  ├─ database.sqlite              # SQLite DBファイル(実体)
│  │
│  ├─ migrations/
│  │  ├─ 2026_02_16_000001_create_users_table.php
│  │  └─ 2026_02_16_000002_create_inventories_table.php
│  │
│  ├─ seeders/
│  │  ├─ DatabaseSeeder.php        # Seederの入口
│  │  ├─ UsersSeeder.php           # users 初期データ
│  │  └─ InventoriesSeeder.php     # inventories 初期データ
│
├─ public/
│  └─ index.php                    # Webアクセスの入口(/)
│
├─ resources/
│  ├─ views/
│  │  ├─ users/
│  │  │  ├─ index.blade.php        # 一覧画面
│  │  │  └─ edit.blade.php         # 編集画面
│  │  └─ layout.blade.php
│  │
│  └─ lang/
│     ├─ ja/
│     │  ├─ messages.php           # 日本語メッセージ
│     │  └─ validation.php
│     └─ en/
│        ├─ messages.php
│        └─ validation.php
│
├─ routes/
│  ├─ web.php                      # 画面用ルーティング
│  └─ api.php                      # API用ルーティング(/api プレフィックス)
│
├─ tests/
│  ├─ Feature/
│  │  └─ UserTest.php              # HTTP/APIテスト
│  └─ Unit/
│     └─ BillingServiceTest.php    # 業務ロジック単体テスト
│
├─ .env                            # 環境変数(DB接続など)
├─ artisan                         # artisanコマンド実行ファイル
└─ composer.json                   # PHP依存関係

2. Migration関連ファイルの位置と役割

パス 役割 いつ使われるか
database/migrations/*.php テーブル構造を定義する php artisan migrate 実行時
up() テーブル作成・変更 migrate / migrate:fresh
down() テーブル削除・巻き戻し migrate:rollback
ポイント
Migrationは「プログラム起動時」に自動で動くものではありません。
必ず php artisan migrate 系コマンドで 明示的に実行します。

3. Seeder関連ファイルの位置と役割

パス 役割 実行タイミング
database/seeders/DatabaseSeeder.php Seeder全体の入口 --seed 指定時
UsersSeeder.php users 初期データ投入 php artisan db:seed
または
migrate:fresh --seed
InventoriesSeeder.php inventories 初期データ投入

4. 「Migration / Seeder」と「Web表示」の関係

実務上の原則
HTTPリクエスト中にテーブル作成や初期データ投入は行わない。
必ず「起動前・テスト開始前」に artisan コマンドで実行する。

5. 初心者向け一言まとめ

Laravel(PHP)入門:Hello, world を表示する(Controller → Service → Blade)

この手順は「画面に Hello, world を表示するだけ」ですが、
実務でよくある層(Controller → Service → View/Blade)の形をあえて作ります。

目的は「Laravelの基本的なファイル構成と役割を理解すること」です。
実際のコードはシンプルなので、初心者の方も安心して進めてください。

1. このサンプルの完成イメージ


2. 用語ミニ辞典(初心者向け)

用語 意味(超ざっくり) このサンプルでの役割
Route(ルート / ルーティング) URL と処理(Controller)を結びつける /hello に来たら Controller を呼ぶ
Controller(コントローラー) HTTPリクエストを受けて、処理を振り分ける Service を呼び、Blade を返す
Service(サービス) 業務ロジック(計算や判断)を書く場所 Hello, world の文字列を返す
Blade(ブレード) HTMLのテンプレート(LaravelのView) 受け取った文字列を画面に表示
DI(依存性注入) 必要な部品(Service等)を Laravel が自動で渡してくれる仕組み Controller のコンストラクタに Service を渡す

3. VS Code で Laravel プロジェクトを作る(作業手順)

この手順は Windows を想定します。
事前に PHPComposer がインストールされている必要があります。

3-1. 作業フォルダを作る

  1. 任意の場所にフォルダを作成(例:C:\work
  2. VS Code でフォルダを開く(ファイル → フォルダーを開く

3-2. ターミナルを開く

3-3. Laravel プロジェクトを作成する

ターミナルで以下を実行します(プロジェクト名は例です)。

cd C:\work
composer create-project laravel/laravel hello-laravel
cd hello-laravel
補足:composer create-project とは?
Laravel の「ひな型(テンプレ)」をダウンロードし、すぐ動く形でプロジェクトを作るコマンドです。

4. 追加で作るファイル一覧(このサンプルで増えるもの)

routes/
  web.php                     # ルート定義(/hello)

app/
  Http/
    Controllers/
      HelloController.php      # /hello を処理する

  Services/
    HelloService.php           # Hello, world を返す(業務ロジック役)

resources/
  views/
    hello.blade.php            # 画面テンプレ(Hello, world を表示)

5. 具体的なコード(コピペで作れます)

5-1. Route:routes/web.php

/hello にアクセスしたら HelloController@index を呼ぶようにします。

<?php
// routes/web.php

use Illuminate\Support\Facades\Route;          // ルーティング機能
use App\Http\Controllers\HelloController;      // HelloController を使う

// GET /hello へのアクセスを HelloController@index に割り当てる
Route::get('/hello', [HelloController::class, 'index'])
    ->name('hello.index');
ポイント
Route::get は「GETリクエスト(ブラウザでURLを開く操作)」を受け取ります。
name('hello.index') はこのルートに名前を付け、route('hello.index') でURL生成できるようにします。

5-2. Service:app/Services/HelloService.php

「実務では業務ロジックを書く場所」ですが、今回は練習として Hello, world を返します。

<?php
// app/Services/HelloService.php

namespace App\Services; // このクラスが属する場所(フォルダ構造と一致)

class HelloService
{
    /**
     * 画面に表示するメッセージを返す
     *
     * 実務なら:
     * ・DBから値を取る
     * ・計算する
     * ・ルール判定する
     * などをここに書く
     */
    public function getMessage(): string
    {
        // 今回は固定文字列を返すだけ
        return 'Hello, world';
    }
}
注意(学習ポイント)
この例では Service に固定文字列があります。
しかし「画面に出す文言をすべて多言語化ファイルに寄せる」方針の現場も多いです。
今は仕組み理解が目的なので、ここでは最小の例にしています。

5-3. Controller:app/Http/Controllers/HelloController.php

Controller は HTTPを受ける担当で、Serviceを呼び、Bladeへ渡します。
LaravelのControllerは通常 extends Controller(基底クラス継承)を使います。

<?php
// app/Http/Controllers/HelloController.php

namespace App\Http\Controllers; // Controllerはこの名前空間が基本

use App\Services\HelloService;   // Service を使う
use Illuminate\View\View;        // 戻り値の型(画面)

class HelloController extends Controller
{
    // Service を保持する(DI で注入される)
    private HelloService $helloService;

    /**
     * コンストラクタ(Controllerが作られるときに呼ばれる)
     *
     * DI(依存性注入)により、Laravel が HelloService を自動生成して渡してくれる
     */
    public function __construct(HelloService $helloService)
    {
        $this->helloService = $helloService; // 渡されたServiceを保持
    }

    /**
     * GET /hello の処理
     *
     * 1) Serviceからメッセージを取得
     * 2) Bladeに渡して画面表示
     */
    public function index(): View
    {
        // Serviceからメッセージ取得
        $message = $this->helloService->getMessage();

        // Bladeへ渡して表示
        return view('hello', [
            'message' => $message,
        ]);
    }
}
あなたの認識の確認
「Controller から呼ばれるのは Service でいい?」は 基本的にOKです。
ControllerはHTTPの入り口なので、業務処理はServiceへ寄せるのが一般的です。

5-4. Blade:resources/views/hello.blade.php

BladeはHTMLテンプレートです。Controllerから渡された $message を表示します。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>Hello</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>

  <h1>{{ $message }}</h1>

</body>
</html>
Bladeの {{ }} とは?
{{ $message }} は「PHP変数をHTMLに埋め込む」書き方です。
Laravelでは安全のため自動エスケープ(危険な文字を無害化)が入ります。

6. 具体的な作業手順(VS Codeでのファイル作成)

  1. VS Code で hello-laravel フォルダを開く
  2. routes/web.php を開き、上記の Route を追記する
  3. 左のエクスプローラーで app/Services フォルダを作成する(無ければ)
    → その中に HelloService.php を新規作成して貼り付ける
  4. app/Http/ControllersHelloController.php を新規作成して貼り付ける
  5. resources/viewshello.blade.php を新規作成して貼り付ける

7. 実行方法(Webページを表示するモード)

7-1. サーバー起動

php artisan serve

7-2. ブラウザでアクセス

http://127.0.0.1:8000/hello
表示結果
画面に Hello, world が表示されます。

8. よくあるエラー(初心者が詰まるポイント)

8-1. Class not found(クラスが見つからない)

ファイル名・namespace・フォルダ位置が一致していない可能性があります。
例:app/Services/HelloService.php の namespace は App\Services になっているか確認してください。

8-2. 画面が 404 になる

Route が正しく追加されているか確認してください。
routes/web.phpRoute::get('/hello', ...) があるか確認します。


9. まとめ

PHP の namespace とは?なぜ付けるのか(Laravel前提で理解する)

namespace(名前空間)は、PHPでクラス名がぶつからないようにするための仕組みです。
Laravelでは「フォルダ構成」と「namespace」を揃えるのが基本ルールになっています。

1. namespace の一言定義

namespace は「クラス名の住所(所属)」です。
同じクラス名でも 住所が違えば別のクラスとして扱えます。


2. 何が困る?(namespace が無い世界)

もし namespace が無い(全クラスが同じ場所に住んでいる)と、
同じクラス名を2つ作れません

<?php
// namespace が無いと仮定

class User { }        // ① User クラス
class User { }        // ② 同じ名前なのでエラー(再定義)
実務では「User」「Request」「Service」「Logger」など、ありがちな名前が大量に出ます。
namespace がないと、プロジェクトが大きくなるほど破綻します。

3. namespace があるとどうなる?(住所が違うので衝突しない)

クラス名が同じでも、namespace(住所)が違えば共存できます。

<?php
namespace App\Models;

class User { }  // App\Models\User

// 別ファイル
namespace App\Dtos;

class User { }  // App\Dtos\User(同名でもOK)

ここでのポイントは、PHP内部ではクラス名は実はこう扱われることです:

つまり「User」ではなく「住所付きの完全な名前」で区別しています。


4. Laravel で namespace を付ける意味(フォルダと一致させる)

Laravel(というか Composer のオートロード)では、
フォルダ構造と namespace が対応するように設定されています(PSR-4)。

ファイルの場所 namespace クラスの完全名
app/Http/Controllers/HelloController.php namespace App\Http\Controllers; App\Http\Controllers\HelloController
app/Services/HelloService.php namespace App\Services; App\Services\HelloService
app/Models/User.php namespace App\Models; App\Models\User
重要
namespace を付ける最大の実務メリットは「Laravel/Composerが自動でクラスを見つけて読み込める」ことです。
逆に言うと、namespace とフォルダがズレると Class not found が起きます。

5. use は何?(長い住所を短く書く)

namespace を使うと「住所付きの長い名前」になります。
そこで use を使って 別住所のクラスを読み込む(参照する)ようにします。

<?php
namespace App\Http\Controllers;

use App\Services\HelloService; // ← 別住所のHelloServiceを使う宣言

class HelloController extends Controller
{
    public function __construct(private HelloService $helloService) {}
}

use App\Services\HelloService; を書かずに、完全名で書くこともできます:

<?php
namespace App\Http\Controllers;

class HelloController extends Controller
{
    // use を書かない場合は「住所付き」を毎回書く必要がある
    public function __construct(private \App\Services\HelloService $helloService) {}
}
結論
use は「長い住所を省略するための仕組み」です。
namespace は「住所を付けて衝突を防ぐ仕組み」です。

6. よくあるミス(初心者が詰まるポイント)

6-1. ファイル場所と namespace がズレる

app/Services/HelloService.php
namespace App\Service;  ← Services ではなく Service になってる(間違い)

この場合、Laravelは App\Services\HelloService を探しても見つけられず、
Class "App\Services\HelloService" not found のようなエラーになります。

6-2. use の書き忘れ

<?php
namespace App\Http\Controllers;

// use App\Services\HelloService; ← これが無い

class HelloController extends Controller
{
    // HelloService がどこのクラスか分からずエラーになる
    public function __construct(private HelloService $helloService) {}
}

7. まとめ

なぜ $this->helloService->getMessage() と書くのか?

結論から言うと、$this
「この Controller オブジェクト自身」を指します。

$this->helloService
この Controller が持っている helloService」という意味です。

1. 前提:Controller は「クラス」から作られた「オブジェクト」

PHP(Laravel)では、Controller は次のような クラス です。

<?php
class HelloController
{
    private HelloService $helloService;

    public function __construct(HelloService $helloService)
    {
        $this->helloService = $helloService;
    }

    public function index()
    {
        $message = $this->helloService->getMessage();
    }
}

このクラスから、Laravel が 実体(オブジェクト)を作ります。

$controller = new HelloController($helloService);
PHPでは、クラスそのもの
クラスから作られた実体(オブジェクト)は別物です。

2. $this が無いと何が起きる?

仮に $this を書かずにこう書くとどうなるでしょうか。

// ❌ 間違い
$message = $helloService->getMessage();

PHPはこう考えます:

重要
クラスのプロパティ(メンバ変数)にアクセスするときは、
必ず $this-> が必要です。

3. $this は「今この処理をしている自分」

$this は実行中のメソッドが
どのオブジェクトの中で動いているか」を示します。

書き方 意味
$this->helloService この Controller 自身が持っている helloService
$this->helloService->getMessage() その Service の getMessage() を呼ぶ

4. なぜコンストラクタで代入しているのか?

public function __construct(HelloService $helloService)
{
    $this->helloService = $helloService;
}

この1行があることで、

もし代入しなければ、$helloService
コンストラクタの中だけで消える変数になります。


5. 図で理解する(イメージ)

HelloController(オブジェクト)
├─ $this
│   ├─ helloService  ──▶ HelloService(オブジェクト)
│   │                    └─ getMessage()
│   └─ index()

6. 静的メソッドとの違い(補足)

もし static メソッドであれば $this は使えません。

class Sample
{
    public static function test()
    {
        // $this は使えない(オブジェクトが無い)
    }
}

今回の Controller / Service は すべてインスタンス(実体)として動くため、
$this を使います。


7. まとめ

return view('hello', ['message' => $message]); の意味

この1行は Laravel で 「画面(Blade)を表示する」ための超重要構文です。
Controller → View(Blade)へデータを渡して表示する、という役割を担っています。

1. 全体を一言で言うと

return view('hello', [
    'message' => $message,
]);

意味を日本語にすると:

hello.blade.php という画面を表示し、
その画面に message という名前でデータを渡す」

2. view() は Laravel の関数ですか?

はい。view()Laravel が用意しているグローバルヘルパ関数です。

// Laravelのviewヘルパ
view(string $viewName, array $data = []): View
ポイント
自分で定義した関数ではありません。
Laravel が「画面を返すため」に用意してくれている関数です。

3. 'hello' は何を指している?

'hello'Blade ファイル名を指しています。

resources/
└─ views/
   └─ hello.blade.php

Laravel のルールでは:

指定 実際に使われるファイル
view('hello') resources/views/hello.blade.php
view('users.index') resources/views/users/index.blade.php
.blade.php は書かず、
フォルダはドット区切りで指定します。

4. 'message' は Blade 側の変数名ですか?

はい、その通りです。

'message' => $message

これは次の意味を持ちます:

「Controller 側の $message という値を、
Blade 側では $message という変数名で使えるようにする」

5. Blade 側ではどう使われる?

resources/views/hello.blade.php では、次のように書けます。

<!doctype html>
<html>
<body>

  <h1>{{ $message }}</h1>

</body>
</html>

この {{ $message }} は、


6. もし配列を渡したら?

return view('hello', [
    'message' => 'Hello',
    'userName' => 'Taro',
]);

Blade 側では:

{{ $message }}   // Hello
{{ $userName }}  // Taro
配列の キー が Blade の変数名になります。

7. なぜ return しているのか?

Controller の役割は HTTPレスポンスを返すことです。

ブラウザ
  ↑
HTTPレスポンス(HTML)
  ↑
Controller → return view(...)

8. まとめ(初心者向け超要点)

この仕組みが分かると、
「Controller は処理、Blade は表示」
という Laravel の基本設計が一気に理解できます。

Service が複数ある場合、__construct にはどう書くのか?

結論から言うと、
はい。必要な Service を __construct の引数に複数並べて書きます。
Laravel の DI(依存性注入)は、それを正しく解決してくれます。

1. 基本形(Service が1つの場合)

class HelloController extends Controller
{
    private HelloService $helloService;

    public function __construct(HelloService $helloService)
    {
        $this->helloService = $helloService;
    }
}

これは:


2. Service が複数ある場合の書き方

例えば、次のような Service が必要だとします。

この場合、__construct はこうなります。

class SampleController extends Controller
{
    private HelloService $helloService;
    private UserService $userService;
    private LogService $logService;

    public function __construct(
        HelloService $helloService,
        UserService $userService,
        LogService $logService
    ) {
        $this->helloService = $helloService;
        $this->userService  = $userService;
        $this->logService   = $logService;
    }
}
重要
Laravel は「引数の型(クラス名)」を見て、
どの Service を渡すか判断します。
引数の順番は関係ありません。

3. なぜ複数書いても動くのか?(DI の仕組み)

Laravel は Controller を作るとき、内部で次のようなことをしています。

1. HelloController を new したい
2. __construct の引数を見る
3. HelloService が必要だと分かる
4. HelloService をコンテナから取得(なければ生成)
5. 同様に UserService, LogService も解決
6. new HelloController(HelloService, UserService, LogService)
これを DIコンテナ(Service Container) と呼びます。

4. よくある疑問①:数が増えすぎたらどうする?

例えば __construct がこうなったら要注意です。

public function __construct(
    AService $a,
    BService $b,
    CService $c,
    DService $d,
    EService $e
) {}
これは設計の匂い(God Controller)です。
Controller がやりすぎている可能性があります。

対処法:


5. PHP8以降の省略記法(プロパティプロモーション)

PHP8 以降では、次のように短く書けます。

class SampleController extends Controller
{
    public function __construct(
        private HelloService $helloService,
        private UserService $userService
    ) {}
}

これは以下と 完全に同じ意味です。

private HelloService $helloService;
private UserService $userService;

public function __construct(
    HelloService $helloService,
    UserService $userService
) {
    $this->helloService = $helloService;
    $this->userService  = $userService;
}
業務コードでは「明示的な方」を好む現場も多いので、
チームのルールに従うのが正解です。

6. よくある誤解

誤解 正解
Serviceは1つしか書けない ❌ 何個でも書ける
引数の順番が重要 ❌ 型で解決される
new しないと使えない ❌ Laravel が自動生成

7. まとめ

Controller → Service の依存関係が見える形で書けるのが、
DI の最大のメリットです。

DI されるのは Controller だけですか?

結論から言うと、
いいえ。
DI(依存性注入)は Controller だから特別なのではありません。

Laravel の DI コンテナに解決される場所であれば、
何も extends していない普通のクラスでも DI されます。

1. なぜ Controller では DI が「当たり前」に見えるのか

Controller で DI が自然に使える理由は、
Laravel が Controller を必ず DI コンテナ経由で生成するからです。

ルーティング
  ↓
Laravel が Controller を生成
  ↓
DI コンテナを使って new する
  ↓
__construct の引数を自動解決

そのため、次のような書き方が普通に動きます。

class HelloController extends Controller
{
    public function __construct(HelloService $helloService)
    {
        // 自動で注入される
    }
}
重要
Controller が DI されるのは
「extends Controller だから」ではなく、
「Laravel が Controller を管理しているから」です。

2. 何も extends しない普通のクラスでも DI される?

はい、されます。
ただし 条件 があります。

条件
そのクラスが DI コンテナ経由で生成されること

3. Service クラスは extends していないが DI されている

典型例が Service クラスです。

class HelloService
{
    public function getMessage(): string
    {
        return 'Hello, world';
    }
}

このクラスは何も extends していませんが、

class HelloController extends Controller
{
    public function __construct(HelloService $helloService)
    {
        // ここに注入される
    }
}

問題なく DI されます。

理由
HelloService は
Controller を作るときに「必要だ」と判断され、
コンテナが自動生成しているからです。

4. DI されないケース(重要)

次のように new したクラスは DI されません。

// ❌ DIされない
$service = new HelloService();

これは PHP が直接インスタンスを作っているため、
Laravel の DI コンテナが関与できないからです。

覚えておくポイント
DI は「Laravel が new する」場合にのみ働く

5. 一般クラスを DI したいときの正しい流れ

例えば、業務用クラスを作った場合:

class PriceCalculator
{
    public function calc(int $price): int
    {
        return $price * 2;
    }
}

これを DI したい場合:

class BillingService
{
    public function __construct(
        PriceCalculator $calculator
    ) {
        // DIされる
    }
}

BillingService が DI コンテナ経由で生成される限り、
PriceCalculator も自動的に DI されます。


6. 「DIされるかどうか」の判断基準

ケース DIされる?
Controller ✅ される
Service ✅ される
Repository ✅ される
何も extends しないクラス ✅ 条件付きでされる
new で直接生成 ❌ されない

7. まとめ(初心者向け超重要)

DI の本質
「自分で new しない。
Laravel に作らせる。」

DTO や Entity も自動生成(DI)されるのか?

結論を先に言います。
DTO と Entity は「原則として DI されません」。

ただし、これは「できない」という意味ではなく、
「役割的に DI するものではない」という設計上の話です。

1. まず結論を表で整理

種類 DI される? 理由
Controller ✅ される Laravel が生成・管理する
Service ✅ される DI コンテナ経由で生成される
Repository ✅ される Service から依存される
Entity(Eloquent Model) ❌ 原則されない 「データ」を表す存在だから
DTO ❌ されない 値の入れ物だから

2. なぜ Service は DI されるのか?

class UserService
{
    public function doSomething() {}
}

class UserController extends Controller
{
    public function __construct(
        UserService $userService
    ) {
        // ← DIされる
    }
}

Service は:

そのため DI コンテナが管理する対象になります。

Service は「どう処理するか」を知っている存在

3. DTO はなぜ DI されないのか?

class UserDto
{
    public function __construct(
        public string $name,
        public string $email
    ) {}
}

DTO(Data Transfer Object)は:

DTO を DI すると、
「どの値を入れるの?」という問題が発生します。

そのため DTO は普通、こう使います。

// 自分で new する(正しい)
$dto = new UserDto(
    name: 'Taro',
    email: 'taro@example.com'
);

4. Entity(Model)はなぜ DI されないのか?

class User extends Model
{
    protected $table = 'users';
}

Entity(Eloquent Model)は:

// DBから取得される
$user = User::find(1);
Entity は「DI される存在」ではなく、
「DB から生まれる存在」です。

5. 質問のコードの誤りを修正

質問にあったコードを PHP/Laravel 的に直します。

❌ Java風・誤解がある例

public class Service {
    private Service $service; // ← これはおかしい
}
問題点:
  • Service が Service に依存している(循環)
  • DI の意味がない

✅ 正しい Service の依存関係

class OrderService
{
    public function __construct(
        UserRepository $userRepository,
        PriceCalculator $calculator
    ) {
        // これらは DI される
    }
}

Service は「別の Service / Repository」に依存します。


6. まとめ(超重要)

覚え方

ロジック → DI
データ → new / DB

Laravel は「どこを見て」DI が動作するか決めているのか?

Laravel は
「Controller だから」「Service だから」といった
クラスの種類では DI を判断していません。

DI が動作するかどうかは、非常に単純な条件だけで決まります。

1. DI が動作する条件(これだけ)

Laravel の DI(Service Container)が動作するのは、
次の条件を満たすクラスです。

DI が動作する条件
  • コンストラクタが存在しない
  • または、すべてのコンストラクタ引数にデフォルト値がある
  • または、すべてのコンストラクタ引数がさらに解決可能なクラスである

まとめると:

「引数なしで new できる形になっているか」
これだけを Laravel は見ています。

2. Laravel が実際にやっている処理(概要)

Laravel は、DI 対象のクラスを生成するときに、
内部で次の処理を行っています。

① コンテナに「このクラスを作って」と依頼される
② コンストラクタ (__construct) を調べる
③ 引数があれば、それぞれ解決できるか確認する
④ すべて解決できたら new する

この処理は 再帰的に行われます。


3. コードで見る DI が動作する例

class HelloService
{
    // コンストラクタなし
}

class HelloController
{
    public function __construct(
        HelloService $helloService
    ) {}
}

この場合:

HelloController を作る
↓
HelloService が必要と分かる
↓
HelloService は引数なしで new できる
↓
DI が成立する

4. クラスの種類は判断材料ではない

DI が動作するかどうかは、次の要素とは関係ありません。

Laravel は「クラスの役割」や「名前」を一切見ていません。

5. まとめ(事実のみ)

DI が動作するかの判断基準
「このクラスは、引数なしで new できるか?」

PHP / Laravel のログ出力:デバッグ用(コンソール)と本番用(ログファイル)の使い分け

C のテストで printf() を入れて動作確認するのと同じように、PHP / Laravel でもログを出して確認できます。
ただし Laravel では「画面に出す」よりも、基本は ログ(ファイルやコンソール)に出す運用が一般的です。

1. 大前提:Laravel のログは Log ファサードで出す

Laravel では、ログ出力は Illuminate\Support\Facades\Log を使います。
これは「ログの出力先(ファイル / 標準出力 / その他)」を設定で切り替えられる仕組みになっています。

<?php

use Illuminate\Support\Facades\Log;

Log::debug('debug message');   // 開発中の細かい情報(本番では出さないことが多い)
Log::info('info message');     // 通常運用の記録
Log::warning('warn message');  // 想定外だが継続できる状態
Log::error('error message');   // エラー(原因調査が必要)

2. デバッグ時:C の printf 相当は「ログ」または「画面 dump」

2-1. まずはログで出す(おすすめ)

C の printf() のように「ここ通った」「値が何か」を確認したい場合、
Laravel では Log::debug() で出すのが安全です。

<?php

use Illuminate\Support\Facades\Log;

public function calc(Request $request)
{
    $payload = $request->all();

    // printf 相当:入力をログに出す
    Log::debug('[Billing] input payload', [
        'payload' => $payload,
    ]);

    // 何か計算した結果
    $result = ['ok' => true];

    // 結果もログへ
    Log::debug('[Billing] result', [
        'result' => $result,
    ]);

    return response()->json($result);
}
ポイント
Log::debug() は「開発中に便利」ですが、
本番で大量に出すとログが膨れたり機密情報が出たりするので注意します。

2-2. 画面に出す(dd / dump)=開発中だけ

C の printf で画面に出す感覚に近いのが dd()(Dump and Die)です。
ただしこれは 処理を止めるので、開発中だけにします。

// 開発中だけ:値を表示して処理停止
dd($payload);

// 表示して止めない(Laravel の dump)
dump($payload);
注意
dd() は本番に残すとシステムが止まるので絶対に残しません。

3. 「デバッグ時のコンソールログ」と「本番のログファイル」

3-1. 開発時:コンソールに出したい(stdout)

開発時に「ターミナル(コンソール)で見たい」場合、Laravel のログ出力先を stderr/stdout にできます。
典型例は Docker や CI で、コンテナログに流したい場合です。

Laravel のログ出力先は .envLOG_CHANNEL で切り替えます。

# 開発:コンソールに流す例
LOG_CHANNEL=stderr
LOG_LEVEL=debug

※ 実際の利用可否は Laravel バージョンや config/logging.php の定義に依存します。
ただ、考え方は「ログは設定で出力先を変えられる」です。

3-2. 本番:ログファイルに残す(重要)

本番運用では「あとで原因調査できるように」ログをファイルに残します。
Laravel の標準では、storage/logs/laravel.log に出力されます。

# 本番:ファイルに残す(一般的な例)
LOG_CHANNEL=stack
LOG_LEVEL=info
本番ログでよく出すレベル
  • info:操作記録(誰が何をしたか等)
  • warning:想定外だが致命ではない
  • error:障害調査が必要
debug は通常、本番では出さないことが多いです(ログ肥大や機密情報の理由)。

4. 「使い分け」の実例(デバッグ用 / 本番用)

開発中は「値を見る」ために debug を多用しますが、
本番では「後から追える情報」に絞って info / warning / error を出します。

<?php

use Illuminate\Support\Facades\Log;

public function update(Request $request, int $id)
{
    // 開発中:入力値を見たい(本番では出さないことが多い)
    Log::debug('[UserUpdate] request received', [
        'id' => $id,
        'email' => $request->input('email'),
        'tel' => $request->input('tel'),
    ]);

    try {
        // 何らかの更新処理(例)
        // $this->service->update(...);

        // 本番:更新成功の記録(調査に使える)
        Log::info('[UserUpdate] updated', [
            'id' => $id,
        ]);

        return response()->json(['ok' => true]);

    } catch (\Throwable $e) {

        // 本番:例外は error で必ず残す
        Log::error('[UserUpdate] failed', [
            'id' => $id,
            'error' => $e->getMessage(),
        ]);

        return response()->json(['ok' => false], 500);
    }
}

5. まとめ(C の printf と比較して)

仕様説明:ユーザー一覧(name / email)表示(Eloquent + 20件ページネーション + 業務レベル例外処理)

1. 画面仕様

2. エラー処理仕様(業務プログラム並み)

3. 責務分離(サンプルとしての設計)


コード(Laravel / Eloquent)

以下は「業務プログラムのサンプル構成」を意識して、クラスを分割した例です。
コードは貼り付けやすいように、ファイル単位で <pre><code> にまとめています。

1) Eloquent Model:app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * Class User
 * Eloquent Model(DB行の表現)
 *
 * 役割:
 * - users テーブルの行を Eloquent として扱う
 * - SoftDeletes により deleted_at を論理削除として扱う
 */
class User extends Model
{
    use SoftDeletes;

    protected $table = 'users';

    // 本サンプルでは参照のみだが、業務では fillable/guarded を適切に設定する
    protected $fillable = [
        'email',
        'password_hash',
        'name',
        'role',
        'is_active',
    ];

    // パスワードハッシュは通常 hidden にする(API等で漏れないように)
// $hidden は「この Model を配列・JSON に変換したときに
// 含めないカラム」を指定するための設定
//
// 【重要】
// ・Blade に「送られない」わけではない
// ・Model のプロパティとしては普通に存在する
//
// つまり:
//   $user->password_hash
// は、PHPコード上では普通にアクセスできる。
//
// ただし、次のような「配列化・JSON化」の場面では除外される。

protected $hidden = [
    // password_hash を hidden に指定することで、
    //
    // 【除外されるケース】
//  1) $user->toArray()
//  2) $user->toJson()
//  3) return $user;           // Controller から JSON レスポンス
//  4) API(Resource) 経由の出力
//
// 【除外されないケース】
//  1) $user->password_hash          // PHPコード内での参照
//  2) Blade に Model をそのまま渡した場合
//     {{ $user->password_hash }}    // ← 書けば表示される(非推奨)
//
// 【業務的な意味】
// ・API でうっかりパスワードハッシュを返す事故を防ぐ
// ・デバッグ用に toArray() したときに漏れない
// ・「出力のデフォルト安全性」を高める
//
// 【結論】
// hidden = 「外に出すときに自動で隠す」ための仕組み
// Blade への送信そのものを禁止する機能ではない
//
    'password_hash',
];


protected $casts = [
    // is_active カラムを「boolean 型」として扱う指定
    //
    // 【意味】
    // DB上では is_active は TINYINT(1) や BOOLEAN として
    // 0 / 1 の数値で保存されていることが多いが、
    // Eloquent で取得したときに
    //   0  → false
    //   1  → true
    // と、自動的に PHP の bool 型に変換してくれる。
    //
    // 【これが無い場合】
    // $user->is_active の値は
    //   "0" や "1"(文字列)
    //   あるいは 0 / 1(整数)
    // として扱われ、if文などで意図しない挙動になることがある。
    //
    // 【これがある場合】
    // if ($user->is_active) {
    //     // 有効ユーザー
    // }
    // のように、業務ロジックを自然に書ける。
    //
    // 【業務プログラム的な意義】
    // ・型の揺れをなくす
    // ・DB表現(0/1)と業務ロジック(true/false)を分離する
    // ・バグを未然に防ぐ
    'is_active' => 'boolean',
];

}

2) Entity:app/Domain/User/UserEntity.php

<?php

namespace App\Domain\User;

/**
 * Class UserEntity
 * Entity(ドメイン表現)
 *
 * 役割:
 * - 業務で扱う「ユーザー」の最小単位を表現する
 * - Eloquent(Model)に依存しない形で、業務層に渡せる
 */
class UserEntity
{
    public function __construct(
        public readonly int $id,
        public readonly ?string $name,
        public readonly string $email
    ) {
        // ここにドメイン的な不変条件があればチェックを書く(例:email形式など)
    }
}

3) DTO:app/Http/Dto/User/UserRowDto.php

<?php

namespace App\Http\Dto\User;

/**
 * Class UserRowDto
 * DTO(画面表示用)
 *
 * 役割:
 * - View に渡す「テーブル1行ぶん」の表示データ
 * - 今回は name / email だけ表示するので、それに特化
 */
class UserRowDto
{
    public function __construct(
        public readonly string $name,
        public readonly string $email
    ) {}
}

4) 業務例外:app/Domain/Common/AppBusinessException.php

<?php

namespace App\Domain\Common;

use RuntimeException;

/**
 * Class AppBusinessException
 * 業務例外(アプリ内で統一して扱う例外)
 *
 * 役割:
 * - DB例外などの技術例外を、そのまま画面に出さない
 * - ユーザー向けメッセージを保持して Controller/View に渡す
 */
class AppBusinessException extends RuntimeException
{
    public function __construct(
        string $userMessage,
        ?\Throwable $previous = null
    ) {
        parent::__construct($userMessage, 0, $previous);
    }
}

5) Repository IF:app/Domain/User/UserRepository.php

<?php

namespace App\Domain\User;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;

/**
 * Interface UserRepository
 * Repository(永続化層の窓口)
 *
 * 役割:
 * - 「ユーザー一覧をページングして取得する」という要求を定義
 * - 実装は Eloquent / QueryBuilder / 外部API などに差し替え可能
 */
interface UserRepository
{
    /**
     * ユーザー一覧をページネーションで取得する
     *
     * @param int $perPage 1ページの件数(本仕様は 20)
     * @return LengthAwarePaginator ページング結果(items + 総件数など)
     */
    public function paginateActiveUsers(int $perPage): LengthAwarePaginator;
}

6) Repository 実装:app/Infrastructure/Repository/EloquentUserRepository.php

<?php

namespace App\Infrastructure\Repository;

use App\Domain\User\UserRepository;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

/**
 * Class EloquentUserRepository
 * Repository 実装(Eloquent)
 *
 * 役割:
 * - Eloquent を使って users テーブルへアクセスする
 * - 論理削除(deleted_at)と有効フラグ(is_active)を考慮する
 */
class EloquentUserRepository implements UserRepository
{
    /**
     * ユーザー一覧をページネーションで取得する
     */
    public function paginateActiveUsers(int $perPage): LengthAwarePaginator
    {
        // ここが唯一 Eloquent に依存する層
        // - SoftDeletes: deleted_at IS NULL を自動付与(withTrashed を使わない限り)
        // - is_active: true のみ表示(業務要件に合わせる)
        return User::query()
            ->select(['id', 'name', 'email'])
            ->where('is_active', true)
            ->orderBy('id', 'desc')
            ->paginate($perPage);
    }
}

7) Service:app/Domain/User/UserListService.php

<?php

namespace App\Domain\User;

use App\Domain\Common\AppBusinessException;
use App\Http\Dto\User\UserRowDto;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;

/**
 * Class UserListService
 * Service(業務処理)
 *
 * 役割:
 * - Repository から取得した結果を、画面表示向け DTO に変換する
 * - 例外を統一的に捕捉し、業務例外として投げ直す(業務レベルのエラー処理)
 */
class UserListService
{
    public function __construct(
        private readonly UserRepository $userRepository
    ) {}

    /**
     * ユーザー一覧(ページング)を DTO として返す
     *
     * @param int $perPage 1ページ件数(仕様は20)
     * @return LengthAwarePaginator DTO の paginator(items が DTO に置き換わる)
     */
    public function getUserRowsPaginated(int $perPage): LengthAwarePaginator
    {
        try {
            // 1) DBからページング取得(Eloquentの paginator)
            $paginator = $this->userRepository->paginateActiveUsers($perPage);

            // 2) 取得した items(Eloquent Model)を DTO に変換
            $dtoItems = $paginator->getCollection()->map(function ($row) {
                // null name の場合、業務では「(未設定)」などに丸めることがある
                $name = $row->name ?? '(未設定)';

                return new UserRowDto(
                    name: $name,
                    email: $row->email
                );
            });

            // 3) paginator の items を DTO コレクションに差し替え
            $paginator->setCollection($dtoItems);

            return $paginator;

        } catch (QueryException $e) {
            // DB系の例外(SQL文、接続、制約違反など)
            Log::error('[UserListService] DB error while fetching users', [
                'message' => $e->getMessage(),
                'sql' => $e->getSql(),
                'bindings' => $e->getBindings(),
            ]);

            // 画面に出すメッセージは「安全」なものにする
            throw new AppBusinessException(
                'ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。',
                $e
            );

        } catch (\Throwable $e) {
            // 予期しない例外も握りつぶさずログへ
            Log::error('[UserListService] Unexpected error', [
                'message' => $e->getMessage(),
            ]);

            throw new AppBusinessException(
                '処理中にエラーが発生しました。時間をおいて再度お試しください。',
                $e
            );
        }
    }
}

8) Controller:app/Http/Controllers/UserListController.php

<?php

namespace App\Http\Controllers;

use App\Domain\Common\AppBusinessException;
use App\Domain\User\UserListService;
use Illuminate\Http\Request;

/**
 * Class UserListController
 * Controller(HTTP入出力)
 *
 * 役割:
 * - 画面表示リクエストを受け取る
 * - Service を呼び出して View に渡す
 * - エラー時は View にエラーメッセージを渡し、モーダル表示させる
 */
class UserListController extends Controller
{
    public function __construct(
        private readonly UserListService $userListService
    ) {}

    /**
     * GET /users
     * ユーザー一覧画面
     */
    public function index(Request $request)
    {
        $errorMessage = null;
        $usersPaginator = null;

        try {
            // 仕様:20件ページネーション
            $usersPaginator = $this->userListService->getUserRowsPaginated(20);

        } catch (AppBusinessException $e) {
            // ここではユーザー向けメッセージだけを View に渡す
            $errorMessage = $e->getMessage();

            // 一覧が取れない場合でも、画面は表示し「空の一覧 + エラーモーダル」にする
            // LengthAwarePaginator を作ることもあるが、サンプルでは null で扱う
        }

        return view('users.index', [
            'usersPaginator' => $usersPaginator,
            'errorMessage' => $errorMessage,
        ]);
    }
}

9) DI設定:app/Providers/AppServiceProvider.php(Repository バインド)

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Domain\User\UserRepository;
use App\Infrastructure\Repository\EloquentUserRepository;

/**
 * Class AppServiceProvider
 * DI 設定(業務サンプル向け)
 *
 * 役割:
 * - Interface を実装に紐づけ、Controller/Service は IF に依存できるようにする
 */
class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        // UserRepository を EloquentUserRepository に解決させる
        $this->app->bind(UserRepository::class, EloquentUserRepository::class);
    }

    public function boot()
    {
        //
    }
}

10) ルーティング:routes/web.php

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserListController;

/**
 * routes/web.php
 * 役割:
 * - 画面URLとControllerメソッドを結びつける
 */
Route::get('/users', [UserListController::class, 'index']);

11) View(Blade):resources/views/users/index.blade.php

<!--
  resources/views/users/index.blade.php

  役割:
  - name / email のテーブル表示
  - 20件ページネーションリンク表示
  - エラー時はモーダル(ダイアログ)を表示
-->

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ユーザー一覧</title>
  <style>
    body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif; margin: 24px; }
    h1 { margin: 0 0 16px; }
    table { width: 100%; border-collapse: collapse; background: #fff; }
    th, td { border: 1px solid #ddd; padding: 10px 12px; text-align: left; }
    th { background: #f6f6f6; }
    .pager { margin-top: 14px; }
    .note { margin-top: 10px; color: #666; font-size: 13px; }

    /* ===== モーダル(簡易ダイアログ) ===== */
    .modal-backdrop {
      position: fixed; inset: 0;
      background: rgba(0,0,0,.45);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 9999;
      padding: 16px;
    }
    .modal {
      width: min(560px, 100%);
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 10px 40px rgba(0,0,0,.25);
      overflow: hidden;
    }
    .modal-header { padding: 14px 16px; background: #f6f6f6; display:flex; gap: 10px; align-items:center; justify-content: space-between; }
    .modal-title { font-weight: 700; }
    .modal-body { padding: 16px; line-height: 1.6; }
    .modal-footer { padding: 12px 16px; display:flex; justify-content:flex-end; gap: 10px; }
    .btn {
      border: 1px solid #333; background: #333; color: #fff;
      padding: 8px 12px; border-radius: 10px; cursor: pointer;
    }
    .btn.secondary { background: #fff; color: #333; }
  </style>
</head>

<body>
  <h1>ユーザー一覧</h1>

  <!-- テーブル:1行に name / email を表示 -->
  <table>
    <thead>
      <tr>
        <th style="width: 40%;">name</th>
        <th>email</th>
      </tr>
    </thead>

    <tbody>
      @if($usersPaginator && $usersPaginator->count() > 0)
        @foreach($usersPaginator as $row)
          <tr>
            <td>{{ $row->name }}</td>
            <td>{{ $row->email }}</td>
          </tr>
        @endforeach
      @else
        <tr>
          <td colspan="2">表示できるユーザーがありません。</td>
        </tr>
      @endif
    </tbody>
  </table>

  <div class="pager">
    <!-- ページネーション(Laravelの標準リンク) -->
    @if($usersPaginator)
      {{ $usersPaginator->links() }}
    @endif
  </div>

  <div class="note">
    ※ DBエラー時は画面を壊さず、ダイアログでエラー通知します。
  </div>

  <!-- ===== エラーダイアログ(モーダル) ===== -->
  <div id="errorModalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true">
    <div class="modal">
      <div class="modal-header">
        <div class="modal-title">エラー</div>
        <button type="button" class="btn secondary" id="closeXBtn">×</button>
      </div>
      <div class="modal-body">
        <!-- サーバから渡された安全なメッセージを表示 -->
        <div id="errorModalMessage"></div>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn" id="closeBtn">閉じる</button>
      </div>
    </div>
  </div>

  <script>
    /**
     * モーダルを開く(業務向け:画面破壊せず、確実に通知する)
     */
    function openErrorModal(message) {
      const backdrop = document.getElementById('errorModalBackdrop');
      const msg = document.getElementById('errorModalMessage');

      msg.textContent = message || 'エラーが発生しました。';
      backdrop.style.display = 'flex';
      backdrop.setAttribute('aria-hidden', 'false');
    }

    /**
     * モーダルを閉じる
     */
    function closeErrorModal() {
      const backdrop = document.getElementById('errorModalBackdrop');
      backdrop.style.display = 'none';
      backdrop.setAttribute('aria-hidden', 'true');
    }

    // 閉じるボタンイベント
    document.getElementById('closeBtn').addEventListener('click', closeErrorModal);
    document.getElementById('closeXBtn').addEventListener('click', closeErrorModal);

    // 背景クリックで閉じる(業務要件次第で無効化もあり)
    document.getElementById('errorModalBackdrop').addEventListener('click', function(e) {
      if (e.target === this) closeErrorModal();
    });

    // サーバ側でエラーがあれば、初期表示時にモーダルを開く
    @if(!empty($errorMessage))
      openErrorModal(@json($errorMessage));
    @endif
  </script>

</body>
</html>

仕様説明:ユーザー新規登録(DTOでBladeと受け渡し / Eloquent / 業務レベル例外処理 / i18n対応)

1. 画面仕様

2. バリデーション仕様

3. 登録処理仕様

4. 例外処理仕様(業務レベル)

5. 責務分離(業務サンプル構成)


コード(Laravel / Eloquent / DTO / i18n / モーダル)

0) 設定ファイル:config/ui.php(成功ダイアログの自動消滅)

<?php

/**
 * config/ui.php
 *
 * 役割:
 * - UI挙動(成功ダイアログの自動消滅など)を「環境設定(.env)」から読み込む
 * - 運用担当は .env を書き換えるだけで挙動を変えられる(ソース改修不要)
 *
 * 注意:
 * - config/ui.php 自体はソースに含まれるが、値は .env で差し替え可能
 * - 本番で即反映したい場合は config キャッシュ運用に注意(php artisan config:clear 等)
 */
return [
    'success_modal' => [
        // 成功ダイアログを自動で閉じるかどうか(.env で制御)
        // 例: UI_SUCCESS_MODAL_AUTO_CLOSE_ENABLED=true / false
        'auto_close_enabled' => (bool) env('UI_SUCCESS_MODAL_AUTO_CLOSE_ENABLED', true),

        // 自動で閉じるまでの秒数(.env で制御)
        // 例: UI_SUCCESS_MODAL_AUTO_CLOSE_SECONDS=3
        'auto_close_seconds' => (int) env('UI_SUCCESS_MODAL_AUTO_CLOSE_SECONDS', 3),
    ],
];

1) 多言語:lang/ja/user.php

<?php

/**
 * lang/ja/user.php
 *
 * 役割:
 * - ユーザー登録画面で使うメッセージを日本語で定義する
 */
return [
    'title' => 'ユーザー新規登録',
    'name' => 'ユーザー名',
    'email' => 'メールアドレス',
    'password' => 'パスワード',
    'register' => '登録',

    'success_title' => '成功',
    'success_message' => 'ユーザー登録が完了しました。',

    'error_title' => 'エラー',
    'system_error' => '処理中にエラーが発生しました。時間をおいて再度お試しください。',

    // バリデーションメッセージ(必要な分だけ明示)
    'validation' => [
        'name_required' => 'ユーザー名は必須です。',
        'name_format' => 'ユーザー名は英小文字と数字のみ使用できます。',
        'email_required' => 'メールアドレスは必須です。',
        'email_format' => 'メールアドレスの形式が正しくありません。',
        'email_unique' => 'すでに登録済みのメールアドレスです。',
        'password_required' => 'パスワードは必須です。',
        'password_min' => 'パスワードは8文字以上で入力してください。',
        'password_format' => 'パスワードは英数字と _ , - , @ , # , $ , % , & のみ使用できます。',
    ],
];

2) 多言語:lang/en/user.php

<?php

/**
 * lang/en/user.php
 *
 * 役割:
 * - User registration screen messages (English)
 */
return [
    'title' => 'User Registration',
    'name' => 'User Name',
    'email' => 'Email',
    'password' => 'Password',
    'register' => 'Register',

    'success_title' => 'Success',
    'success_message' => 'User registration completed.',

    'error_title' => 'Error',
    'system_error' => 'An error occurred. Please try again later.',

    'validation' => [
        'name_required' => 'User name is required.',
        'name_format' => 'User name must contain only lowercase letters and digits.',
        'email_required' => 'Email is required.',
        'email_format' => 'Email format is invalid.',
        'email_unique' => 'This email is already registered.',
        'password_required' => 'Password is required.',
        'password_min' => 'Password must be at least 8 characters.',
        'password_format' => 'Password may contain only letters, digits, and _ , - , @ , # , $ , % , &.',
    ],
];

3) Eloquent Model:app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * Class User
 * Eloquent Model(DB行の表現)
 *
 * 役割:
 * - users テーブルの行を Eloquent として扱う
 * - SoftDeletes により deleted_at を論理削除として扱う
 */
class User extends Model
{
    use SoftDeletes;

    protected $table = 'users';

    protected $fillable = [
        'email',
        'password_hash',
        'name',
        'role',
        'is_active',
    ];

    // パスワードハッシュは配列/JSON化するときに隠す(API事故防止)
    protected $hidden = [
        'password_hash',
    ];

    // is_active を 0/1 ではなく true/false(bool)として扱う
    protected $casts = [
        'is_active' => 'boolean',
    ];
}

4) DTO(入力):app/Http/Dto/User/UserRegisterRequestDto.php

<?php

namespace App\Http\Dto\User;

/**
 * Class UserRegisterRequestDto
 * DTO(入力:Blade -> Controller)
 *
 * 役割:
 * - リクエストから受け取った値を「型のあるデータ」としてまとめる
 * - Controller 内で request()->input(...) を散らさない(業務サンプル)
 */
class UserRegisterRequestDto
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password
    ) {}
}

5) DTO(表示):app/Http/Dto/User/UserRegisterViewDto.php

<?php

namespace App\Http\Dto\User;

/**
 * Class UserRegisterViewDto
 * DTO(表示:Controller -> Blade)
 *
 * 役割:
 * - 入力値の再表示(バリデーションエラー時)を安全に行う
 * - Blade 側で old() だけに依存しないサンプルにする
 */
class UserRegisterViewDto
{
    public function __construct(
        public readonly string $name,
        public readonly string $email
    ) {}
}

6) Entity:app/Domain/User/UserEntity.php

<?php

namespace App\Domain\User;

/**
 * Class UserEntity
 * Entity(ドメイン表現)
 *
 * 役割:
 * - 業務で扱うユーザーを Eloquent から分離して表現する
 */
class UserEntity
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
        public readonly bool $isActive,
        public readonly string $role
    ) {}
}

7) 業務例外:app/Domain/Common/AppBusinessException.php

<?php

namespace App\Domain\Common;

use RuntimeException;

/**
 * Class AppBusinessException
 * 業務例外(アプリで統一して扱う例外)
 *
 * 役割:
 * - 技術例外(DB例外等)を直接画面へ出さない
 * - ユーザー向けメッセージ(安全な文言)で例外を表現する
 */
class AppBusinessException extends RuntimeException
{
    public function __construct(
        string $userMessage,
        ?\Throwable $previous = null
    ) {
        parent::__construct($userMessage, 0, $previous);
    }
}

8) Repository IF:app/Domain/User/UserRepository.php

<?php

namespace App\Domain\User;

/**
 * Interface UserRepository
 * Repository(永続化層の窓口)
 *
 * 役割:
 * - Eloquent 等の実装詳細を隠し、業務層は IF に依存する
 */
interface UserRepository
{
    /**
     * email が既に存在するか(論理削除も含めて確認する)
     *
     * @param string $email
     * @return bool true:存在する / false:存在しない
     */
    public function existsByEmailIncludingTrashed(string $email): bool;

    /**
     * ユーザーを新規作成する
     *
     * @param string $name
     * @param string $email
     * @param string $passwordHash
     * @return UserEntity 作成されたユーザー
     */
    public function createUser(string $name, string $email, string $passwordHash): UserEntity;
}

9) Repository 実装:app/Infrastructure/Repository/EloquentUserRepository.php

<?php

namespace App\Infrastructure\Repository;

use App\Domain\User\UserEntity;
use App\Domain\User\UserRepository;
use App\Models\User;

/**
 * Class EloquentUserRepository
 * Repository 実装(Eloquent)
 *
 * 役割:
 * - users テーブルへのアクセスを Eloquent で実装する
 */
class EloquentUserRepository implements UserRepository
{
    /**
     * email が既に存在するか(論理削除も含めて確認)
     */
    public function existsByEmailIncludingTrashed(string $email): bool
    {
        // withTrashed() を付けることで deleted_at != null(論理削除)も検索対象に含める
        return User::withTrashed()
            ->where('email', $email)
            ->exists();
    }

    /**
     * ユーザーを新規作成する
     */
    public function createUser(string $name, string $email, string $passwordHash): UserEntity
    {
        // Eloquent による INSERT
        $user = User::create([
            'name' => $name,
            'email' => $email,
            'password_hash' => $passwordHash,
            'role' => 'user',       // デフォルト
            'is_active' => true,    // 仕様:正常登録時に active
        ]);

        return new UserEntity(
            id: (int)$user->id,
            name: (string)($user->name ?? ''),
            email: (string)$user->email,
            isActive: (bool)$user->is_active,
            role: (string)$user->role
        );
    }
}

10) Service:app/Domain/User/UserRegisterService.php

<?php

namespace App\Domain\User;

use App\Domain\Common\AppBusinessException;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;

/**
 * Class UserRegisterService
 * Service(業務処理:ユーザー新規登録)
 *
 * 役割:
 * - email 重複確認
 * - password ハッシュ化
 * - repository へ登録依頼
 * - 例外を業務例外へ変換(業務レベルのエラー処理)
 */
class UserRegisterService
{
    public function __construct(
        private readonly UserRepository $userRepository
    ) {}

    /**
     * ユーザー登録を実行する
     *
     * @param string $name
     * @param string $email
     * @param string $rawPassword 生パスワード(このメソッド内でハッシュ化し、以後は保持しない)
     * @return UserEntity
     */
    public function register(string $name, string $email, string $rawPassword): UserEntity
    {
        try {
            // 1) email 重複チェック(仕様:論理削除も含めて登録済み扱い)
            if ($this->userRepository->existsByEmailIncludingTrashed($email)) {
                // ここは「システム例外」ではなく「業務エラー」なので BusinessException を投げる
                // Controller 側で email フィールドエラーとして扱う
                throw new AppBusinessException(__('user.validation.email_unique'));
            }

            // 2) パスワードを必ずハッシュ化する(生パスワード保存禁止)
            $passwordHash = Hash::make($rawPassword);

            // 3) 登録(is_active=true)
            return $this->userRepository->createUser($name, $email, $passwordHash);

        } catch (AppBusinessException $e) {
            // 業務エラーはログを抑制することが多い(監視ノイズになるため)
            // ただし要件により info で残しても良い
            return throw $e;

        } catch (QueryException $e) {
            // DB例外(接続、SQL、制約など)をログへ詳細出力(業務レベル)
            Log::error('[UserRegisterService] DB error', [
                'message' => $e->getMessage(),
                'sql' => $e->getSql(),
                'bindings' => $e->getBindings(),
            ]);

            // 画面には安全な共通メッセージ
            throw new AppBusinessException(__('user.system_error'), $e);

        } catch (\Throwable $e) {
            // 予期しない例外もログに残す
            Log::error('[UserRegisterService] Unexpected error', [
                'message' => $e->getMessage(),
            ]);

            throw new AppBusinessException(__('user.system_error'), $e);
        }
    }
}

11) バリデーション:app/Http/Requests/UserRegisterFormRequest.php

<?php

namespace App\Http\Requests;

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

/**
 * Class UserRegisterFormRequest
 * FormRequest(バリデーション専用)
 *
 * 役割:
 * - 仕様で定義した入力チェックをここに集約する
 * - Controller のコードを薄く保つ(業務サンプル)
 */
class UserRegisterFormRequest extends FormRequest
{
    /**
     * 認可(本サンプルは誰でも登録できる前提のため true)
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * バリデーションルール
     */
    public function rules(): array
    {
        return [
            // name: 必須、英小文字+数字のみ
            'name' => [
                'required',
                'regex:/^[a-z0-9]+$/',
                'max:100',
            ],

            // email: 必須、形式
            // ※重複チェックは「Serviceで業務ルールとして実施」するためここでは行わない
            // (FormRequestで unique を使う実装も一般的だが、責務分離の教材として Service に寄せる)
            'email' => [
                'required',
                'email:rfc,dns',
                'max:255',
            ],

            // password: 必須、8文字以上、許可文字のみ
            'password' => [
                'required',
                'min:8',
                // 許可:英数字と _ , - , @ , # , $ , % , &
                // ハイフンは文字クラス内で位置に注意(末尾に置くと安全)
                'regex:/^[A-Za-z0-9_,@#\\$%&-]+$/',
            ],
        ];
    }

    /**
     * バリデーションメッセージ(i18n対応)
     *
     * ※ ここで __('user.validation.xxx') を使うことで
     *   日本語/英語を locale に応じて切り替えできる
     */
    public function messages(): array
    {
        return [
            'name.required' => __('user.validation.name_required'),
            'name.regex'    => __('user.validation.name_format'),

            'email.required' => __('user.validation.email_required'),
            'email.email'    => __('user.validation.email_format'),

            'password.required' => __('user.validation.password_required'),
            'password.min'      => __('user.validation.password_min'),
            'password.regex'    => __('user.validation.password_format'),
        ];
    }
}

12) Controller:app/Http/Controllers/UserRegisterController.php

<?php

namespace App\Http\Controllers;

use App\Domain\Common\AppBusinessException;
use App\Domain\User\UserRegisterService;
use App\Http\Dto\User\UserRegisterRequestDto;
use App\Http\Dto\User\UserRegisterViewDto;
use App\Http\Requests\UserRegisterFormRequest;

/**
 * Class UserRegisterController
 * Controller(ユーザー新規登録画面)
 *
 * 役割:
 * - GET: 画面表示(DTOでBladeへ渡す)
 * - POST: 入力受け取り(DTO化)-> Service呼び出し -> 成功/失敗をViewへ返す
 */
class UserRegisterController extends Controller
{
    public function __construct(
        private readonly UserRegisterService $userRegisterService
    ) {}

    /**
     * GET /users/register
     * 新規登録画面を表示する
     */
    public function show()
    {
        // 初期表示用DTO(空)
        $viewDto = new UserRegisterViewDto(name: '', email: '');

        return view('users.register', [
            'viewDto' => $viewDto,
            'successMessage' => null,
            'systemErrorMessage' => null,
            // UI設定(成功モーダルの自動消滅)
            'successModalAutoCloseEnabled' => (bool)config('ui.success_modal.auto_close_enabled', true),
            'successModalAutoCloseSeconds' => (int)config('ui.success_modal.auto_close_seconds', 3),
        ]);
    }

    /**
     * POST /users/register
     * 新規登録を実行する
     */
    public function register(UserRegisterFormRequest $request)
    {
        $successMessage = null;
        $systemErrorMessage = null;

        // 1) request -> DTO(入力DTO)
        $reqDto = new UserRegisterRequestDto(
            name: (string)$request->input('name'),
            email: (string)$request->input('email'),
            password: (string)$request->input('password')
        );

        try {
            // 2) サービス呼び出し(業務処理)
            $this->userRegisterService->register(
                $reqDto->name,
                $reqDto->email,
                $reqDto->password
            );

            // 3) 成功メッセージ(i18n)
            $successMessage = __('user.success_message');

            // 4) 登録成功後:フォームは空に戻す(業務要件次第)
            $viewDto = new UserRegisterViewDto(name: '', email: '');

            return view('users.register', [
                'viewDto' => $viewDto,
                'successMessage' => $successMessage,
                'systemErrorMessage' => null,
                'successModalAutoCloseEnabled' => (bool)config('ui.success_modal.auto_close_enabled', true),
                'successModalAutoCloseSeconds' => (int)config('ui.success_modal.auto_close_seconds', 3),
            ]);

        } catch (AppBusinessException $e) {
            // 業務例外の中身が「email重複」か「システムエラー」かを分ける
            // 本サンプルでは email重複の文言は __('user.validation.email_unique') で投げている想定
            $msg = $e->getMessage();

            // email重複の場合:email のフィールドエラーとして表示したい
            // FormRequestの errors とは別に、email に手動で追加して返す
            if ($msg === __('user.validation.email_unique')) {
                return back()
                    ->withInput()
                    ->withErrors(['email' => $msg]);
            }

            // それ以外(システム安全メッセージ等)はモーダルで表示する
            $systemErrorMessage = $msg;

            // 表示用DTO(入力保持)
            $viewDto = new UserRegisterViewDto(name: $reqDto->name, email: $reqDto->email);

            return view('users.register', [
                'viewDto' => $viewDto,
                'successMessage' => null,
                'systemErrorMessage' => $systemErrorMessage,
                'successModalAutoCloseEnabled' => (bool)config('ui.success_modal.auto_close_enabled', true),
                'successModalAutoCloseSeconds' => (int)config('ui.success_modal.auto_close_seconds', 3),
            ]);
        }
    }
}

13) DI設定:app/Providers/AppServiceProvider.php(Repository バインド)

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Domain\User\UserRepository;
use App\Infrastructure\Repository\EloquentUserRepository;

/**
 * Class AppServiceProvider
 * DI 設定
 *
 * 役割:
 * - Interface -> Implementation を紐づける
 */
class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(UserRepository::class, EloquentUserRepository::class);
    }

    public function boot()
    {
        //
    }
}

14) ルーティング:routes/web.php

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserRegisterController;

/**
 * routes/web.php
 * 役割:
 * - URL と Controller を紐づける
 */
Route::get('/users/register', [UserRegisterController::class, 'show']);
Route::post('/users/register', [UserRegisterController::class, 'register']);

15) View(Blade):resources/views/users/register.blade.php

<!--
  resources/views/users/register.blade.php

  役割:
  - 新規登録フォーム表示
  - バリデーションエラーを項目の近くに赤表示
  - 成功時にダイアログ表示(設定に応じて自動で閉じる)
  - システムエラー時もダイアログ表示
-->

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ __('user.title') }}</title>
  <style>
    body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif; margin: 24px; }
    h1 { margin: 0 0 16px; }
    form { max-width: 520px; }
    label { display:block; font-weight: 700; margin-top: 14px; }
    input {
      width: 100%;
      padding: 10px 12px;
      border: 1px solid #ccc;
      border-radius: 10px;
      margin-top: 6px;
      box-sizing: border-box;
    }
    .error {
      margin-top: 6px;
      color: #c00;
      font-size: 13px;
      font-weight: 700;
    }
    .btn {
      margin-top: 16px;
      border: 1px solid #333; background: #333; color: #fff;
      padding: 10px 14px; border-radius: 10px; cursor: pointer;
    }

    /* ===== モーダル(成功/エラー共通) ===== */
    .modal-backdrop {
      position: fixed; inset: 0;
      background: rgba(0,0,0,.45);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 9999;
      padding: 16px;
    }
    .modal {
      width: min(560px, 100%);
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 10px 40px rgba(0,0,0,.25);
      overflow: hidden;
    }
    .modal-header { padding: 14px 16px; background: #f6f6f6; display:flex; gap: 10px; align-items:center; justify-content: space-between; }
    .modal-title { font-weight: 800; }
    .modal-body { padding: 16px; line-height: 1.6; }
    .modal-footer { padding: 12px 16px; display:flex; justify-content:flex-end; gap: 10px; }
    .btn2 {
      border: 1px solid #333; background: #333; color: #fff;
      padding: 8px 12px; border-radius: 10px; cursor: pointer;
    }
    .btn2.secondary { background: #fff; color: #333; }

    .hint {
      margin-top: 10px;
      color: #666;
      font-size: 13px;
      line-height: 1.6;
    }
  </style>
</head>

<body>
  <h1>{{ __('user.title') }}</h1>

  <form method="POST" action="/users/register">
    @csrf

    <!-- name -->
    <label for="name">{{ __('user.name') }}</label>
    <input id="name" name="name" type="text" value="{{ old('name', $viewDto->name) }}" autocomplete="username">
    @error('name')
      <div class="error">{{ $message }}</div>
    @enderror

    <!-- email -->
    <label for="email">{{ __('user.email') }}</label>
    <input id="email" name="email" type="email" value="{{ old('email', $viewDto->email) }}" autocomplete="email">
    @error('email')
      <div class="error">{{ $message }}</div>
    @enderror

    <!-- password -->
    <label for="password">{{ __('user.password') }}</label>
    <input id="password" name="password" type="password" value="" autocomplete="new-password">
    @error('password')
      <div class="error">{{ $message }}</div>
    @enderror

    <button class="btn" type="submit">{{ __('user.register') }}</button>

    <div class="hint">
      <div>name: [a-z0-9]+</div>
      <div>password: 8+ chars, allowed: A-Z a-z 0-9 _ , - @ # $ % &</div>
      <div>※言語切替は Laravel の locale 設定で対応(config/app.php の locale 等)</div>
    </div>
  </form>

  <!-- ===== 成功/エラー ダイアログ ===== -->
  <div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true">
    <div class="modal">
      <div class="modal-header">
        <div class="modal-title" id="modalTitle"></div>
        <button type="button" class="btn2 secondary" id="closeXBtn">×</button>
      </div>
      <div class="modal-body">
        <div id="modalMessage"></div>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn2" id="closeBtn">OK</button>
      </div>
    </div>
  </div>

  <script>
    /**
     * モーダルを開く(成功/エラー共通)
     * - 業務画面では「画面を壊さず、確実に通知」したいのでモーダルを使う
     */
    function openModal(title, message) {
      const backdrop = document.getElementById('modalBackdrop');
      document.getElementById('modalTitle').textContent = title || '';
      document.getElementById('modalMessage').textContent = message || '';
      backdrop.style.display = 'flex';
      backdrop.setAttribute('aria-hidden', 'false');
    }

    /**
     * モーダルを閉じる
     */
    function closeModal() {
      const backdrop = document.getElementById('modalBackdrop');
      backdrop.style.display = 'none';
      backdrop.setAttribute('aria-hidden', 'true');
    }

    document.getElementById('closeBtn').addEventListener('click', closeModal);
    document.getElementById('closeXBtn').addEventListener('click', closeModal);

    // 背景クリックで閉じる(要件次第で無効化)
    document.getElementById('modalBackdrop').addEventListener('click', function(e) {
      if (e.target === this) closeModal();
    });

    // ===== 設定値(サーバから渡されたもの) =====
    const successAutoCloseEnabled = @json((bool)$successModalAutoCloseEnabled);
    const successAutoCloseSeconds = @json((int)$successModalAutoCloseSeconds);

    // ===== 成功メッセージがあればモーダル表示 =====
    @if(!empty($successMessage))
      openModal(@json(__('user.success_title')), @json($successMessage));

      // 設定でONなら数秒後に自動で閉じる
      if (successAutoCloseEnabled) {
        setTimeout(() => {
          closeModal();
        }, successAutoCloseSeconds * 1000);
      }
    @endif

    // ===== システムエラーがあればモーダル表示 =====
    @if(!empty($systemErrorMessage))
      openModal(@json(__('user.error_title')), @json($systemErrorMessage));
    @endif
  </script>

</body>
</html>

仕様説明:UserSetting(JSON)をログイン時に読み込み、全処理から参照できる設定クラスに保持する

1. 背景と前提

2. 重要な注意(PHP/Laravelの仕組み上の前提)

3. 要件

4. 参照方法(完成形)


コード(Laravel / Eloquent / Listener / Middleware / グローバルStore)

0) テーブル例(参考DDL)

<?php
// ※参考:migrationで作るのがLaravel標準だが、要件理解のためDDLも記載
//
// CREATE TABLE user_settings (
//   user_id BIGINT PRIMARY KEY,
//   settings JSON NOT NULL,
//   created_at TIMESTAMP NULL,
//   updated_at TIMESTAMP NULL
// );

1) Eloquent Model:app/Models/UserSetting.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Class UserSetting
 * Eloquent Model(user_settings テーブル)
 *
 * 役割:
 * - user_id と settings(JSON) を扱う
 * - settings を PHP の配列として扱えるよう casts を設定する
 */
class UserSetting extends Model
{
    protected $table = 'user_settings';

    // user_id を主キーとして扱う(1ユーザー1レコード前提)
    protected $primaryKey = 'user_id';
    public $incrementing = false;

    protected $fillable = [
        'user_id',
        'settings',
    ];

    protected $casts = [
        // settings(JSON) を PHP配列に自動変換する
        // DB: {"paging":{"per_page":20}} → PHP: ['paging' => ['per_page' => 20]]
        'settings' => 'array',
    ];
}

2) Repository IF:app/Domain/Setting/UserSettingRepository.php

<?php

namespace App\Domain\Setting;

/**
 * Interface UserSettingRepository
 * Repository(永続化層の窓口)
 *
 * 役割:
 * - Eloquent等の実装を隠蔽し、「ユーザーの設定(JSON)を取得する」操作を定義する
 */
interface UserSettingRepository
{
    /**
     * 指定ユーザーの settings(配列)を取得する
     *
     * @param int $userId
     * @return array|null settings配列。レコードが無ければ null
     */
    public function findSettingsByUserId(int $userId): ?array;
}

3) Repository 実装:app/Infrastructure/Repository/EloquentUserSettingRepository.php

<?php

namespace App\Infrastructure\Repository;

use App\Domain\Setting\UserSettingRepository;
use App\Models\UserSetting;

/**
 * Class EloquentUserSettingRepository
 * Repository 実装(Eloquent)
 *
 * 役割:
 * - user_settings から settings(JSON) を取り出し、配列として返す
 */
class EloquentUserSettingRepository implements UserSettingRepository
{
    /**
     * 指定ユーザーの settings を取得する
     */
    public function findSettingsByUserId(int $userId): ?array
    {
        $row = UserSetting::query()
            ->where('user_id', $userId)
            ->first();

        if (!$row) {
            return null;
        }

        // casts により settings は配列になっている(nullの場合もあるため保険)
        return $row->settings ?? [];
    }
}

4) 業務例外:app/Domain/Common/AppBusinessException.php

<?php

namespace App\Domain\Common;

use RuntimeException;

/**
 * Class AppBusinessException
 * 業務例外(統一例外)
 *
 * 役割:
 * - 技術例外の詳細を外部に出さず、安全なメッセージに変換する
 */
class AppBusinessException extends RuntimeException
{
    public function __construct(string $userMessage, ?\Throwable $previous = null)
    {
        parent::__construct($userMessage, 0, $previous);
    }
}

5) グローバル設定クラス:app/Support/UserSettingsStore.php

<?php

namespace App\Support;

/**
 * Class UserSettingsStore
 * グローバル設定ストア(全処理から参照される最終形)
 *
 * 役割:
 * - 「現在ログイン中ユーザー」の設定を保持する(1リクエスト内)
 * - get('a.b.c') のようにドット記法で値を取得できるようにする
 *
 * 注意:
 * - PHPはリクエストごとにメモリがリセットされるため、
 *   永続化は session/cache が担当し、ここは「毎リクエスト復元される入れ物」。
 */
class UserSettingsStore
{
    /**
     * @var array 現在ユーザーの設定データ(配列)
     */
    private array $data = [];

    /**
     * 設定を丸ごと差し替える
     *
     * @param array $settings
     */
    public function replaceAll(array $settings): void
    {
        // ここで deep merge したい場合は実装可能だが、サンプルでは単純置換
        $this->data = $settings;
    }

    /**
     * 設定を配列として丸ごと取得(デバッグ等)
     */
    public function all(): array
    {
        return $this->data;
    }

    /**
     * ドット記法で設定値を取得する
     *
     * @param string $key 例:paging.per_page
     * @param mixed $default 無い場合のデフォルト
     * @return mixed
     */
    public function get(string $key, mixed $default = null): mixed
    {
        // "paging.per_page" → ["paging","per_page"] に分割
        $parts = explode('.', $key);

        $current = $this->data;
        foreach ($parts as $p) {
            if (!is_array($current) || !array_key_exists($p, $current)) {
                return $default;
            }
            $current = $current[$p];
        }
        return $current;
    }

    /**
     * intとして取りたい場合のヘルパ
     *
     * @param string $key
     * @param int $default
     */
    public function getInt(string $key, int $default): int
    {
        $v = $this->get($key, $default);

        // 数字文字列なども考慮して int 化(業務では型揺れ防止が重要)
        if (is_numeric($v)) {
            return (int)$v;
        }
        return $default;
    }

    /**
     * boolとして取りたい場合のヘルパ
     *
     * @param string $key
     * @param bool $default
     */
    public function getBool(string $key, bool $default): bool
    {
        $v = $this->get($key, $default);
        return (bool)$v;
    }
}

6) Service:app/Domain/Setting/UserSettingsLoadService.php

<?php

namespace App\Domain\Setting;

use App\Domain\Common\AppBusinessException;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;

/**
 * Class UserSettingsLoadService
 * Service(設定ロード)
 *
 * 役割:
 * - DBから settings を取得する
 * - 無い場合はデフォルトを補う
 * - 例外を業務例外に変換する
 */
class UserSettingsLoadService
{
    public function __construct(
        private readonly UserSettingRepository $repo
    ) {}

    /**
     * 指定ユーザーの設定をロードする(配列で返す)
     *
     * @param int $userId
     * @return array
     */
    public function loadForUser(int $userId): array
    {
        try {
            $settings = $this->repo->findSettingsByUserId($userId);

            // レコードが無い場合は空配列扱い(以降でデフォルトを補う)
            $settings = $settings ?? [];

            // デフォルト設定を補完(業務で必須の“安全な既定値”)
            return $this->applyDefaults($settings);

        } catch (QueryException $e) {
            // DB例外はログに詳細を残す(業務レベル)
            Log::error('[UserSettingsLoadService] DB error', [
                'message' => $e->getMessage(),
                'sql' => $e->getSql(),
                'bindings' => $e->getBindings(),
            ]);

            // 画面に詳細は出さない(安全な文言に変換)
            throw new AppBusinessException('ユーザー設定の読み込みに失敗しました。', $e);

        } catch (\Throwable $e) {
            Log::error('[UserSettingsLoadService] Unexpected error', [
                'message' => $e->getMessage(),
            ]);

            throw new AppBusinessException('ユーザー設定の読み込みに失敗しました。', $e);
        }
    }

    /**
     * デフォルト設定を補完する
     *
     * @param array $settings
     * @return array
     */
    private function applyDefaults(array $settings): array
    {
        // 例:ページング行数のデフォルト
        // settings: { "paging": { "per_page": 20 } }
        if (!isset($settings['paging']) || !is_array($settings['paging'])) {
            $settings['paging'] = [];
        }
        if (!isset($settings['paging']['per_page'])) {
            $settings['paging']['per_page'] = 20;
        }

        // 必要な設定が増えたらここに追加していく
        return $settings;
    }
}

7) ログイン時にDB→session→Storeへ保存:app/Listeners/LoadUserSettingsOnLogin.php

<?php

namespace App\Listeners;

use App\Domain\Setting\UserSettingsLoadService;
use App\Support\UserSettingsStore;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Session;

/**
 * Class LoadUserSettingsOnLogin
 * Listener(ログインイベント)
 *
 * 役割:
 * - ログイン成功時に UserSetting をDBから読み込む
 * - その結果を session に保存(次リクエスト以降にも維持)
 * - 同一リクエスト内ですぐ使えるよう Store にも反映
 */
class LoadUserSettingsOnLogin
{
    public function __construct(
        private readonly UserSettingsLoadService $loadService,
        private readonly UserSettingsStore $store
    ) {}

    /**
     * ログインイベントハンドラ
     *
     * @param Login $event
     */
    public function handle(Login $event): void
    {
        // ログインしたユーザーID
        $userId = (int)$event->user->id;

        // 1) DBから設定ロード
        $settings = $this->loadService->loadForUser($userId);

        // 2) sessionに保存(これが“次リクエスト以降も保持”の本体)
        Session::put('user_settings', $settings);

        // 3) 同一リクエスト内で即参照できるよう Store にも反映
        $this->store->replaceAll($settings);
    }
}

8) 各リクエスト開始時に session→Store 復元:app/Http/Middleware/HydrateUserSettings.php

<?php

namespace App\Http\Middleware;

use App\Domain\Setting\UserSettingsLoadService;
use App\Support\UserSettingsStore;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;

/**
 * Class HydrateUserSettings
 * Middleware(設定の復元)
 *
 * 役割:
 * - リクエストごとに session から UserSettingsStore を復元する
 * - sessionに無ければDBから読み直して session と Store を補完する
 *
 * 注意:
 * - PHPは毎リクエストでメモリがクリアされるので、これが必要
 */
class HydrateUserSettings
{
    public function __construct(
        private readonly UserSettingsStore $store,
        private readonly UserSettingsLoadService $loadService
    ) {}

    /**
     * ミドルウェア本体
     */
    public function handle(Request $request, Closure $next)
    {
        // ログインしていない場合は空設定で進める
        if (!Auth::check()) {
            $this->store->replaceAll([]);
            return $next($request);
        }

        // 1) sessionから設定を取得
        $settings = Session::get('user_settings');

        // 2) sessionに無い場合(例:session消失、初回ログイン直後以外のケース)
        if (!is_array($settings)) {
            $userId = (int)Auth::id();

            // DBから再ロードして埋める(業務ではキャッシュ復旧戦略の一種)
            $settings = $this->loadService->loadForUser($userId);

            // sessionにも再保存(次回以降はDBを読まない)
            Session::put('user_settings', $settings);
        }

        // 3) Storeへ反映(これで全処理から参照可能になる)
        $this->store->replaceAll($settings);

        return $next($request);
    }
}

9) イベント登録:app/Providers/EventServiceProvider.php

<?php

namespace App\Providers;

use App\Listeners\LoadUserSettingsOnLogin;
use Illuminate\Auth\Events\Login;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

/**
 * Class EventServiceProvider
 * イベント登録
 *
 * 役割:
 * - ログイン成功イベント(Login)に対して、設定ロードListenerを紐づける
 */
class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        Login::class => [
            LoadUserSettingsOnLogin::class,
        ],
    ];
}

10) DI設定:app/Providers/AppServiceProvider.php(Store/Repositoryのバインド)

<?php

namespace App\Providers;

use App\Domain\Setting\UserSettingRepository;
use App\Infrastructure\Repository\EloquentUserSettingRepository;
use App\Support\UserSettingsStore;
use Illuminate\Support\ServiceProvider;

/**
 * Class AppServiceProvider
 * DI設定
 *
 * 役割:
 * - Storeを“全処理から同じインスタンスで参照”できるように singleton として登録
 * - RepositoryのIF→実装をバインド
 */
class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        // UserSettingsStore は1リクエスト内で同じ入れ物を共有したいので singleton
        $this->app->singleton(UserSettingsStore::class, function () {
            return new UserSettingsStore();
        });

        // Repository IF → Eloquent 実装
        $this->app->bind(UserSettingRepository::class, EloquentUserSettingRepository::class);
    }

    public function boot()
    {
        //
    }
}

11) Middleware登録:app/Http/Kernel.php(webグループへ追加)

<?php
// app/Http/Kernel.php の一部(例)

/**
 * webミドルウェアグループに追加する。
 * - sessionが使える順序の後に入れるのが重要(StartSessionの後)
 */
protected $middlewareGroups = [
    'web' => [
        // ... 既存(EncryptCookies, AddQueuedCookiesToResponse, StartSession など)

        // ここで設定を復元(ログイン済みなら session → Store)
        \App\Http\Middleware\HydrateUserSettings::class,
    ],
];

12) 使い方例(どの層からでも参照できる)

<?php

use App\Support\UserSettingsStore;

/**
 * 例:Controller/Serviceなど任意の場所でページング件数を参照
 */
$perPage = app(UserSettingsStore::class)->getInt('paging.per_page', 20);

// Eloquentのpaginateに使う例
// User::query()->paginate($perPage);
<!-- Blade で参照する例 -->
<div>
  perPage: {{ app(\App\Support\UserSettingsStore::class)->getInt('paging.per_page', 20) }}
</div>
💡 php artisan serve が失敗する場合の解決手順

1. php.ini の場所を特定する

ターミナルで以下のコマンドを実行し、現在使用されている設定ファイルのパスを確認します。

php --ini

Loaded Configuration File: の項目に表示されるパスをメモします。


2. variables_order を修正する

メモした php.ini をテキストエディタで開き、以下の行を探します。

; 修正前("E" が抜けている、または別の値になっている場合があります)
variables_order = "EGPCS"

これを以下のように修正して保存します:

variables_order = "GPCS"

※設定反映のため、保存後にサーバー(Herd等)やPCの再起動が必要な場合があります。