Laravelで保守性・安全性・パフォーマンスの高いアプリケーションを作るための実践的な指針をまとめたものです。
目次
- 基本方針
- ディレクトリ構成と責務分離
- ルーティング
- コントローラー
- バリデーション
- Eloquent / データベース
- マイグレーション
- 認可(Policy / Gate)
- API開発
- 設定と環境変数
- キュー・イベント・非同期処理
- パフォーマンス
- セキュリティ
- テスト
- コード品質・ツール
- 命名規則
- デプロイ・運用
- チェックリスト
1. 基本方針
- フレームワークの規約に従う(Convention over Configuration) Laravelの標準的な書き方から外れるほど、新メンバーの学習コストとバグの温床が増えます。
- Fat Controller を避ける コントローラーはHTTPの入出力に専念させ、ビジネスロジックは別のクラスに置きます。
- 単一責任の原則(SRP) 1つのクラス・メソッドは1つの責務だけを持たせます。
- 標準機能を優先する 独自実装の前に、Laravel標準機能や公式パッケージ(Sanctum、Horizon、Pint など)で解決できないか確認します。
- 型を明示する 引数・戻り値・プロパティに型宣言をつけ、静的解析を効かせます。
declare(strict_types=1);2. ディレクトリ構成と責務分離
推奨される役割分担
| レイヤー | 置き場所の例 | 責務 |
|---|---|---|
| Controller | app/Http/Controllers | リクエスト受付・レスポンス返却 |
| Form Request | app/Http/Requests | バリデーション・入力の認可 |
| Action / Service | app/Actions, app/Services | ビジネスロジック |
| Model | app/Models | データ構造・リレーション・スコープ |
| Policy | app/Policies | 認可ロジック |
| Resource | app/Http/Resources | APIレスポンスの整形 |
| Job | app/Jobs | 非同期処理 |
| Event / Listener | app/Events, app/Listeners | 疎結合な副作用処理 |
| Enum | app/Enums | 状態・種別の定義 |
Action クラスの例
1つのユースケースを1クラスで表現すると、再利用・テストがしやすくなります。
namespace App\Actions\Order;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
final class PlaceOrderAction
{
public function __construct(
private readonly InventoryService $inventory,
) {}
public function execute(User $user, array $items): Order
{
return DB::transaction(function () use ($user, $items) {
$this->inventory->reserve($items);
$order = $user->orders()->create([
'status' => OrderStatus::Pending,
]);
$order->items()->createMany($items);
OrderPlaced::dispatch($order);
return $order;
});
}
}Repository パターンについて Eloquent自体が抽象化レイヤーなので、単純なCRUDに対してRepositoryを機械的に挟むのは過剰設計になりがちです。複雑なクエリの集約やデータソースの差し替えが本当に必要な場合に限定して導入しましょう。
3. ルーティング
- リソースルートを使う
// Good
Route::resource('posts', PostController::class);
Route::apiResource('comments', CommentController::class)->only(['index', 'store']);
// Bad: 同じことを個別に書く
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/create', [PostController::class, 'create']);
// ...- 名前付きルートを使い、URLのハードコードを避ける
return redirect()->route('posts.show', $post);- ルートモデルバインディングを使う
// Good
public function show(Post $post) { ... }
// Bad
public function show($id)
{
$post = Post::findOrFail($id);
}- グループ化でミドルウェア・プレフィックスをまとめる
Route::middleware(['auth', 'verified'])
->prefix('admin')
->name('admin.')
->group(function () {
Route::resource('users', Admin\UserController::class);
});- ルートにクロージャを書かない(
route:cacheの妨げになり、テストもしにくい) - 本番環境では
php artisan route:cacheを実行する
4. コントローラー
- 薄く保つ:バリデーションはForm Request、ロジックはAction/Service、認可はPolicyへ。
- リソースコントローラーの7アクション(
index,create,store,show,edit,update,destroy)に収める。収まらない場合は新しいコントローラーを切る。 - 単一アクションなら
__invokeを使う
final class PublishPostController extends Controller
{
public function __invoke(Post $post, PublishPostAction $action)
{
$this->authorize('publish', $post);
$action->execute($post);
return back()->with('status', '公開しました');
}
}Good / Bad
// Bad: すべてをコントローラーで処理
public function store(Request $request)
{
$request->validate([...]);
if ($request->user()->cannot('create', Post::class)) abort(403);
$post = new Post();
$post->title = $request->title;
// ...長いロジック
Mail::to(...)->send(...);
}
// Good
public function store(StorePostRequest $request, CreatePostAction $action)
{
$post = $action->execute($request->user(), $request->validated());
return redirect()->route('posts.show', $post);
}5. バリデーション
- Form Request を使う
final class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'status' => ['required', Rule::enum(PostStatus::class)],
'tags' => ['array'],
'tags.*' => ['integer', 'exists:tags,id'],
];
}
}- ルールは配列記法で書く(
'required|string'よりも拡張・可読性が高い) $request->validated()または$request->safe()だけを使う。$request->all()を保存処理に渡さない。- 複雑なルールはカスタムRuleクラスに切り出す(
php artisan make:rule) - エラーメッセージは
lang/ja/validation.phpで日本語化する
6. Eloquent / データベース
マスアサインメント対策
class Post extends Model
{
protected $fillable = ['title', 'body', 'status'];
}$guarded = [] は便利ですが、意図しないカラム更新のリスクがあるため避けるのが無難です。
N+1問題を防ぐ
// Bad: posts件数分のクエリが発生
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
// Good
$posts = Post::with('author')->get();開発環境では遅延ロードを禁止して早期発見します。
// AppServiceProvider::boot()
Model::shouldBeStrict(! app()->isProduction());shouldBeStrict() は以下をまとめて有効化します。
- 遅延ロードの禁止(N+1検出)
$fillableにない属性への代入で例外- 存在しない属性へのアクセスで例外
必要なカラムだけ取得する
User::select(['id', 'name', 'email'])->get();大量データはチャンク/カーソルで処理する
User::where('active', true)->chunkById(1000, function ($users) {
foreach ($users as $user) { ... }
});
foreach (User::lazy() as $user) { ... }クエリスコープで条件を再利用する
// モデル
public function scopePublished(Builder $query): void
{
$query->where('status', PostStatus::Published)
->whereNotNull('published_at');
}
// 利用側
Post::published()->latest()->paginate();キャスト・Enumを活用する
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
}
class Post extends Model
{
protected function casts(): array
{
return [
'status' => PostStatus::class,
'published_at' => 'datetime',
'meta' => 'array',
];
}
}アクセサ/ミューテタは Attribute で定義する
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->last_name} {$this->first_name}",
);
}トランザクション
複数テーブルを更新する処理は必ずトランザクションで囲みます。
DB::transaction(function () { ... });生SQLは極力避ける
やむを得ず使う場合は必ずバインディングを使います。
// Bad: SQLインジェクションの危険
DB::select("SELECT * FROM users WHERE email = '$email'");
// Good
DB::select('SELECT * FROM users WHERE email = ?', [$email]);whereRaw、orderByRaw などにユーザー入力を直接埋め込まないよう注意してください。
7. マイグレーション
- 一度本番に適用したマイグレーションは編集しない。変更は新しいマイグレーションで行う。
- 外部キー制約とインデックスを適切に張る
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->string('status')->index();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});down()メソッドを実装してロールバック可能にする- 大規模テーブルへのカラム追加・インデックス作成はロックやダウンタイムに注意する
- テストデータはFactory、初期データはSeederで管理する
8. 認可(Policy / Gate)
- モデルに紐づく認可はPolicyにまとめる
final class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}- コントローラー・Blade・ルートで一貫して使う
// コントローラー
$this->authorize('update', $post);
// または Gate::authorize('update', $post);
// ルート
Route::put('/posts/{post}', ...)->can('update', 'post');
// Blade
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">編集</a>
@endcan- UIで隠すだけでなく、必ずサーバー側でも認可チェックする
9. API開発
- API Resourceでレスポンスを整形する(モデルをそのまま返さない)
final class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'status' => $this->status->value,
'author' => UserResource::make($this->whenLoaded('author')),
'published_at' => $this->published_at?->toIso8601String(),
];
}
}- 認証はSanctum(SPA・モバイル・トークン)を第一候補にする。OAuth2サーバーが必要な場合はPassport。
- ページネーションを必ず行う(
paginate()/cursorPaginate()) - レートリミットを設定する
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});- HTTPステータスコードを正しく使う(201 Created、204 No Content、422 Unprocessable Entity など)
- バージョニングを考慮する(
/api/v1/...)
10. 設定と環境変数
env()は設定ファイル(config/*.php)の中だけで使う
// config/services.php
'stripe' => [
'key' => env('STRIPE_KEY'),
],
// アプリケーションコード
config('services.stripe.key'); // Good
env('STRIPE_KEY'); // Bad: config:cache 後は null になる.envはGit管理しない。.env.exampleに必要なキーを列挙して共有する。- 本番では
APP_DEBUG=false、APP_ENV=productionにする - マジックナンバーや固定文字列は設定ファイル・定数・Enumに切り出す
11. キュー・イベント・非同期処理
- 時間のかかる処理(メール送信、外部API、画像処理など)はキューに逃がす
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(public readonly User $user) {}
public function handle(): void
{
Mail::to($this->user)->send(new WelcomeMail($this->user));
}
public function failed(Throwable $e): void
{
report($e);
}
}- ジョブは冪等に作る(リトライされても問題が起きないように)
$tries・$backoff・$timeoutを明示する- トランザクション内でディスパッチする場合は
afterCommitを使う
SendWelcomeEmail::dispatch($user)->afterCommit();- Redisキューの監視にはHorizonを使う
- 副作用(通知、ログ、集計など)はEvent / Listenerで疎結合にする
- 定期実行はスケジューラーで管理し、cronは
schedule:runの1行だけにする
12. パフォーマンス
| 対策 | 内容 |
|---|---|
| Eager Loading | with() / load() でN+1を解消 |
| インデックス | WHERE・ORDER BY・JOINに使うカラムに付与 |
| キャッシュ | Cache::remember() で重いクエリや計算結果を保持 |
| キュー | 重い処理を非同期化 |
| 各種キャッシュコマンド | config:cache / route:cache / view:cache / event:cache |
| Autoloader最適化 | composer install --optimize-autoloader --no-dev |
| OPcache | 本番環境で有効化 |
| 計測 | Telescope、Debugbar(開発環境のみ)、Pulse などで可視化 |
$stats = Cache::remember('dashboard.stats', now()->addMinutes(10), function () {
return [
'users' => User::count(),
'posts' => Post::published()->count(),
];
});- 件数だけが欲しいときは
count()を使い、get()->count()はしない - 存在確認は
exists()を使う - 集計はDB側で行う(
withCount()、sum()、avg()など)
13. セキュリティ
- CSRF:フォームには
@csrfを必ず入れる - XSS:Bladeは
{{ }}を使う。{!! !!}は信頼できるHTMLにのみ使う - SQLインジェクション:クエリビルダー/Eloquentを使い、生SQLではバインディング必須
- マスアサインメント:
$fillableを定義し、validated()の値のみ渡す - 認可:すべての更新系操作でPolicyチェック
- パスワード:
Hash::make()(またはモデルの'password' => 'hashed'キャスト)を使う。平文保存は厳禁 - ファイルアップロード:MIMEタイプ・サイズを検証し、公開ディレクトリに直接置かない。ファイル名はサーバー側で生成する
- レートリミット:ログインやAPIに
throttleミドルウェアを設定 - 機密情報:ログやエラーレスポンスに個人情報・トークンを出力しない
- 依存パッケージ:
composer auditで脆弱性を定期確認し、Laravel本体もサポート期間内のバージョンを使う - HTTPS:本番では強制し、Cookieに
secureを設定
14. テスト
- Feature Testを中心に書く(HTTPリクエスト単位で振る舞いを検証)
- Pest または PHPUnit を使う
it('allows the author to update their post', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)
->put(route('posts.update', $post), [
'title' => '新しいタイトル',
'body' => '本文',
'status' => 'draft',
])
->assertRedirect(route('posts.show', $post));
expect($post->fresh()->title)->toBe('新しいタイトル');
});
it('forbids other users from updating the post', function () {
$post = Post::factory()->create();
$this->actingAs(User::factory()->create())
->put(route('posts.update', $post), [...])
->assertForbidden();
});- Factoryでテストデータを作る。状態は
state()メソッドで表現する RefreshDatabaseトレイトでテストごとにDBをリセットする- 外部依存はFakeを使う:
Mail::fake()、Queue::fake()、Event::fake()、Http::fake()、Storage::fake() - 正常系だけでなく、バリデーションエラー・認可エラーもテストする
- CIで自動実行する
15. コード品質・ツール
| ツール | 用途 |
|---|---|
| Laravel Pint | コードフォーマッター(PSR-12ベース) |
| Larastan (PHPStan) | 静的解析 |
| Rector | 自動リファクタリング・バージョンアップ支援 |
| Pest / PHPUnit | テスト |
| Laravel Telescope | 開発時のデバッグ・リクエスト監視 |
| Laravel Debugbar | クエリ数・実行時間の確認 |
| Laravel IDE Helper | IDE補完の強化 |
./vendor/bin/pint
./vendor/bin/phpstan analyse
php artisan test --parallelCIでフォーマット・静的解析・テストを必ず通すようにしておくと、品質を継続的に保てます。
16. 命名規則
| 対象 | 規則 | 良い例 | 悪い例 |
|---|---|---|---|
| コントローラー | 単数形 + Controller | PostController | PostsController |
| モデル | 単数形・パスカルケース | User, OrderItem | Users |
| テーブル | 複数形・スネークケース | order_items | orderItem |
| ピボットテーブル | 単数形をアルファベット順に | post_tag | tags_posts |
| カラム | スネークケース(モデル名を含めない) | title, created_at | post_title |
| 外部キー | 単数形 + _id | user_id | users_id |
| ルート名 | 複数形・ドット区切り | posts.show | show-post |
| hasOne / belongsTo | 単数形 | author() | authors() |
| hasMany / belongsToMany | 複数形 | comments() | comment() |
| メソッド | キャメルケース | getActiveUsers() | get_active_users() |
| 変数 | キャメルケース | $activeUsers | $active_users |
| Bladeビュー | ケバブケース | show-filtered.blade.php | showFiltered.blade.php |
| 設定キー | スネークケース | config('app.timezone') | config('app.timeZone') |
| Form Request | 動詞 + モデル + Request | StorePostRequest | PostRequest |
| Job | 動詞で始める | SendWelcomeEmail | WelcomeEmailJob |
| Event | 過去形 | OrderPlaced | PlaceOrder |
17. デプロイ・運用
本番デプロイ時の基本コマンド
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize # config/route/view/event のキャッシュ
php artisan queue:restart # ワーカーに新しいコードを反映- ゼロダウンタイムデプロイを検討する(Envoyer、Deployer、Laravel Cloud / Forge、コンテナのローリングアップデートなど)
- キューワーカーはSupervisor等でプロセス監視する
- ログは
stackチャンネルで集約し、エラー監視サービス(Sentry等)と連携する - ヘルスチェック用のエンドポイント(
/up)を監視に使う - ストレージ・セッション・キャッシュは複数サーバー構成を想定して外部化(S3、Redis等)する
- 定期的にバックアップを取り、リストア手順を検証しておく
18. チェックリスト
実装時
- コントローラーにビジネスロジックを書いていない
- バリデーションはForm Requestで行っている
- 保存処理に
validated()の値のみを渡している - 認可チェック(Policy)を実装している
- N+1クエリが発生していない
- 複数テーブル更新はトランザクションで囲んでいる
-
env()を設定ファイル以外で使っていない - 重い処理はキューに逃がしている
- 型宣言をつけている
リリース前
- テストが通っている(正常系・異常系・認可)
- Pint・Larastanでエラーがない
-
APP_DEBUG=falseになっている -
composer auditで脆弱性がない - マイグレーションのロールバックが可能
- キャッシュコマンドを実行する手順がデプロイに含まれている