Laravelで保守性・安全性・パフォーマンスの高いアプリケーションを作るための実践的な指針をまとめたものです。


目次

  1. 基本方針
  2. ディレクトリ構成と責務分離
  3. ルーティング
  4. コントローラー
  5. バリデーション
  6. Eloquent / データベース
  7. マイグレーション
  8. 認可(Policy / Gate)
  9. API開発
  10. 設定と環境変数
  11. キュー・イベント・非同期処理
  12. パフォーマンス
  13. セキュリティ
  14. テスト
  15. コード品質・ツール
  16. 命名規則
  17. デプロイ・運用
  18. チェックリスト

1. 基本方針

  • フレームワークの規約に従う(Convention over Configuration) Laravelの標準的な書き方から外れるほど、新メンバーの学習コストとバグの温床が増えます。
  • Fat Controller を避ける コントローラーはHTTPの入出力に専念させ、ビジネスロジックは別のクラスに置きます。
  • 単一責任の原則(SRP) 1つのクラス・メソッドは1つの責務だけを持たせます。
  • 標準機能を優先する 独自実装の前に、Laravel標準機能や公式パッケージ(Sanctum、Horizon、Pint など)で解決できないか確認します。
  • 型を明示する 引数・戻り値・プロパティに型宣言をつけ、静的解析を効かせます。
declare(strict_types=1);

2. ディレクトリ構成と責務分離

推奨される役割分担

レイヤー置き場所の例責務
Controllerapp/Http/Controllersリクエスト受付・レスポンス返却
Form Requestapp/Http/Requestsバリデーション・入力の認可
Action / Serviceapp/Actions, app/Servicesビジネスロジック
Modelapp/Modelsデータ構造・リレーション・スコープ
Policyapp/Policies認可ロジック
Resourceapp/Http/ResourcesAPIレスポンスの整形
Jobapp/Jobs非同期処理
Event / Listenerapp/Events, app/Listeners疎結合な副作用処理
Enumapp/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 Loadingwith() / 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 HelperIDE補完の強化
./vendor/bin/pint
./vendor/bin/phpstan analyse
php artisan test --parallel

CIでフォーマット・静的解析・テストを必ず通すようにしておくと、品質を継続的に保てます。


16. 命名規則

対象規則良い例悪い例
コントローラー単数形 + ControllerPostControllerPostsController
モデル単数形・パスカルケースUser, OrderItemUsers
テーブル複数形・スネークケースorder_itemsorderItem
ピボットテーブル単数形をアルファベット順にpost_tagtags_posts
カラムスネークケース(モデル名を含めない)title, created_atpost_title
外部キー単数形 + _iduser_idusers_id
ルート名複数形・ドット区切りposts.showshow-post
hasOne / belongsTo単数形author()authors()
hasMany / belongsToMany複数形comments()comment()
メソッドキャメルケースgetActiveUsers()get_active_users()
変数キャメルケース$activeUsers$active_users
Bladeビューケバブケースshow-filtered.blade.phpshowFiltered.blade.php
設定キースネークケースconfig('app.timezone')config('app.timeZone')
Form Request動詞 + モデル + RequestStorePostRequestPostRequest
Job動詞で始めるSendWelcomeEmailWelcomeEmailJob
Event過去形OrderPlacedPlaceOrder

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 で脆弱性がない
  • マイグレーションのロールバックが可能
  • キャッシュコマンドを実行する手順がデプロイに含まれている

参考リンク

0件のページ