Eloquent ORM:Laravel のためのパターンと最適化
高度なパターンと最適化テクニックで Eloquent ORM を使いこなします。Laravel アプリケーションのためのイーガーローディング、クエリスコープ、アクセサ、ミューテータ、パフォーマンス。

Eloquent ORM はデータベースとのやり取りを流麗で表現力豊かな操作に変換します。エレガントな構文に加えて、高度なパターンと最適化テクニックを習得することが、本番環境における Laravel アプリケーションのパフォーマンスを左右します。
N+1 問題は Eloquent アプリケーションにおける遅さの主な原因です。最適化されていないリレーションは、レコードごとに追加の SQL クエリを発生させます。
イーガーローディングで N+1 問題を解決する
N+1 問題は、コレクションを反復処理するたびにリレーションを読み込むための追加クエリが発行されると発生します。100 件の記事と著者で言えば、最適化された 1 件のクエリではなく 101 件のクエリになります。
イーガーローディングはすべてのリレーションを最大 1〜2 件のクエリで取得し、応答時間を劇的に短縮します。
// 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 を回避します。
// 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() になります。
// 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}%"));
});
}
}// 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());
}
}// 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();スコープを連結することで、ビジネスロジックをモデルに集約しつつ、可読性と保守性に優れたクエリが生まれます。
Laravelの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
Attribute によるアクセサとミューテータ
Laravel 9+ では、Attribute クラスを通じてアクセサとミューテータの統一された構文が導入されました。このモダンなアプローチは get*Attribute および set*Attribute メソッドを置き換えます。
// 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'),
);
}
}// 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 とデータベース間の値を自動変換します。カスタムキャストはビジネス型のシリアライズロジックをカプセル化します。
// 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');
}
}// 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,
];
}
}// 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',
];
}
}// 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 DBValue Object とカスタムキャストを組み合わせることで、エレガントな API を保ちつつビジネスデータの整合性が保証されます。
大規模クエリの最適化
数百万件のレコードに対する操作では、メモリ枯渇を避けるために特定のテクニックが必要です。チャンキングとカーソルはデータをバッチで処理します。
Model::all() はすべてのレコードをメモリに読み込みます。10 万行のテーブルでは数ギガバイトの RAM を消費し、アプリケーションをクラッシュさせる可能性があります。
// 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 つのモデルが単一のリレーションを介して複数の異なるモデル型に属することができます。この柔軟性はコメント、タグ、添付ファイルなどに最適です。
// 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);
}
}// 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');
}
}// 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');
}
}// 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']);
});
}
};// 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ポリモーフィックリレーションはテーブルの重複を避け、横断的な機能のロジックを集約します。
Laravelの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
再利用可能なロジックのためのトレイトとオブザーバ
トレイトはモデル間で再利用可能な振る舞いをカプセル化します。オブザーバはライフサイクルイベントへのフックを集約します。
// 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;
}
}// 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';
}
}// 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;
}
}// 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 クエリを可視化し、欠けている最適化を特定できます。
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
タグ
共有
関連記事

LaravelとPHPの面接質問:2026年版トップ25
LaravelとPHPの面接で最も頻出する25の質問を解説します。Eloquent ORM、ミドルウェア、キュー、テスト、アーキテクチャパターンについて、詳細な回答とコード例を掲載しています。

Laravel 11:ゼロから完全なアプリケーションを構築する方法
Laravel 11を使用した完全なアプリケーション構築ガイド。認証、REST API、Eloquent ORM、デプロイまで、実践的なコード例とともに解説します。

Laravel Middleware徹底解説:認証、レート制限、カスタムミドルウェアの実装ガイド
Laravelミドルウェアの仕組みを基礎から応用まで解説します。認証ミドルウェア、throttleによるレート制限、カスタムミドルウェアの作成方法、そして本番環境で使える実践的なミドルウェアパターンを具体的なコード例とともに紹介します。