0. 全体像(最初に地図を持つ)
さらに、フロント側(CSS/JSのビルド)を扱うなら ③Node.js(または Bun) も使います(Laravel公式でも推奨)。
おすすめの選び方(Windows)
| 選択肢 | こんな人向け | 特徴 |
|---|---|---|
| A. Laravel Herd(最短) | 「まず動かしたい」「Dockerは後で」 | Windowsに PHP + nginx 等がまとまって入る。最短で開始できる。 |
| B. Laravel Sail(Docker) | 「チームと同じ環境」「本番に近い」 | Dockerで環境が揃う。Windowsは WSL2 前提が基本。 |
必要になったら B(Sail) に移行できるように説明します。
1. 必要なもの(用語と最低限)
| 名前 | 役割 | なぜ必要? |
|---|---|---|
| PHP | Laravel を実行する言語ランタイム | Laravel は PHP で動くため |
| Composer | PHPの依存ライブラリ管理 | Laravel 本体や周辺ライブラリを入れるため |
| Laravel Installer / composer create-project | Laravel プロジェクト生成 | 雛形を作って、すぐ開発開始するため |
| VS Code | エディタ | 補完・デバッグが強い |
| Xdebug | PHPのステップ実行(デバッガ) | ブレークポイント停止・変数確認をするため |
2. 環境構築(Windows:おすすめ順)
2-A. Laravel Herd(いちばん簡単)
- Herd をインストール(公式サイトから)
- インストール後、PowerShell で確認:
(Herd により PHP が入っていればphp -v composer -Vphpが動きます。)
php が見つからない:PATH が通っていない / 端末を再起動していない、など。まずは PC 再起動 or VS Code を再起動してから再チェック。
2-B. Laravel Sail(Docker / WSL2)
- WSL2(Ubuntuなど)を入れる(Windowsの標準機能)
- Docker Desktop を入れ、WSL2 統合を有効にする
- 以降の作業は基本的に WSL2 側のターミナル で行う(パスや権限でハマりにくい)
ただしチーム開発・本番寄せなら Sail が強いです。
3. 新規プロジェクトを作る(2通り)
① Laravel Installer(laravel new) / ② Composer(create-project)。
3-1. まず作業フォルダを作る(VS Codeで開く)
- 例:
C:\work\laravel-sampleを作成 - VS Code → File → Open Folder... で開く
- VS Code → Terminal → New Terminal
3-2. Composer で作る(初心者はこれでOK)
# フォルダ直下で(例:C:\work)
composer create-project laravel/laravel my-app
「テンプレ展開+依存インストール」までがワンコマンドだと思ってOKです。
3-3. Laravel Installer で作る(慣れたら)
Laravel 公式は「PHP / Composer / Laravel installer」の用意を前提に案内しています。
# Laravel installer を入れる(例)
composer global require laravel/installer
# 新規作成
laravel new my-app
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. フロントのビルド(必要になったら)
# 依存導入 → 開発ビルド
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. ワークスペース推奨(初心者向け)
app/, routes/, config/ などフォルダが多いです。ファイル単体ではなく、必ず プロジェクトフォルダを開くのが基本です。
6. デバッグ(Xdebug + VS Code)
Xdebug 3 では VS Code の既定ポートが 9003 でよく使われます。
6-1. VS Code 側:launch.json を作る
- 左の「実行とデバッグ」 → 「launch.json を作成」
- テンプレから 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}"
}
}
]
}
Docker(Sail)だと
/var/www/html のようなコンテナパスになるので、ここが重要になります。
6-2. PHP 側:Xdebug を有効化する(Herd の場合)
概念として必要になる設定(例: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)の場合:典型の考え方
- VS Code は Listen for Xdebug を開始
- コンテナ側の PHP に Xdebug を入れて debug を ON
- pathMappings を「コンテナ内パス ↔ ワークスペース」に合わせる
routes/web.phpに一時的なルートを追加- その行にブレークポイント
- ブラウザでアクセスして止まるか確認
7. リリース(本番配置)— まず「何をやるか」を固定する
php artisan config:cache)。
7-1. “配布物” とは何か(Laravelの場合)
- ソース一式(通常は Git から取得)
vendor/(Composer 依存で生成される:本番ではcomposer installで作る).env(本番設定:機密なので Git に入れない)
7-2. 本番の基本手順(超定番チェックリスト)
- 本番の .env を用意(最低限)
APP_ENV=production APP_DEBUG=false APP_KEY=...(本番用) - 依存インストール(本番向け)
composer install --no-dev --optimize-autoloader--no-dev:開発用依存を入れない(本番を軽く)
--optimize-autoloader:オートロード最適化(本番向け) - アプリ最適化(代表)
php artisan config:cache php artisan route:cache php artisan view:cache php artisan event:cache公式の Deployment でconfig:cacheなどの実行が推奨されています。 - DB を使うなら migrate(本番は force)
php artisan migrate --force - ストレージリンク(必要な場合)
php artisan storage:link
config:cache を使うと、以後は .env を直接読まない(キャッシュに固まる)ため、
設定変更時は「キャッシュ作り直し」が必要です。
7-3. 公開(ホスティング)の代表パターン
| 方式 | 向いている | 要点 |
|---|---|---|
| レンタルサーバー(共有) | 小規模・学習 | public/ をドキュメントルートにする、PHPバージョン要確認 |
| VPS(nginx/Apache) | 実務に近い | 権限、Queue、Scheduler(cron)、ログ運用が必要 |
| マネージド(Forge等) | 運用を楽に | デプロイ自動化・SSL・監視を寄せられる(別途学習) |
8. よくある詰まり(ここだけ見れば復帰できる)
8-1. composer が見つからない
- Composer が未インストール
- PATH が通っていない(再起動で直ることが多い)
8-2. php artisan serve は動くが、VS Code デバッグで止まらない
- Xdebug が有効な php.ini が違う(CLI と Web で別のことがある)
- VS Code が 9003 を待ち受けていない(Listen を押していない)
- Docker(Sail)の場合:pathMappings が合っていない
これで「どこまでできてるか」が切り分けできます。
8-3. 本番で 500 エラー(画面真っ白)
APP_DEBUG=falseだと詳細が出ない → まずは ログ(storage/logs)を見る- 権限(
storage/,bootstrap/cache) - キャッシュの作り直し(設定を変えたのに反映されない)
1) 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)
declare(strict_types=1) や型宣言でかなり安全にできます。
2-1. 代表的な型
| 型 | 例 | 説明 |
|---|---|---|
int | 123 | 整数 |
float | 3.14 | 小数 |
string | "abc" | 文字列 |
bool | true | 真偽値 |
array | [1,2,3] | 配列(連番/連想、両方を含む) |
object | new User() | オブジェクト |
null | null | 値がない |
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
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の演算子まとめ(算術・論理・ビット・代入・三項など)
ここでは 「業務コードを書くときによく使うもの」 を中心に、 種類ごとに整理して説明します。
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';
$inputName が null の場合だけ右側が使われます。
8. インクリメント / デクリメント
$i++;
$i--;
++$i;
--$i;
9. まとめ
- 算術演算子:数値計算
- 比較・論理演算子:条件分岐
- 代入演算子:値更新
- 文字列演算子:PHP 特有(
.) - 三項 / null 合体:条件を簡潔に書ける
PHPで正規表現(Regex)を扱う方法(実務向け)
主に
preg_* 系関数を使います。
1. PHPの正規表現の基本(delimiter が必須)
PHPでは、正規表現パターンは 必ず区切り文字(delimiter)で囲みます。
よく使う delimiter は / です。
$pattern = '/^abc/'; // "abc"で始まる
$pattern = '/abc$/'; // "abc"で終わる
$pattern = '/a.c/'; // a + 任意1文字 + c
/ を使いたい場合は \/ とエスケープが必要です。例: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);
}
}
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 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) {
// 支払済の処理
}
7) array / list / set / map(PHPでの考え方)と主なメソッド
しかし 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(配列より便利な “メソッド集合”)
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 / 関数 | メソッドチェーン |
| 可読性 | 処理の意図が見えにくい | 「何をしたいか」が見える |
| 副作用 | 破壊的操作が多い | 基本は非破壊(元を変えない) |
業務ロジックを安全に書くためのラッパーです。
2) map:各要素を変換する(for の代わり)
// 元データ(例:金額一覧)
$prices = collect([100, 200, 300]);
// 税込み価格(10%)に変換
$withTax = $prices->map(function ($price) {
// 各要素に対して処理される
return (int) round($price * 1.1);
});
// 結果:Collection [110, 220, 330]
「同じ個数のまま、中身だけを別の値に変換する」
短縮記法(アロー関数)
$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]
// ※ 元のキーは保持される
連番に戻したい場合は
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
「配列全体を 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']
「同じ値は 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:重複排除(集合)
「業務として何をしたいか」を先に考えると Collection は一気に理解しやすくなります。
クラス設計の基本:可視性(public / protected / private)とファイル単位、命名規約
文法の正しさよりも、業務コードとして長く保守できるかを重視した考え方です。
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〜業務ロジック
特に ルーティング → コントローラ → リポジトリ → サービス(業務ロジック) → ビュー(Blade) の流れが理解できるようにしています。
1) 構造体(DTO)の書き方・説明・注意点
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
) {}
}
- 配列(
['orderId' => ...])のままだと、キーのスペルミスや型崩れが起きやすい - DTO にすると 型 と 項目名 が固定され、業務ロジックが読みやすい
- 「入力」「内部処理」「出力」の境界がはっきりする
1-2. 注意点(DTOは “データだけ” に寄せる)
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
税率、上限回数、固定メッセージ、業務ルールの固定値など「変わらないもの」。
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
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
fn() => ...)は外側の変数を自動でキャプチャします。そのため短い処理に向きます。
4) tuple があるか?(PHPの実態)
ただし、配列で近い表現ができます(戻り値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アクセス層)
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;
}
}
ただし学習段階では、まず InMemory で流れを掴むと理解が速いです。
8) ビジネスロジック(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) ビュー関数(あれば)
主に
view(...) ヘルパー(関数)で Blade テンプレートを返します。
// Controller や Route のクロージャから使える
return view('orders.index', ['orders' => $orders]);
10) HTMLテンプレート(Blade)の書き方と、処理する関数の書き方
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)
単なるHTMLに見えますが、
・変数表示 ・条件分岐 ・ループ ・レイアウト継承 ・フォーム部品
を安全かつ読みやすく書くための仕組みが用意されています。
1) Bladeの基本思想(なぜBladeを使うのか)
- HTMLの見た目を壊さずに PHP の処理を書ける
- XSS対策(HTMLエスケープ)が自動
- Controller → View の役割分離が明確
- 「表示ロジック」だけを書く場所
Blade は「表示するだけ」に徹するのが鉄則です。
2) 変数の表示(超重要)
{{ $name }}
{{ }}:HTMLエスケープあり(基本これ){!! !!}:エスケープなし(信頼できるHTMLのみ)
// 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
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)
基本は 普通のHTML を書きます。
5-1. text input
<input
type="text"
name="customer_name"
value="{{ old('customer_name', $customerName ?? '') }}"
>
name:送信時のキーold():バリデーションエラー時の再表示
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>
条件式で制御します。
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,
]);
}
- 入力(Request)を受け取る
- Service を呼ぶ
- Blade に渡すデータを整える
9) よくある失敗と回避策
- ❌ Blade に if / 計算を書きすぎる
- ❌ 配列のキーを直接使いすぎる
- ❌ HTMLとPHPがぐちゃぐちゃになる
業務判断は Service、
表示判断だけ Blade に置くと、コードは長く生きます。
Blade を処理する Controller の書き方(関数定義から丁寧に)
「Controllerとは何か」→「関数はどう定義するか」→「Bladeにどう値を渡すか」
を 1行ずつコメント付き で説明します。
1) Controller とは何をするクラスか
Controller は HTTPリクエストの入口です。
- URL にアクセスされたら呼ばれる
- 入力を受け取る(必要なら)
- 業務ロジック(Service)を呼ぶ
- Blade テンプレートを返す
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) 初心者がつまずきやすいポイント
- ❌ Controller の関数を
privateにしてしまう - ❌ Blade で存在しない変数を使う
- ❌ Blade に計算処理を書き始める
Controller は「橋渡し」。
業務処理は Service。
この役割分担を守ると、必ず読みやすくなります。
Bladeディレクティブ一覧と詳細解説(@extends / @yield など)
「一覧 → 役割 → サンプル → どう処理されるか」
の順で、初心者向けに丁寧に説明します。
1) Bladeディレクティブとは?
Bladeディレクティブとは、
HTMLの中に書ける「Laravel専用の命令」です。
@extends:親テンプレートを指定する@section:中身を書く@yield:差し込み口を作る@include:部品を読み込む@if / @foreach:制御構文
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 |
セクション名(子と親をつなぐキー) |
「ここに、子テンプレートの @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ディレクティブ一覧(初心者向け・意味が分かる版)
「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 リクエストを防ぐため。 |
@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)
{
...
}
フレームワークやライブラリが 文字列として解析して使っていました。
どこで使われていた?
- Symfony のルーティング
- Doctrine ORM
- PHPDoc(IDE補助、型ヒント)
- 書き間違えても 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()
{
// ...
}
}
文法として解釈され、Reflection で安全に取得できます。
4) Laravel は Attribute(アノテーション)を使うのか?
Laravel は設計思想として、
「コードを見れば挙動が分かる」ことを重視しています。
Laravel の代表例
// routes/web.php
Route::get('/orders', [OrderController::class, 'index']);
「Controllerを開かないとルートが分からない」
という状態を避けています。
5) では、Laravel は「何で代替しているのか?」
| 役割 | Laravelでのやり方 |
|---|---|
| ルーティング | routes/web.php |
| DI / 設定 | コンストラクタ型指定 |
| バリデーション | FormRequest クラス |
| ORM定義 | Eloquentの命名規約 |
| 認可 | Policy / Gate |
アノテーションに依存しない設計をしています。
6) それでも Attribute を使うケースは?
- 独自フレームワークやツールを書くとき
- DTO / Validation / Mapping のメタ情報
- Doctrine ORM(Laravel外)
- 静的解析・自動生成ツール
「設定が分散して読みにくい」状態になりがちです。
7) 業務開発向けまとめ(重要)
- PHP 7まで:アノテーションは「コメントの工夫」
- PHP 8以降:Attribute という正式機能がある
- Laravel:基本は Attribute を使わない
- Laravel流:設定と構造で意味を表す
Laravel では、
「このクラスが何者か」「どこから呼ばれるか」を、
アノテーションではなく、ファイルの置き場所と設定ファイルで表現します。
例:
routes/web.phpに書いてある → 画面用ルーティングだと分かるapp/Http/Controllersにある → Controller だと分かるRoute::get(...)を見れば → どのURLで呼ばれるか分かる
「特別な印(アノテーション)を探さなくても、見える場所にそのまま書いてある」
という設計をしています。
非同期処理とは何か(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 は基本的に:
- 1リクエスト = 1プロセス
- 上から順に同期実行
そのため PHP での非同期処理とは、実際には:
3) Laravel における非同期処理の正体(Queue / Job)
Laravel では、非同期処理は Queue(キュー) と Job で実現します。
構成イメージ
- Controller:Job を投げるだけ
- Queue Worker:裏で 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' => 'メール送信を受け付けました'
]);
}
}
「受け付けた」時点で処理は完了です。
6) try / catch / throw の役割を整理
| 構文 | 意味 | どこで使う? |
|---|---|---|
try |
正常に動くはずの処理 | Job / Service |
catch |
失敗時の処理 | ログ・復旧 |
throw |
例外を上に伝える | 失敗として扱わせたい時 |
finally |
必ず通る後処理 | クリーンアップ |
7) 非同期処理で初心者が必ずハマる点
- ❌ Job の中で画面表示しようとする
- ❌ Controller で Job の結果を待とうとする
- ❌ 非同期なのに即時結果を期待する
前提に設計する必要があります。
8) 業務向けまとめ(重要)
- PHPの非同期 = キューに投げる
- Laravelでは Job / Queue を使う
- try/catch/throw で失敗を明示する
- Controller は「受け付けたら終わり」
JavaScript の async/await や、
Java/C# の Future/Task とは発想が違う点が、
PHP/Laravel の最大の特徴です。
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. 設定の中心は .env の QUEUE_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キュー)を使う手順
① テーブル作成(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 サーバと 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) トランザクションと非同期処理の関係(超重要)
「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) 最低限の運用メモ(本番で必要になること)
- ワーカーは常駐が必要(プロセスが止まると Job は実行されない)
- ログ監視(失敗が増えてないか)
- failed_jobs の確認(queue:failed)
- 再実行手順(queue:retry)
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 ファサード
Illuminate\Support\Facades\Http を使うのが標準です。
use Illuminate\Support\Facades\Http;
これは内部的に Guzzle(HTTPクライアント)を使っていますが、
Guzzleを直接触る必要はありません。
3) GET リクエスト(データ取得)
3-1. 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 の意味
「サーバーにデータを送って、何かを作成・更新してもらう」ためのメソッドです。
データは リクエストボディに含まれます。
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) よくある失敗と注意点
- ❌ タイムアウト未設定
- ❌ HTTPステータスを無視
- ❌ 外部APIを Controller に直書き
- ❌ エラー時の挙動を考えていない
常に「失敗する前提」で設計します。
9) 実務での設計指針(重要)
- HTTP通信は Service クラスにまとめる
- GET / POST の意味を守る
- try / catch / timeout を必ず書く
- テストでは Http::fake() を使う
ここまで理解できれば、
「外部APIを安全に呼べる 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 を作ります。
<?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) 配列のままにしないメリット(初心者に重要)
-
配列だと:
$data['craeted_at']のようなスペルミスでも気づきにくい (実行時に突然エラーになったり、nullになったり) -
DTOだと:
$dto->createdAtとして扱えるので、IDE補完が効きやすく間違いにくい - DTOだと:型が固定されるので「ここは int のはず」が守られる
外部APIの JSON は “そのまま使わず”、一度 DTO に詰め替えると、
バグが減り、業務ロジックが読みやすくなります。
DBに対するI/O処理の全体像(SQLite3を代表例に)
コネクション → SELECT/INSERT/UPDATE/DELETE → プリペアド → トランザクション
の順に説明します。代表DBは SQLite3 を使います。
最後に PostgreSQL / MySQL / Oracle / SQL Server の違いもまとめます。
また「SQLを書かずにアクセスする方法(ORM/Query Builder)」も紹介します。
0) まず用語(最初にこれだけ)
- DB(データベース):データを保存・検索する仕組み
- テーブル:行(レコード)を並べて保存する箱(表)
- SQL:DBに対して「検索・追加・更新・削除」を指示する言語
- コネクション:アプリ ↔ DB の接続
- トランザクション:複数の更新を「全部成功 / 全部失敗」にまとめる仕組み
1) SQLite3とは?(代表例として採用する理由)
1つの ファイル(例:database.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
DB_CONNECTION と DB_DATABASE を正しく設定できれば、接続は完成です。
2-3. 接続確認の考え方
(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 接続をしているわけではありません。
実際には:
DB::select()が呼ばれる- Facade が Laravel の DI コンテナを見る
- 中で管理されている Database Manager を取得する
- そのオブジェクトの
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');
これは、
という意味です。
DB は「データベース用の Facade」で、
実際のデータベース処理は、Laravel の内部にある本物の処理クラスが行っています。
なぜ new しなくていいのか
普通のクラスであれば、
$db = new DbClass();
のように自分で作ります。
しかしデータベース接続は、
- アプリ全体で 1つあればよい
- 設定ファイルを読んで初期化する必要がある
ため、Laravel が起動時に作って管理しています。
new する必要はありません。
::(ダブルコロン)の意味
:: は PHP の文法で、
クラス名を指定してメソッドを呼び出す書き方」
という意味です。
クラス名::メソッド名();
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)
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
);
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) 超重要:プリペアド(プレースホルダ)で安全に書く
例:
"... 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)
「全部成功したら確定(commit)」「途中で失敗したら全部取り消し(rollback)」
にする仕組みです。
5-1. どういう時に必要?(初心者向け例)
- 在庫を減らす + 注文を作る(どっちも成功しないとダメ)
- 口座Aから減らす + 口座Bに足す(片方だけ成功したら事故)
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の場合)
① 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();
}
}
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();
}
}
初心者はまず「CRUDの意味」と「トランザクション」を押さえるのが先です。
7) (補足)Laravel以外:素のPHPでSQLiteに接続する(PDO)
参考として、素のPHPの基本形も載せます(考え方は同じです)。
PDO(PHP Data Objects)とは何か
PDO(ピー・ディー・オー)とは、
PHP からデータベースに接続・操作するための標準機能です。
MySQL、SQLite、PostgreSQL など、
複数のデータベースを同じ書き方で扱えるのが最大の特徴です。
PDO が解決する問題
昔の PHP では、データベースごとに使い方が違いました。
- MySQL 用の書き方
- SQLite 用の書き方
- PostgreSQL 用の書き方
PDO を使うと:
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インジェクション攻撃を防げます。
Laravel と PDO の関係
Laravel で使われている
DB::select()- Eloquent(ORM)
は、
内部では 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 は次の順番でクラスを探します。
App\Services\PDO- 見つからなければエラー
PHP 標準の PDO クラスは探しに行きません。
\PDO と書くと何が変わるか
先頭に \ を付けると、
と明示したことになります。
$pdo = new \PDO('sqlite:database.sqlite');
これは次の意味です。
グローバル空間の PDO クラスを使う
use PDO; と書いた場合
もしファイルの先頭で次のように書けば:
use PDO;
$pdo = new PDO(...);
これも同じ意味になります。
use 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;
}
create() や update() で「まとめて代入」できるカラムを制限します。
(悪意ある入力で想定外のカラム更新がされるのを防ぐ)
「Laravel規約なら省略可能」とはどういう意味か
Laravel には、
「設定を書かなくても自動で判断するための決まり(規約)」
が多数用意されています。
この考え方は
と呼ばれます。
この場合の「規約」
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件
つまり:
?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ページ目のデータを返します。
開発者が
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() を使っている場合、
そのため、
- 全件数 →
$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))
を自動計算しています。
そのため、
初心者向けまとめ
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ページ目 |
つまり、
なぜ Laravel は ?page を見ているのか
paginate() は、
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 が勝手に読む
「ページ番号を気にしなくていいようにする仕組み」
存在しないページ番号(最後のページ以降)が指定されたらどうなるか
結論
Laravel の paginate() はエラーにしません。
最後のページを超える ?page が指定された場合でも、
「空の結果セット」を返すだけです。
具体例で説明
例えば、
全件数: 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_urlはnull
Controller 側で特別な処理は必要か?
通常は不要です。
API や画面側で、
- 「もうデータがない」
- 「次のページは存在しない」
という判断を、
data が空かどうか、
next_page_url が null かで行えば十分です。
もし「ページ超過はエラーにしたい」場合
業務要件によっては、
という場合もあります。
その場合は、Controller で明示的にチェックします。
$users = User::query()
->orderBy('username')
->paginate(20);
// 最終ページを超えていたら 404
if ($users->currentPage() > $users->lastPage() && $users->lastPage() !== 0) {
abort(404);
}
初心者向けまとめ
- 最後のページを超えてもエラーにはならない
- 空データが返るだけ
- Laravel は安全側(壊れない側)に倒す設計
- エラーにしたいなら自分で判定を書く
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();
ループの中で毎回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ごとの「プログラミング時の違い」まとめ
アプリケーションコード(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差異はゼロにならない
本番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 は次のような特徴を持ちます。
- データを一時的に持つ
- 型・項目を固定する
- DBやSQLの知識を持たない
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
) {}
}
ただ「データの形」を保証するだけです。
3) 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;
}
}
INSERT後、自動で ID が Model にセットされます。
自分で lastInsertId() を呼ぶ必要はありません。
6) 「DTO → DB保存」の正しい流れ(全体像)
画面 / API入力
↓
DTO(入力データの形を固定)
↓
Service(業務ルール・変換)
↓
Repository(INSERT / UPDATE)
↓
DB(auto increment ID 発行)
↓
Entity / Model(IDを持った状態)
7) よくある誤解(初心者が必ずハマる)
- ❌ DTO に save() を生やす
- ❌ DTO に SQL を書く
- ❌ Controller から直接 INSERT する
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とは何か?
「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(最も一般的)
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 StorageStorage::disk() |
ローカル/クラウドを同じ書き方で扱える | 実務のファイル保存(推奨) |
1) パス結合(OS差を吸収する)
\、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');
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) 大きいファイル(ストリームで読む / 書く)
理由:メモリを大量に使って落ちる可能性があるため。
代わりに 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 を使う(推奨)
ローカル(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) よくあるエラーと対処(初心者向け)
- Permission denied:保存先フォルダの権限が無い
- ファイルが送られない:formの
enctypeが無い - 巨大ファイルで失敗:PHP設定(upload_max_filesize 等)
- メモリ不足:大きいファイルを一括読み込みしている
abort() は業務でも使ってよいのか?
abort() は業務コードでも「正しい場所であれば」問題なく使えます。
ただし、どこでも使ってよいわけではありません。
1) abort() とは何か?
abort() は Laravel が用意している
「HTTPエラーを即座に返して、処理を中断する」ための関数です。
// 例:400 Bad Request を返して処理を止める
abort(400, '不正なリクエストです');
この1行で、内部的には次のことが起きています。
- HTTPステータスコード(400, 403, 404 など)を決定
- レスポンスを生成
- 例外を投げて処理を中断
2) abort() は「例外の一種」
abort() は、HttpException を投げているだけです。
// イメージ的には、これとほぼ同じ
throw new HttpException(400, '不正なリクエストです');
つまり、
「処理をこれ以上続けてはいけない」
という意思表示を、HTTPレベルで行う仕組みです。
3) 業務で「使ってよい」ケース
-
Controller
リクエストが不正な場合(権限不足・不正パラメータなど) -
HTTP層の処理
URL・クエリ・パスが不正なとき -
セキュリティ的に即遮断したい場合
パストラバーサル・CSRF・認可エラー
実務で普通に見る例
<?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) 業務で「使うべきでない」ケース
-
Service クラス
業務ロジックの途中 -
Repository
DBアクセス中 -
ドメインロジック
「ビジネス的に失敗した」だけのケース
NG例(よくある失敗)
<?php
class OrderService
{
public function create(array $data)
{
if ($data['qty'] <= 0) {
// ❌ HTTPの都合を業務ロジックに持ち込んでいる
abort(400, '数量が不正です');
}
// ...
}
}
ここでは 例外(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)— 設定・フォルダ・ファイル名・コードでの扱い方(初心者向け)
「どこに何ファイルを置くのか」、「設定はどこか」、「コードでどう呼ぶのか」、
「言語切替をどう実装するか」まで、順番に丁寧に説明します。
0) 多言語化でやること(全体像)
- 画面やメッセージの文字列を「コードに直書き」しない
- 言語ごとの翻訳ファイルにまとめる
- ユーザーの言語(locale)に応じて表示を切り替える
1) Laravel の翻訳ファイルはどこに置く?
1-1. 基本フォルダ
lang/
en/
messages.php
validation.php
ja/
messages.php
validation.php
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
基本は「最終的に 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 の登録(どこに書く?)
多くのプロジェクトでは
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)
- 現在の locale(例:ja)
- fallback_locale(例:en)
- それでも無ければキー文字列がそのまま表示される
8) バリデーションメッセージの多言語化(よく使う)
lang/ja/validation.php のようなファイルがあり、入力チェックのメッセージを多言語化できます。
<?php
// lang/ja/validation.php(例の一部)
return [
'required' => ':attribute は必須です。',
'email' => ':attribute はメール形式で入力してください。',
];
9) 初心者がハマる点(実務の注意)
- 翻訳キーを日本語にしない(キーはIDとして英数字推奨)
- 翻訳ファイルの分類を決める(messages/auth/errors など)
- ユーザー入力やDB値を翻訳キーに混ぜない
- 対応言語はホワイトリスト方式(ja/en 以外を弾く)
INSERTに失敗したら「画面にエラーダイアログ」で原因と対策を出す(Laravel)
INSERTに失敗したら、画面上にエラーダイアログを表示し、
原因と対策をユーザーに分かる言葉で表示する。
重要:DBエラーメッセージ(生のSQL例外)をそのまま画面に出さない。
理由:セキュリティ(内部構造が漏れる)と、ユーザーに理解できないため。
1) 実装の全体像(どこで何をするか)
| 層 | 役割 | ここでやること |
|---|---|---|
| Blade(HTML) | 表示だけ | セッションに入ったエラーを見てダイアログを出す |
| Controller | HTTPの窓口 | Serviceの例外を捕まえて「画面用のエラー」にして戻す |
| Service | 業務ルール | DB例外を「原因」「対策」に翻訳して業務例外として投げる |
| Repository | DB I/O | INSERTを実行する(SQL / Query Builder / Eloquent) |
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のみ担当)
ここに「ユーザー向けの原因・対策文」を書かない(責務が混ざる)。
<?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例外 → 原因/対策の業務例外に変換
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:業務例外を捕まえて画面に返す
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) 補足:バリデーションエラーは別ルートで扱う
「未入力」「形式が違う」は通常バリデーションで弾きます。
それは
$request->validate(...) に任せ、DB失敗は今回のように try/catch で扱う、という分担が一般的です。
バリデーションで弾いたとき、エラーメッセージはどこに表示するのか?
はい。バリデーションエラーは「元の画面」に戻り、
該当する入力項目の近くにエラーメッセージを表示するのが、
Laravelでも業務システムでも標準的なやり方です。
1) なぜ「項目の近く」に出すのか?
- どこが間違っているのか一瞬で分かる
- 画面全体を読まなくてよい
- 修正すべき場所に視線が自然に行く
入力チェック(バリデーション)エラーと、
INSERT失敗などの業務エラーは、
表示場所と扱い方を分けるのが定石です。
2) Laravelのバリデーションが自動でやってくれること
Laravelでは、$request->validate() を使うと、
バリデーションに失敗した瞬間に次のことが自動で行われます。
- 元の画面(直前の画面)へリダイレクト
- 入力値を
old()として保存 - エラーメッセージを
$errorsに格納
3) Controller側:バリデーションの書き方
<?php
// Controller の一部
public function store(Request $request)
{
// 1) バリデーション
// 失敗したら、この時点で自動的に元の画面へ戻る
$validated = $request->validate([
'name' => ['required', 'string', 'max:50'],
'email' => ['required', 'email'],
]);
// 2) ここに来るのは「入力が正しい」場合だけ
// DB登録などの処理を書く
}
バリデーション失敗時は
return も catch も書きません。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() は、見た目以上に多くのことを自動でやっています。
- リクエストの入力値をすべて取得
- ルールに従ってチェック
- 失敗したら:
- エラーメッセージを生成
- セッションに保存
- 元の画面へリダイレクト
- 成功したら:
- チェック済みデータを配列で返す
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() だけで書けます。
- 必須チェック
- 形式チェック(regex)
- 単純な重複チェック(unique)
<?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 では書けません。
- 論理削除(deleted_at が NULL のものだけ対象)
- 有効フラグが true のものだけ対象
- 複数条件・JOIN が必要
このときに初めて、あなたが提示したコードが必要になります。
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画面構成)
画面は ①一覧(検索+結果) と ②編集 の 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. 画面に表示されるもの
- 検索入力:ユーザー名の先頭文字(例:a)
- 検索ボタン:入力値で検索(GET /users?prefix=...)
- クリア(任意):検索条件を外して全件(GET /users)
- 結果テーブル:該当ユーザーの一覧
3-2. 一覧テーブルの列(例)
| id | username | tel | address | update_at | |
|---|---|---|---|---|---|
| 1 | akira | akira@example.com | 090-xxxx-xxxx | 横浜市… | 2026-02-16 21:00 |
| 2 | anna | anna@example.com | 080-xxxx-xxxx | 川崎市… | 2026-02-15 10:15 |
例:
/users/2/edit
3-3. 検索ルール
- 入力が空:全件(または「未検索」扱い)
- 入力がある:
username LIKE '入力%'の前方一致 - 大文字/小文字:DB設定に依存(MySQLの照合順序など)
prefix は 0〜50文字程度に制限し、想定外の長文を弾きます(DoSっぽい入力対策)。
※クエリは Eloquent/QueryBuilder を使えばSQLインジェクションは基本的に防げます。
4) 画面仕様②:ユーザー編集(email / tel を更新)
4-1. 画面に表示されるもの
- 表示専用:
id/username - 編集可能:
email/tel(要件どおり) - 参考表示(任意):
address/update_at - 保存ボタン:PUT /users/{id}
- 戻るボタン:一覧へ(検索条件があれば保持して戻すのが親切)
4-2. 更新ルール
- email はメール形式チェック(例:
emailバリデーション) - tel は形式をゆるく(数字・+・-・空白程度) or 正規化して保存
- 更新成功:一覧へリダイレクトし、完了メッセージを表示
- 更新失敗:編集画面に戻し、エラーメッセージ表示
- 一覧は GET(検索条件はクエリパラメータ)
- 編集表示は GET(URLに id)
- 更新は PUT(フォーム送信)
- 成功したら Redirect(PRGパターン:二重送信防止)
5) エラーハンドリング(最低限)
- 存在しない id:404(
findOrFail相当) - バリデーションエラー:編集画面に戻して入力とエラーを表示
- DB更新失敗:編集画面にエラー表示(ログも残す)
Laravel(PHP)サンプル:ユーザー検索(一覧)+ユーザー編集(2画面・パターンA)
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. 画面①:ユーザー一覧(検索+結果)
- 検索入力:
prefix(usernameの先頭文字) - 検索方法:前方一致(例:a →
username LIKE 'a%') - 一覧表示列:
username,email,tel,address - 行クリック:usernameをクリックすると編集画面へ遷移(例:
/users/2/edit) id,update_atは画面に表示しない
2-2. 画面②:ユーザー編集(email/telのみ編集)
- 表示専用:
username,address - 編集可能:
email,tel - 保存:
PUT /users/{id} - 更新成功:一覧へリダイレクト(PRG)+成功メッセージ
- 更新失敗:編集画面に戻る+入力保持+エラーメッセージ
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接続)
事前に
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. 「バリデーション文言がキーのまま」になる点の扱い(この仕様での正解)
- FormRequest の
messages()で返す値は「文字列」ではなく「キー」にする - Blade側の
@errorでは$messageをそのまま表示せず、I18n::t($message)で解決して表示する - 属性名(:attribute)を組み立てる場合は、I18n側で置換する拡張を入れる(業務では
{attribute}置換など)
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
- 検索入力に
a→username 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)
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. 画面①:ユーザー一覧(検索+結果)
- 検索入力:
prefix(usernameの先頭文字) - 検索方法:前方一致(例:a →
username LIKE 'a%') - 一覧表示列:
username,email,tel,address - 行クリック:usernameをクリックすると編集画面へ遷移(例:
/users/2/edit) id,update_atは画面に表示しない
2-2. 画面②:ユーザー編集(email/telのみ編集)
- 表示専用:
username,address - 編集可能:
email,tel - 保存:
PUT /users/{id} - 更新成功:一覧へリダイレクト(PRG)+成功メッセージ
- 更新失敗:編集画面に戻る+入力保持+エラーメッセージ
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接続)
事前に
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. 「バリデーション文言がキーのまま」になる点の扱い(この仕様での正解)
- FormRequest の
messages()で返す値は「文字列」ではなく「キー」にする - Blade側の
@errorでは$messageをそのまま表示せず、I18n::t($message)で解決して表示する - 属性名(:attribute)を組み立てる場合は、I18n側で置換する拡張を入れる(業務では
{attribute}置換など)
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
- 検索入力に
a→username 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)サンプル:注文・請求ルールエンジン(業務ロジック中心)
入力(注文JSON)を受け取り、税・割引・送料を計算して請求結果を返す、よくある基幹/業務系の計算エンジン例です。
1. このサンプルで学ぶこと(TypeScript版 → Laravel/PHP版への置き換え)
TypeScript版で扱っていた「文法要素」を、Laravel/PHPでの業務実装の形に置き換えます。
「文法の説明」ではなく、実際の業務ルールを実装する中で、言語機能をどう使うかを理解するのが目的です。
- 型の考え方:PHPの型宣言(
int/string/bool/array)と DTO による入力/出力の形固定 - enum(区分値):PHP 8.1+ の
enumを使い、税区分・会員ランク・配送地域を安全に表現 - 配列処理:
foreachで注文行を処理(TypeScript のfor...of相当) - 条件分岐:
match(またはswitch)で税率や送料などの分岐 - 集計:
array_reduceで小計などを集計(TypeScript のreduce相当) - 定数の扱い:
constやprivate constを使ってルール値を固定し、マジックナンバーを排除 - 入力検証:FormRequest で入力バリデーション(enumの不正値・必須/任意の扱いを明確化)
2. 業務の概要
ユーザーから渡された「注文データ(JSON)」を元に、次を計算します。
- 商品ごとの金額(行小計)と税額
- 顧客ランクやクーポンによる割引
- 配送地域による送料
- 最終的な請求金額(合計)
3. 入力データの考え方(Laravelでの受け取り)
入力は「JSON相当」です。実務では HTTP Request の body(JSON)に相当し、Laravelでは Controller が FormRequest で検証した後に業務ロジックへ渡します。
itemsは配列(注文行)なのでforeachで処理するcustomerRank/region/taxは PHP enum で安全に扱う- 計算途中の値は「変更しない前提」で変数に入れ、処理を関数/Serviceに閉じ込める(副作用を減らす)
couponは任意(optional)として、無いケースを必ず考慮する
4. 主な業務ルール(計算仕様)
4-1. 税計算(TaxCategory)
STANDARD:10%REDUCED:8%EXEMPT:0%
4-2. 割引(CustomerRank / Coupon)
GOLD会員:小計の 5%WELCOME10クーポン:小計の 10%- 割引は「会員割 + クーポン割」を合算する(本サンプルの前提)
4-3. 送料(Region / FreeShipping)
TOKYO:500円OTHER:800円- 小計が 10,000円以上で送料無料(送料 = 0)
税区分・会員ランク・配送地域は「任意文字列」ではなく、業務上の決まった選択肢です。
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)
}
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 // 最終請求金額
}
- 各行の
subtotal = unitPrice * qtyを計算 - 税区分(enum)から税率を決めて税額を算出(
matchなど) - 小計を
array_reduceで集計(または素直に合計変数で加算) - 会員割引・クーポン割引を適用して割引合計を算出
- 地域送料を計算し、送料無料条件(小計 >= 10,000)なら 0 にする
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で計算結果を返す)
業務ロジックは Service(RuleEngine)に集約し、Controller は薄く保ちます。
※ユーザー向けメッセージはソースにベタ書きせず、すべて lang ファイルから取得します。
0. 前提
- Laravel(9/10/11 いずれでも概ね同様)
- PHP 8.1+(enum を使用)
- このサンプルは DB を使用しません(純粋な計算エンジン)
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. 仕様チェック(要件の満たし方)
- items は
foreachで行計算(for...of相当) - 区分値は PHP enum(税区分/会員ランク/地域/クーポン)
- 税率・割引率・送料・送料無料閾値は定数化(マジックナンバー排除)
- 集計は
array_reduce例を提示(reduce相当) - ユーザー向けメッセージは
__('...')で lang から取得(ソース直書きなし) - 入力不正は FormRequest + lang/validation で 422 を返す(業務的に安全)
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\Route は Facade(ファサード) です。
ルーティング機能(Route登録)を簡単な書き方で呼び出せるようにした入口です。
3-2. ::post は「POSTメソッド専用のルート登録」
Route::post(...) は、HTTPメソッドが POST のときだけ一致するルートを登録します。
つまり、同じパスでも GET で叩いた場合はこのルートには一致しません(405等になります)。
3-3. 第1引数 '/billing/calc' は「URLパス(URI)」
ここで指定しているのはドメインを除いた「パス部分」です。
/billing/calcは「相対パス」であり、ホスト名は含みませんroutes/api.phpに書くルートは通常/apiが自動で付くため、実際のパスは/api/billing/calcになります
/api が付くかどうかは、Laravelのルート設定(RouteServiceProvider等)によります。多くの標準構成では
routes/api.php は /api 付きです。
3-4. 第2引数 [BillingController::class, 'calc'] は「呼び出す処理(アクション)」
第2引数は「このルートに一致したとき、何を実行するか」を表します。
Laravelでは以下の書き方が一般的です。
(A)Controllerを呼ぶ書き方:[クラス, 'メソッド名']
[BillingController::class, 'calc']
BillingController::classはクラス名(完全修飾される)を文字列として返します- 実体は
'App\Http\Controllers\BillingController'のような文字列です
- 実体は
'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. ルート名を使うメリット(業務で重要)
- URLが変わっても、ルート名が同じなら呼び出し側の修正が最小になる
- BladeやControllerで URL を直接書かずに済む(保守性が上がる)
- ルート一覧を見たときに「何のルートか」が分かりやすい
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行がやっていること)
Route::post(...):POSTのときだけ一致するルートを登録する'/billing/calc':URI(パス)を指定する(api.phpなら通常/apiが前につく)[BillingController::class, 'calc']:一致したら Controller のcalc()を実行する->name('billing.calc'):このルートに名前を付け、route('billing.calc')でURL生成できるようにする
なぜ 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 プレフィックスを付ける」
という処理を行っています。
書き方は変わりましたが、意味は同じです。
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. まとめ(重要ポイントだけ)
/apiが付くのは Laravelのルート読み込み設定によるものapi.phpというファイル名自体に魔法があるわけではない- 内部的には
Route::prefix('api')->group(...)と同じ - そのため
Route::post('/billing/calc', ...)→/api/billing/calcになる
「この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';
/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
ルート名
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>
テンプレートに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行の本質)
->name('billing.calc')は URL の別名ではない- 「このルートを指すための安定した識別子」を付けている
route('billing.calc')により、Laravelが正しいURLを計算する- URL変更に強い業務コードを書くための必須機能
ルート名とは「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 なしで起きること(面倒な事態)
例: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 ありで起きること(得する事態)
ルート定義だけ変更する
// 変更前
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 |
"/api/billing/calc" のような直書きは、環境差(/app など)でズレやすいです。「この環境だけ動かない」が起きやすくなります。
route('billing.calc') は、Laravelが「今の環境設定(APP_URL等)」に合わせて正しいURLを生成します。つまり、環境差をLaravelに吸収させられます。
2-3. 事態③:パラメータ付きURLで特に差が出る
id のようなパラメータがあると、直書きはさらに事故りやすいです。
// 例:ユーザー詳細
Route::get('/users/{id}', [UserController::class, 'show'])
->name('users.show');
// 正しいURLを自動生成:/api/users/10 のようになる
$url = route('users.show', ['id' => 10]);
直書きだと、"/api/users/".$id のように手作業で組み立ててミスしやすくなります。
3. まとめ:name を付けた場合に「得すること」
- URLが変更されても、修正は「ルート定義1か所」で済む
- 環境差(localhost / 本番 / サブディレクトリ)をLaravelが吸収してくれる
- パラメータ付きURLの組み立てミスを減らせる
- 結果として「直書きURLが散らばる事故(修正漏れ・環境不具合)」を防げる
「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 が必要/推奨」なクラス一覧(定義例つき)
一方、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. ひとこと(業務の勘所)
逆に Service / Repository / DTO は「自分たちの業務コード」なので、基本は継承させず単純に保つほうが読みやすく保守しやすいです。
Laravel の主要クラス種類を「いつ・なぜ使うか」で理解する
Laravel には多くの「クラスの種類」がありますが、
初心者が一番つまずくのは
「結局、どの局面でどれを使えばいいのか分からない」点です。
ここでは 実際の業務の流れに沿って、
「この局面ではこのクラス」という形で説明します。
全体像(まずこれだけ覚えてください)
| クラス種類 | 一言で | 使うタイミング |
|---|---|---|
| Controller | 入口 | HTTPリクエストを受ける |
| FormRequest | 入力チェック係 | 入力値を検証したいとき |
| Eloquent Model | DBの1行 | DBとデータをやり取りするとき |
| Migration | 表設計書 | テーブル構造を作る・変更する |
| Seeder | 初期データ投入 | テスト用・初期データを入れる |
| Factory | ダミーデータ工場 | 大量のテストデータが欲しい |
| ServiceProvider | 初期設定係 | アプリ起動時に設定したい |
| Console Command | CLI処理 | php artisan で何かしたい |
| Job | 裏でやる仕事 | 時間がかかる処理 |
| Event / Listener | 合図と反応 | 何か起きたら別処理を動かす |
1. Controller(必須・最初に通る)
役割: ブラウザやAPIからのリクエストを最初に受け取る場所。
「受付係」です。
<?php
class UserController extends Controller
{
// GET /users
public function index()
{
return view('users.index');
}
}
2. FormRequest(入力チェック専用)
役割: フォームやAPI入力が正しいかを検証する。
Controller に if 文を大量に書かないための仕組み。
<?php
class UserStoreRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email'],
];
}
}
3. Eloquent Model(Entity相当)
役割: DBテーブルの1行を PHP オブジェクトとして扱う。
<?php
class User extends Model
{
protected $table = 'users';
}
4. Migration(DB設計そのもの)
役割: テーブル構造をコードで管理する。
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email');
});
5. Seeder(初期データ)
役割: テストや初期状態用のデータ投入。
DB::table('users')->insert([
'email' => 'test@example.com',
]);
6. Factory(ダミーデータ生成)
役割: テスト用データを大量生成。
User::factory()->count(10)->create();
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()
{
// 重い処理
}
}
10. Event / Listener(何か起きたら反応)
役割: 「〇〇が起きたら××する」を分離。
// Event
class UserRegistered {}
// Listener
class SendWelcomeMail
{
public function handle(UserRegistered $event)
{
// 登録後メール
}
}
最後に(初心者向けの覚え方)
- 画面/API → Controller
- 入力チェック → FormRequest
- DB → Model
- DB設計 → Migration
- 初期データ → Seeder / Factory
- 重い処理 → Job
- 連動処理 → Event / Listener
他は「必要になったら足す」で問題ありません。
SQLite3(Laravel)で「テーブル作成 → 初期データ投入」までの流れ(Mermaid + コード例)
Laravel では通常、HTTPリクエストのたびに「テーブルが無ければ作る」はやりません。
理由:同時アクセス時の競合・性能・予期しないスキーマ変更・本番事故の原因になるため。
その代わり、起動(=環境セットアップ/テスト開始)時に Migration/Seeder を実行します。
ここでは「テスト環境(またはローカル環境)」で、PHP起動~テーブル作成~初期データ投入までを一連で説明します。
1) 全体の流れ(Mermaid)
1-1. 実行の流れ(テスト/ローカルでよくあるパターン)
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) まとめ(初心者向け)
- テーブル作成は Migration の
up() - 初期データは Seeder の
run() - SQLiteは DBファイルが必要(
database.sqlite) - 起動時に整えるは、実務では
php artisan migrate:fresh --seed(テスト開始時/環境セットアップ時)
Laravelにおける「初期テーブル作成」と「Webページ表示」の違いと操作方法
それぞれ目的が異なり、実行する
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
- Migrationクラスの
up()が実行される usersやinventoriesテーブルが作成される- Webサーバーは起動しない
2-3. テーブル作成+初期データ投入(テストでよく使う)
php artisan migrate:fresh --seed
既存テーブルをすべて削除し、Migrationで作り直した後、
Seederの
run() により初期データが INSERT されます。
3. Webページを表示するのは「サーバー起動」
テーブル作成が終わった後に、
実際に画面やAPIを利用するために Webサーバーを起動します。
3-1. Webサーバー起動コマンド
php artisan serve
- PHPの簡易Webサーバーが起動する
- デフォルトURL:
http://localhost:8000 - DBの作成・変更は一切行われない
php artisan serve は「表示専用」です。テーブルが無い状態で画面を開くと、DBエラーになります。
4. よくある誤解と正しい理解
❌ 誤解①:serveを叩くとテーブルが作られる
→ 作られません。
serve は Webサーバーを起動するだけです。
❌ 誤解②:migrateを叩かないとWebが見れない
→ ページ自体は表示されますが、DBアクセス時にエラーになります。
✅ 正しい流れ
php artisan migrate:fresh --seed(環境準備)php artisan serve(Web表示)
5. テスト環境の場合(サーバーは起動しない)
php artisan test
- Webサーバーは起動しない
- Laravelが内部的にHTTPリクエストを擬似実行
- CIや自動テスト向けの実行方法
6. まとめ(覚えるべきコマンドはこれだけ)
① DB初期化(作成+初期データ)
php artisan migrate:fresh --seed
② Webページ表示
php artisan serve
③ テスト実行
php artisan test
Migration / Seeder は「起動前の準備作業」。
serve は「アプリを公開する操作」。
両者は役割もタイミングも完全に別です。
Laravel(SQLite + Migration + Web)の一般的なプロジェクト構成ツリー
実務でよく使われる 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表示」の関係
- Migration / Seeder:環境を整えるための準備作業
- Controller / View:準備されたDBを使う側
php artisan serveは Migration を呼ばない
HTTPリクエスト中にテーブル作成や初期データ投入は行わない。
必ず「起動前・テスト開始前」に artisan コマンドで実行する。
5. 初心者向け一言まとめ
- Migration は「設計図」
- Seeder は「初期サンプルデータ」
- artisan は「それらを実行するための道具」
- serve は「Webを表示するだけ」
Laravel(PHP)入門:Hello, world を表示する(Controller → Service → Blade)
Hello, world を表示するだけ」ですが、実務でよくある層(Controller → Service → View/Blade)の形をあえて作ります。
目的は「Laravelの基本的なファイル構成と役割を理解すること」です。
実際のコードはシンプルなので、初心者の方も安心して進めてください。
1. このサンプルの完成イメージ
- ブラウザで
http://127.0.0.1:8000/helloにアクセスする - Route(ルーティング)が Controller のメソッドを呼ぶ
- Controller が Service を呼び、メッセージ
Hello, worldを受け取る - Controller が Blade(テンプレート)へメッセージを渡して表示する
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 プロジェクトを作る(作業手順)
事前に PHP と Composer がインストールされている必要があります。
3-1. 作業フォルダを作る
- 任意の場所にフォルダを作成(例:
C:\work) - VS Code でフォルダを開く(ファイル → フォルダーを開く)
3-2. ターミナルを開く
- Ctrl+@
3-3. Laravel プロジェクトを作成する
ターミナルで以下を実行します(プロジェクト名は例です)。
cd C:\work
composer create-project laravel/laravel hello-laravel
cd hello-laravel
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>
{{ $message }} は「PHP変数をHTMLに埋め込む」書き方です。Laravelでは安全のため自動エスケープ(危険な文字を無害化)が入ります。
6. 具体的な作業手順(VS Codeでのファイル作成)
-
VS Code で
hello-laravelフォルダを開く -
routes/web.phpを開き、上記の Route を追記する -
左のエクスプローラーで
app/Servicesフォルダを作成する(無ければ)
→ その中にHelloService.phpを新規作成して貼り付ける -
app/Http/ControllersにHelloController.phpを新規作成して貼り付ける -
resources/viewsにhello.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.php に Route::get('/hello', ...) があるか確認します。
9. まとめ
php artisan serveは「Webを表示する」ための起動コマンド- Hello表示でも、実務構成に合わせて Controller → Service → Blade に分けられる
- ControllerはHTTP担当、Serviceは業務担当、Bladeは表示担当
PHP の namespace とは?なぜ付けるのか(Laravel前提で理解する)
Laravelでは「フォルダ構成」と「namespace」を揃えるのが基本ルールになっています。
1. namespace の一言定義
namespace は「クラス名の住所(所属)」です。
同じクラス名でも 住所が違えば別のクラスとして扱えます。
2. 何が困る?(namespace が無い世界)
もし namespace が無い(全クラスが同じ場所に住んでいる)と、
同じクラス名を2つ作れません。
<?php
// namespace が無いと仮定
class User { } // ① User クラス
class User { } // ② 同じ名前なのでエラー(再定義)
namespace がないと、プロジェクトが大きくなるほど破綻します。
3. namespace があるとどうなる?(住所が違うので衝突しない)
クラス名が同じでも、namespace(住所)が違えば共存できます。
<?php
namespace App\Models;
class User { } // App\Models\User
// 別ファイル
namespace App\Dtos;
class User { } // App\Dtos\User(同名でもOK)
ここでのポイントは、PHP内部ではクラス名は実はこう扱われることです:
App\Models\UserApp\Dtos\User
つまり「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. まとめ
- namespace=クラスの「住所」。同名クラスの衝突を防ぐ
- Laravelでは フォルダと namespace を一致させるのが基本
- use=別住所のクラスを「短く書いて使う」ための宣言
なぜ $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);
クラスから作られた実体(オブジェクト)は別物です。
2. $this が無いと何が起きる?
仮に $this を書かずにこう書くとどうなるでしょうか。
// ❌ 間違い
$message = $helloService->getMessage();
PHPはこう考えます:
- 「
$helloServiceという 変数を探す」 - 関数内にそんな変数は存在しない
- →
Undefined variable: helloServiceエラー
クラスのプロパティ(メンバ変数)にアクセスするときは、
必ず
$this-> が必要です。
3. $this は「今この処理をしている自分」
$this は実行中のメソッドが
「どのオブジェクトの中で動いているか」を示します。
| 書き方 | 意味 |
|---|---|
$this->helloService |
この Controller 自身が持っている helloService |
$this->helloService->getMessage() |
その Service の getMessage() を呼ぶ |
4. なぜコンストラクタで代入しているのか?
public function __construct(HelloService $helloService)
{
$this->helloService = $helloService;
}
この1行があることで、
- Laravel が作った
HelloServiceを - Controller のプロパティとして保持できる
- 別のメソッド(index など)からも使える
もし代入しなければ、$helloService は
コンストラクタの中だけで消える変数になります。
5. 図で理解する(イメージ)
HelloController(オブジェクト)
├─ $this
│ ├─ helloService ──▶ HelloService(オブジェクト)
│ │ └─ getMessage()
│ └─ index()
6. 静的メソッドとの違い(補足)
もし static メソッドであれば $this は使えません。
class Sample
{
public static function test()
{
// $this は使えない(オブジェクトが無い)
}
}
今回の Controller / Service は すべてインスタンス(実体)として動くため、
$this を使います。
7. まとめ
$thisは「今のオブジェクト自身」を指す- クラスのプロパティにアクセスするには必須
$this->helloServiceは「この Controller が持っている Service」- DI と組み合わせると、Controller 全体で Service を使える
return view('hello', ['message' => $message]); の意味
Controller → View(Blade)へデータを渡して表示する、という役割を担っています。
1. 全体を一言で言うと
return view('hello', [
'message' => $message,
]);
意味を日本語にすると:
その画面に
message という名前でデータを渡す」
2. view() は Laravel の関数ですか?
はい。view() は Laravel が用意しているグローバルヘルパ関数です。
- Laravel本体に最初から含まれている
- Bladeテンプレートを描画するための関数
- Controller から画面を返すときに使う
// 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 |
フォルダはドット区切りで指定します。
4. 'message' は Blade 側の変数名ですか?
はい、その通りです。
'message' => $message
これは次の意味を持ちます:
$message という値を、Blade 側では
$message という変数名で使えるようにする」
5. Blade 側ではどう使われる?
resources/views/hello.blade.php では、次のように書けます。
<!doctype html>
<html>
<body>
<h1>{{ $message }}</h1>
</body>
</html>
この {{ $message }} は、
- Controller から渡された
'message' => $message- の message に対応
6. もし配列を渡したら?
return view('hello', [
'message' => 'Hello',
'userName' => 'Taro',
]);
Blade 側では:
{{ $message }} // Hello
{{ $userName }} // Taro
7. なぜ return しているのか?
Controller の役割は HTTPレスポンスを返すことです。
view()は「画面オブジェクト」を作るreturnすることでブラウザに返す
ブラウザ
↑
HTTPレスポンス(HTML)
↑
Controller → return view(...)
8. まとめ(初心者向け超要点)
view()は Laravel 標準の関数'hello'は表示する Blade ファイル名'message'は Blade 側で使う変数名$messageは Controller 側の値- Controller → Blade にデータを渡す仕組み
「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;
}
}
これは:
- HelloController が
- HelloService に依存している
- その依存関係を Laravel が自動で注入する
2. Service が複数ある場合の書き方
例えば、次のような Service が必要だとします。
HelloService(挨拶ロジック)UserService(ユーザー処理)LogService(業務ログ)
この場合、__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)
4. よくある疑問①:数が増えすぎたらどうする?
例えば __construct がこうなったら要注意です。
public function __construct(
AService $a,
BService $b,
CService $c,
DService $d,
EService $e
) {}
Controller がやりすぎている可能性があります。
対処法:
- Service をまとめた Facade / Coordinator Service を作る
- 処理を別 Controller に分ける
- Service 同士の責務を整理する
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. まとめ
- 必要な Service は
__constructにすべて書いてよい - Laravel は型ヒントを見て自動注入する
- 多すぎる場合は設計見直しのサイン
- PHP8以降は省略記法も使える
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 は「Controller だから」行われるわけではない
- Laravel が管理して生成するクラスは DI される
- extends の有無は関係ない
newすると DI は効かない- 「誰が new しているか」が判断基準
「自分で 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 コンテナが管理する対象になります。
3. DTO はなぜ DI されないのか?
class UserDto
{
public function __construct(
public string $name,
public string $email
) {}
}
DTO(Data Transfer Object)は:
- 単なるデータの箱
- 処理ロジックを持たない
- 毎回中身が違う
「どの値を入れるの?」という問題が発生します。
そのため 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 の1行を表す
- レコードごとに中身が違う
- 「生成される」というより「取得される」
// DBから取得される
$user = User::find(1);
「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 されるのは「処理を持つクラス」
- DTO はデータの箱 → DI しない
- Entity は DB の1行 → DI しない
- Service / Repository は DI する
- 「毎回中身が変わるもの」は DI しない
「
ロジック → DI
データ → new / DB
」
Laravel は「どこを見て」DI が動作するか決めているのか?
「Controller だから」「Service だから」といった
クラスの種類では DI を判断していません。
DI が動作するかどうかは、非常に単純な条件だけで決まります。
1. DI が動作する条件(これだけ)
Laravel の DI(Service Container)が動作するのは、
次の条件を満たすクラスです。
- コンストラクタが存在しない
- または、すべてのコンストラクタ引数にデフォルト値がある
- または、すべてのコンストラクタ引数がさらに解決可能なクラスである
まとめると:
これだけを 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 が動作するかどうかは、次の要素とは関係ありません。
- Controller かどうか
- Service かどうか
- DTO / Entity かどうか
- extends しているかどうか
- プロパティを持っているかどうか
5. まとめ(事実のみ)
- Laravel はクラスの種類で DI を判断しない
- 見ているのはコンストラクタだけ
- 引数なしで new できる形なら DI は動作する
- プロパティの有無や初期値は判断材料ではない
「このクラスは、引数なしで new できるか?」
PHP / Laravel のログ出力:デバッグ用(コンソール)と本番用(ログファイル)の使い分け
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 のログ出力先は .env の LOG_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:障害調査が必要
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 と比較して)
- C の
printf()相当 → Laravel ならLog::debug()(安全) - 画面に出す →
dd()/dump()(開発中だけ) - 本番はログファイル(またはコンテナログ)に残す
- ログの出力先やレベルは
.envで切り替える
仕様説明:ユーザー一覧(name / email)表示(Eloquent + 20件ページネーション + 業務レベル例外処理)
1. 画面仕様
- users テーブルから name, email を取得し、HTML の
<table>に 1行 = 1ユーザーで表示する。 - 一覧は 20件ごとにページネーションする(例:
?page=2)。 - 表示対象は 論理削除除外(
deleted_at IS NULL)とする。 - 必要に応じて 有効ユーザーのみ(
is_active = 1)を表示する(本サンプルでは ON)。
2. エラー処理仕様(業務プログラム並み)
- DBアクセス等で例外が発生した場合:
- サーバ側:例外を握りつぶさず
Logに詳細を記録し、ユーザーには安全なメッセージを返す。 - 画面側:一覧の上に ダイアログ(モーダル) を表示してエラーを通知する。
- サーバ側:例外を握りつぶさず
- ユーザー向けメッセージ例:
- 「ユーザー一覧の取得に失敗しました。時間をおいて再度お試しください。」
3. 責務分離(サンプルとしての設計)
- Entity:ドメインとしての User(name/email 等)
- DTO:画面表示向けの UserRowDto(name/email)
- Repository:Eloquent を使った取得(paginate 20件)を隠蔽
- Service:取得処理+DTO変換+例外を業務例外へ変換
- Controller:HTTPリクエストを受けてViewへ渡す
- View(Blade):テーブル表示+ページネーションリンク+エラーダイアログ
コード(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. 画面仕様
- 入力項目は以下の3つ:
- ユーザー名(name)
- メールアドレス(email)
- パスワード(password)
- 登録成功時:
- 成功ダイアログ(モーダル)を表示する
- 数秒後に自動で消える(自動消滅の有無・秒数は設定ファイルで制御)
- 登録失敗時(バリデーションエラー):
- 該当入力欄の近くに赤文字でエラーメッセージ表示
- 特に email の重複は「すでに登録済みのメールアドレスです」を email のそばに表示
- 表示言語:
- i18n(Laravelの多言語化)で 日本語 / 英語 切り替え可能
2. バリデーション仕様
- ユーザー名(name)
- 必須(空文字NG)
- 許可:英小文字 + 数字のみ
- 例:
taro01はOK、Taroやtaro_01はNG
- メール(email)
- 必須(空文字NG)
- 形式チェック(email形式)
- 重複NG(既存登録済みならエラー)
- パスワード(password)
- 必須(空文字NG)
- 8文字以上
- 許可:英数字 + _ , - , @ , # , $ , % , &
- 例:
abc_1234/Abc-1234@はOK(英字大文字もOKにする場合は正規表現変更)
3. 登録処理仕様
- Eloquentで users にINSERTする
- password は ハッシュ化して
password_hashに保存(生パスワード保存禁止) - 正常登録時は
is_active = trueとする - role はデフォルト
user - 論理削除済み(deleted_at != null)のユーザーは「登録済み扱い」にするかは要件次第:
- 本サンプルでは 論理削除も含めて重複扱い(=登録不可) とする(事故防止)
4. 例外処理仕様(業務レベル)
- DB例外などはサーバ側でログに詳細を出し、画面には安全なメッセージを表示
- email重複は業務例外として扱い、email欄にピンポイントでエラーを返す
5. 責務分離(業務サンプル構成)
- DTO:Blade ⇔ Controller の入力/表示データ
- Entity:ドメインの User
- Repository:EloquentによるDBアクセス
- Service:登録処理・重複判定・例外変換
- Controller:HTTP入出力 / ViewへDTO渡し
- View(Blade):フォーム / エラー表示 / 成功モーダル
- Config:成功モーダルの自動消滅ON/OFFと秒数
- Lang:日本語・英語のメッセージ
コード(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. 背景と前提
- UserSetting テーブルは
user_idとsettingsの2カラムのみを持つ。 settingsは JSON で、ページング行数など複数の設定が詰まっている。- ユーザーがログインしたタイミングで UserSetting を読み込み、以後の処理から簡単に参照したい。
2. 重要な注意(PHP/Laravelの仕組み上の前提)
- PHPはリクエストごとにプロセスが切り替わるため、「メモリに保存した設定」は次のHTTPリクエストには自動では残らない。
- よって本仕様では以下の2段構えで実装する:
- ログイン直後にDBから読み込み → session に保存(次リクエスト以降も維持するため)
- 各リクエスト開始時に session → グローバル設定クラスへ復元(全処理から参照するため)
3. 要件
- ログイン成功時に
user_settingsから該当ユーザーの settings JSON を読み込む。 - 読み込んだ settings を、全処理から見える「設定情報クラス(UserSettingsStore)」に保存する。
- 次のHTTPリクエストでも使えるように、settings を session に保存する。
- settings が存在しない場合は、デフォルト設定を使う(例:ページング 20件)。
- DB例外はログに詳細を残し、アプリ側には安全なエラーを返す(業務レベル)。
4. 参照方法(完成形)
- どの層(Controller/Service/Viewなど)からでも以下で参照できる:
app(\App\Support\UserSettingsStore::class)->getInt('paging.per_page', 20)app(\App\Support\UserSettingsStore::class)->get('theme', 'light')
コード(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>