# Eloquent ORM: pattern e ottimizzazioni per Laravel > Padroneggia Eloquent ORM con pattern avanzati e tecniche di ottimizzazione. Eager loading, query scope, accessor, mutator e performance per applicazioni Laravel. - Published: 2026-02-10 - Updated: 2026-04-28 - Author: SharpSkill - Tags: laravel, eloquent, php, orm, database - Reading time: 18 min --- Eloquent ORM trasforma le interazioni con il database in operazioni fluide ed espressive. Oltre alla sintassi elegante, padroneggiare i pattern avanzati e le tecniche di ottimizzazione determina le prestazioni delle applicazioni Laravel in produzione. > **Le prestazioni prima di tutto** > > Il problema N+1 è la causa principale di lentezza nelle applicazioni Eloquent. Ogni relazione non ottimizzata genera una query SQL aggiuntiva per record. ## Risolvere il problema N+1 con l'eager loading Il problema N+1 si verifica quando ogni iterazione su una collezione attiva una query aggiuntiva per caricare le relazioni. Con 100 articoli e i loro autori si tratta di 101 query invece di una sola ottimizzata. L'eager loading recupera tutte le relazioni in una o due query al massimo, riducendo drasticamente i tempi di risposta. ```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')); } } ``` L'eager loading con `with()` anticipa le esigenze e carica i dati in anticipo. La differenza di prestazioni diventa spettacolare su grandi collezioni. ## Eager loading condizionale e vincolato Le relazioni voluminose richiedono talvolta un caricamento parziale. I vincoli sull'eager loading limitano i dati recuperati evitando al contempo l'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')); } } ``` I metodi `withCount()` e `withSum()` aggiungono aggregazioni senza caricare le collezioni complete, ideali per le statistiche di una dashboard. ## Query scope per query riutilizzabili I query scope incapsulano la logica di filtraggio all'interno del modello. Gli scope locali offrono flessibilità mentre quelli globali si applicano automaticamente a tutte le query. > **Convenzione di denominazione** > > Gli scope locali utilizzano il prefisso `scope` nel modello ma vengono richiamati senza di esso: `scopeActive()` diventa `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(); ``` Il concatenamento degli scope produce query leggibili e mantenibili centralizzando la logica di business nel modello. ## Accessor e mutator con Attribute Laravel 9+ introduce una sintassi unificata per accessor e mutator tramite la classe `Attribute`. Questo approccio moderno sostituisce i metodi `get*Attribute` e `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" ``` Il metodo `shouldCache()` ottimizza gli accessor costosi evitando ricalcoli multipli sullo stesso modello. ## Cast personalizzati per tipi complessi I cast trasformano automaticamente i valori tra PHP e il database. I cast personalizzati incapsulano la logica di serializzazione per i tipi di business. ```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 ``` I Value Object combinati ai cast personalizzati garantiscono l'integrità dei dati di business mantenendo un'API elegante. ## Ottimizzare le query su larga scala Le operazioni su milioni di record richiedono tecniche specifiche per evitare di esaurire la memoria. Il chunking e i cursori elaborano i dati a lotti. > **Attenzione alla memoria** > > `Model::all()` carica tutti i record in memoria. Su una tabella da 100.000 righe questo può consumare diversi gigabyte di RAM e mandare in crash l'applicazione. ```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; } } ``` La scelta tra `chunk()`, `lazy()` e `cursor()` dipende dal caso d'uso: `chunk()` per le modifiche, `lazy()` per le operazioni intermedie e `cursor()` per la sola lettura con il minimo consumo di memoria. ## Relazioni polimorfiche avanzate Le relazioni polimorfiche permettono a un modello di appartenere a più tipi di modelli diversi tramite un'unica relazione. Questa flessibilità è ideale per commenti, tag o file allegati. ```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 ``` Le relazioni polimorfiche evitano la duplicazione delle tabelle e centralizzano la logica per le funzionalità trasversali. ## Trait e observer per logica riutilizzabile I trait incapsulano comportamenti riutilizzabili tra modelli. Gli observer centralizzano gli hook sugli eventi del ciclo di vita. ```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']; } ``` I metodi `boot*` dei trait vengono eseguiti automaticamente durante l'inizializzazione del modello, consentendo un'integrazione trasparente. ## Conclusione Padroneggiare Eloquent ORM passa per la comprensione dei meccanismi sottostanti e l'applicazione dei pattern adeguati. Le tecniche presentate trasformano query ingenue in codice performante e mantenibile. **Checklist di ottimizzazione Eloquent:** ✅ Utilizzare sistematicamente `with()` per le relazioni mostrate ✅ Applicare `withCount()` invece di caricare le collezioni per contarle ✅ Incapsulare la logica di filtraggio nei query scope ✅ Preferire gli accessor a calcoli ripetuti nelle view ✅ Implementare cast personalizzati per i Value Object di business ✅ Usare `chunk()` o `lazy()` per le operazioni su tabelle grandi ✅ Centralizzare gli effetti collaterali negli observer ✅ Estrarre i comportamenti comuni in trait Gli strumenti `php artisan telescope` o `laravel-debugbar` permettono di visualizzare le query SQL generate e di identificare le ottimizzazioni mancanti. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/it/blog/laravel/eloquent-orm-patterns-optimizations