# Eloquent ORM:Laravel のためのパターンと最適化 > 高度なパターンと最適化テクニックで Eloquent ORM を使いこなします。Laravel アプリケーションのためのイーガーローディング、クエリスコープ、アクセサ、ミューテータ、パフォーマンス。 - Published: 2026-02-10 - Updated: 2026-04-28 - Author: SharpSkill - Tags: laravel, eloquent, php, orm, database - Reading time: 18 min --- Eloquent ORM はデータベースとのやり取りを流麗で表現力豊かな操作に変換します。エレガントな構文に加えて、高度なパターンと最適化テクニックを習得することが、本番環境における Laravel アプリケーションのパフォーマンスを左右します。 > **パフォーマンス第一** > > N+1 問題は Eloquent アプリケーションにおける遅さの主な原因です。最適化されていないリレーションは、レコードごとに追加の SQL クエリを発生させます。 ## イーガーローディングで N+1 問題を解決する N+1 問題は、コレクションを反復処理するたびにリレーションを読み込むための追加クエリが発行されると発生します。100 件の記事と著者で言えば、最適化された 1 件のクエリではなく 101 件のクエリになります。 イーガーローディングはすべてのリレーションを最大 1〜2 件のクエリで取得し、応答時間を劇的に短縮します。 ```php // app/Http/Controllers/ArticleController.php // Demonstration of N+1 problem and its solution namespace App\Http\Controllers; use App\Models\Article; use Illuminate\Http\Request; class ArticleController extends Controller { // ❌ N+1 problem: 1 articles query + N author queries public function indexWithProblem() { $articles = Article::all(); // 1 query foreach ($articles as $article) { echo $article->author->name; // N additional queries } } // ✅ Eager loading: 2 queries maximum public function indexOptimized() { $articles = Article::with('author')->get(); // 2 queries total foreach ($articles as $article) { echo $article->author->name; // No additional queries } } // ✅ Nested eager loading for multiple relationships public function indexWithNestedRelations() { // Loads articles → authors → profiles + articles → comments → users $articles = Article::with([ 'author.profile', 'comments.user' ])->get(); return view('articles.index', compact('articles')); } } ``` `with()` によるイーガーローディングはニーズを先回りしてデータを事前に読み込みます。大規模コレクションではパフォーマンス差が劇的になります。 ## 条件付き・制約付きイーガーローディング 大きなリレーションは部分的な読み込みが必要になる場合があります。イーガーローディングへの制約は取得するデータを限定し、同時に N+1 を回避します。 ```php // app/Http/Controllers/UserController.php // Eager loading with constraints to optimize queries namespace App\Http\Controllers; use App\Models\User; use Illuminate\Database\Eloquent\Builder; class UserController extends Controller { public function showWithRecentOrders(int $id) { // Load only the 5 most recent paid orders $user = User::with(['orders' => function (Builder $query) { $query->where('status', 'paid') ->orderByDesc('created_at') ->limit(5); }])->findOrFail($id); return view('users.show', compact('user')); } public function indexActiveWithStats() { // Conditional eager loading with withCount $users = User::query() ->where('active', true) ->with(['profile', 'subscription']) ->withCount(['orders', 'reviews']) // Adds orders_count and reviews_count ->withSum('orders', 'total') // Adds orders_sum_total ->get(); return view('users.index', compact('users')); } public function showWithConditionalRelation(int $id) { // Load relationship only if user is premium $user = User::findOrFail($id); $user->loadMissing( $user->isPremium() ? ['premiumFeatures', 'analytics'] : [] ); return view('users.show', compact('user')); } } ``` `withCount()` と `withSum()` メソッドは、コレクション全体を読み込まずに集計を追加するため、ダッシュボードの統計情報に最適です。 ## 再利用可能なクエリのためのクエリスコープ クエリスコープはフィルタリングロジックをモデル内にカプセル化します。ローカルスコープは柔軟性をもたらし、グローバルスコープはすべてのクエリに自動的に適用されます。 > **命名規則** > > ローカルスコープはモデルでは `scope` プレフィックスを使用しますが、呼び出し時にはそれを付けません。`scopeActive()` は `User::active()` になります。 ```php // app/Models/Article.php // Local and global scopes to encapsulate business logic namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Attributes\ScopedBy; #[ScopedBy([PublishedScope::class])] // Global scope via PHP 8 attribute class Article extends Model { // Simple local scope: Article::published() public function scopePublished(Builder $query): Builder { return $query->whereNotNull('published_at') ->where('published_at', '<=', now()); } // Local scope with parameter: Article::byCategory('tech') public function scopeByCategory(Builder $query, string $category): Builder { return $query->where('category', $category); } // Local scope with optional parameter public function scopePopular(Builder $query, int $minViews = 1000): Builder { return $query->where('views_count', '>=', $minViews) ->orderByDesc('views_count'); } // Dynamic scope for flexible search public function scopeSearch(Builder $query, ?string $term): Builder { if (empty($term)) { return $query; } return $query->where(function (Builder $q) use ($term) { $q->where('title', 'like', "%{$term}%") ->orWhere('content', 'like', "%{$term}%") ->orWhereHas('tags', fn($t) => $t->where('name', 'like', "%{$term}%")); }); } } ``` ```php // app/Models/Scopes/PublishedScope.php // Reusable global scope across models namespace App\Models\Scopes; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; class PublishedScope implements Scope { public function apply(Builder $builder, Model $model): void { // Automatically applied to all Article queries $builder->whereNotNull('published_at') ->where('published_at', '<=', now()); } } ``` ```php // Using scopes in a controller $articles = Article::query() ->byCategory('technology') ->popular(500) ->search($request->input('q')) ->with('author') ->paginate(20); // Disable a global scope temporarily $allArticles = Article::withoutGlobalScope(PublishedScope::class)->get(); ``` スコープを連結することで、ビジネスロジックをモデルに集約しつつ、可読性と保守性に優れたクエリが生まれます。 ## Attribute によるアクセサとミューテータ Laravel 9+ では、`Attribute` クラスを通じてアクセサとミューテータの統一された構文が導入されました。このモダンなアプローチは `get*Attribute` および `set*Attribute` メソッドを置き換えます。 ```php // app/Models/User.php // Modern accessors and mutators with the Attribute class namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; class User extends Model { protected $fillable = ['first_name', 'last_name', 'email', 'password']; // Accessor: generates a virtual attribute from other fields protected function fullName(): Attribute { return Attribute::make( get: fn () => "{$this->first_name} {$this->last_name}", ); } // Mutator: transforms value before saving protected function password(): Attribute { return Attribute::make( set: fn (string $value) => Hash::make($value), ); } // Combined accessor + mutator protected function email(): Attribute { return Attribute::make( get: fn (string $value) => Str::lower($value), set: fn (string $value) => Str::lower(trim($value)), ); } // Cached accessor to avoid recalculations protected function initials(): Attribute { return Attribute::make( get: fn () => Str::upper( Str::substr($this->first_name, 0, 1) . Str::substr($this->last_name, 0, 1) ), )->shouldCache(); // Caches result during request } // Computed attribute based on a relationship protected function ordersTotal(): Attribute { return Attribute::make( get: fn () => $this->orders->sum('total'), ); } } ``` ```php // Transparent usage of accessors and mutators $user = new User(); $user->first_name = 'John'; $user->last_name = 'Doe'; $user->email = ' JOHN@EXAMPLE.COM '; // Automatically normalized $user->password = 'secret123'; // Automatically hashed $user->save(); echo $user->full_name; // "John Doe" echo $user->initials; // "JD" echo $user->email; // "john@example.com" ``` `shouldCache()` メソッドは、同一モデルでの複数回の再計算を回避することで、コストの高いアクセサを最適化します。 ## 複雑な型のためのカスタムキャスト キャストは PHP とデータベース間の値を自動変換します。カスタムキャストはビジネス型のシリアライズロジックをカプセル化します。 ```php // app/Casts/MoneyCast.php // Custom cast for handling monetary amounts namespace App\Casts; use App\ValueObjects\Money; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; class MoneyCast implements CastsAttributes { public function __construct( protected string $currency = 'USD' ) {} // DB → PHP conversion: cents to Money object public function get(Model $model, string $key, mixed $value, array $attributes): ?Money { if ($value === null) { return null; } return new Money( amount: (int) $value, currency: $this->currency ); } // PHP → DB conversion: Money object to cents public function set(Model $model, string $key, mixed $value, array $attributes): ?int { if ($value === null) { return null; } if ($value instanceof Money) { return $value->getAmountInCents(); } if (is_numeric($value)) { return (int) ($value * 100); } throw new InvalidArgumentException('Value must be Money instance or numeric'); } } ``` ```php // app/ValueObjects/Money.php // Immutable Value Object representing amounts namespace App\ValueObjects; use JsonSerializable; final readonly class Money implements JsonSerializable { public function __construct( private int $amount, // Stored in cents private string $currency ) {} public function getAmountInCents(): int { return $this->amount; } public function getAmountInUnits(): float { return $this->amount / 100; } public function format(): string { return number_format($this->getAmountInUnits(), 2) . ' ' . $this->currency; } public function add(Money $other): self { return new self($this->amount + $other->amount, $this->currency); } public function jsonSerialize(): array { return [ 'amount' => $this->getAmountInUnits(), 'currency' => $this->currency, ]; } } ``` ```php // app/Models/Order.php // Using the custom cast namespace App\Models; use App\Casts\MoneyCast; use Illuminate\Database\Eloquent\Model; class Order extends Model { protected function casts(): array { return [ 'total' => MoneyCast::class, // USD by default 'shipping_cost' => MoneyCast::class . ':USD', 'tax_amount' => MoneyCast::class . ':USD', 'paid_at' => 'datetime', 'metadata' => 'array', ]; } } ``` ```php // Natural usage with the cast $order = Order::find(1); echo $order->total->format(); // "149.99 USD" echo $order->total->getAmountInCents(); // 14999 $order->total = 199.99; // Automatically converted $order->save(); // Stored as 19999 in DB ``` Value Object とカスタムキャストを組み合わせることで、エレガントな API を保ちつつビジネスデータの整合性が保証されます。 ## 大規模クエリの最適化 数百万件のレコードに対する操作では、メモリ枯渇を避けるために特定のテクニックが必要です。チャンキングとカーソルはデータをバッチで処理します。 > **メモリに注意** > > `Model::all()` はすべてのレコードをメモリに読み込みます。10 万行のテーブルでは数ギガバイトの RAM を消費し、アプリケーションをクラッシュさせる可能性があります。 ```php // app/Console/Commands/ProcessUsersCommand.php // Batch processing techniques for large tables namespace App\Console\Commands; use App\Models\User; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; class ProcessUsersCommand extends Command { protected $signature = 'users:process'; public function handle(): int { // ✅ Chunk: processes in batches of 1000, reloads from DB User::query() ->where('needs_processing', true) ->chunk(1000, function ($users) { foreach ($users as $user) { $user->processAccount(); } }); // ✅ Chunk with updates: avoids infinite loop during modifications User::query() ->where('status', 'pending') ->chunkById(1000, function ($users) { foreach ($users as $user) { $user->update(['status' => 'processed']); } }); // ✅ Lazy collection: single record in memory at a time foreach (User::lazy(1000) as $user) { $user->sendNewsletter(); } // ✅ Cursor: for read-only operations, minimal memory foreach (User::cursor() as $user) { $this->info("Processing: {$user->email}"); } // ✅ Mass update without Eloquent: maximum performance User::query() ->where('last_login_at', '<', now()->subYear()) ->update(['status' => 'inactive']); // ✅ Optimized mass deletion User::query() ->where('deleted_at', '<', now()->subMonths(6)) ->forceDelete(); return self::SUCCESS; } } ``` `chunk()`、`lazy()`、`cursor()` の選択はユースケースによります。変更には `chunk()`、中間処理には `lazy()`、最小限のメモリでの単純な読み取りには `cursor()` が適しています。 ## 高度なポリモーフィックリレーション ポリモーフィックリレーションでは、1 つのモデルが単一のリレーションを介して複数の異なるモデル型に属することができます。この柔軟性はコメント、タグ、添付ファイルなどに最適です。 ```php // app/Models/Comment.php // Model with inverse polymorphic relationship namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Comment extends Model { protected $fillable = ['body', 'user_id']; // A comment can belong to Article, Video, or any other model public function commentable(): MorphTo { return $this->morphTo(); } public function user() { return $this->belongsTo(User::class); } } ``` ```php // app/Models/Article.php // Parent model with polymorphic relationship namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; class Article extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } ``` ```php // app/Models/Video.php // Another parent model using the same relationship namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; class Video extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } ``` ```php // Migration for polymorphic comments table // database/migrations/2026_01_15_create_comments_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('comments', function (Blueprint $table) { $table->id(); $table->text('body'); $table->foreignId('user_id')->constrained(); $table->morphs('commentable'); // Creates commentable_type and commentable_id $table->timestamps(); // Composite index for polymorphic queries $table->index(['commentable_type', 'commentable_id']); }); } }; ``` ```php // Using polymorphic relationships $article = Article::find(1); $article->comments()->create([ 'body' => 'Excellent article!', 'user_id' => auth()->id(), ]); $video = Video::find(1); $video->comments()->create([ 'body' => 'Very instructive video', 'user_id' => auth()->id(), ]); // Retrieve parent from comment $comment = Comment::with('commentable')->find(1); echo get_class($comment->commentable); // App\Models\Article or App\Models\Video ``` ポリモーフィックリレーションはテーブルの重複を避け、横断的な機能のロジックを集約します。 ## 再利用可能なロジックのためのトレイトとオブザーバ トレイトはモデル間で再利用可能な振る舞いをカプセル化します。オブザーバはライフサイクルイベントへのフックを集約します。 ```php // app/Models/Concerns/HasSlug.php // Trait for automatic slug generation namespace App\Models\Concerns; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; trait HasSlug { public static function bootHasSlug(): void { static::creating(function (Model $model) { if (empty($model->slug)) { $model->slug = $model->generateUniqueSlug(); } }); } protected function generateUniqueSlug(): string { $slug = Str::slug($this->getSlugSource()); $originalSlug = $slug; $counter = 1; // Check uniqueness and add suffix if needed while (static::where('slug', $slug)->exists()) { $slug = "{$originalSlug}-{$counter}"; $counter++; } return $slug; } // Can be overridden in the model protected function getSlugSource(): string { return $this->title ?? $this->name; } } ``` ```php // app/Models/Concerns/HasUuid.php // Trait for using UUIDs as primary key namespace App\Models\Concerns; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; trait HasUuid { public static function bootHasUuid(): void { static::creating(function (Model $model) { if (empty($model->{$model->getKeyName()})) { $model->{$model->getKeyName()} = (string) Str::uuid(); } }); } public function getIncrementing(): bool { return false; } public function getKeyType(): string { return 'string'; } } ``` ```php // app/Observers/ArticleObserver.php // Observer to centralize hooks on Article model namespace App\Observers; use App\Models\Article; use App\Jobs\NotifySubscribersJob; use App\Services\SearchIndexService; use Illuminate\Support\Facades\Cache; class ArticleObserver { public function __construct( private SearchIndexService $searchIndex ) {} public function created(Article $article): void { // Invalidate recent articles cache Cache::tags(['articles', 'recent'])->flush(); // Index for search $this->searchIndex->index($article); } public function updated(Article $article): void { // Update search index $this->searchIndex->update($article); // Notify subscribers if article was just published if ($article->wasChanged('published_at') && $article->published_at !== null) { NotifySubscribersJob::dispatch($article); } Cache::tags(['articles'])->flush(); } public function deleted(Article $article): void { $this->searchIndex->remove($article); Cache::tags(['articles'])->flush(); } // Prevent deletion if article has comments public function deleting(Article $article): bool { if ($article->comments()->exists()) { return false; // Cancel deletion } return true; } } ``` ```php // app/Models/Article.php // Model using traits and observer namespace App\Models; use App\Models\Concerns\HasSlug; use App\Models\Concerns\HasUuid; use App\Observers\ArticleObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Model; #[ObservedBy(ArticleObserver::class)] class Article extends Model { use HasSlug, HasUuid; protected $fillable = ['title', 'content', 'published_at']; } ``` トレイトの `boot*` メソッドはモデルの初期化中に自動的に実行され、透過的な統合を可能にします。 ## まとめ Eloquent ORM を使いこなすには、基盤となる仕組みを理解し、適切なパターンを適用することが重要です。紹介したテクニックは、素朴なクエリを高性能で保守しやすいコードへと変えてくれます。 **Eloquent 最適化チェックリスト:** ✅ 表示されるリレーションには `with()` を体系的に使用する ✅ コレクションを読み込んでカウントする代わりに `withCount()` を適用する ✅ フィルタリングロジックをクエリスコープにカプセル化する ✅ ビュー内での繰り返し計算よりもアクセサを優先する ✅ ビジネス Value Object 用にカスタムキャストを実装する ✅ 大規模テーブルへの操作には `chunk()` または `lazy()` を使用する ✅ サイドエフェクトをオブザーバに集約する ✅ 共通の振る舞いをトレイトに抽出する `php artisan telescope` や `laravel-debugbar` のツールを使えば、生成された SQL クエリを可視化し、欠けている最適化を特定できます。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/laravel/eloquent-orm-patterns-optimizations