Eloquent ORM: patrones y optimizaciones para Laravel
Domina Eloquent ORM con patrones avanzados y técnicas de optimización. Eager loading, query scopes, accessors, mutators y rendimiento para aplicaciones Laravel.

Eloquent ORM convierte las interacciones con la base de datos en operaciones fluidas y expresivas. Más allá de su sintaxis elegante, dominar los patrones avanzados y las técnicas de optimización determina el rendimiento de las aplicaciones Laravel en producción.
El problema N+1 es la causa principal de lentitud en las aplicaciones Eloquent. Cada relación no optimizada genera una consulta SQL adicional por registro.
Resolver el problema N+1 con eager loading
El problema N+1 aparece cuando cada iteración sobre una colección dispara una consulta adicional para cargar las relaciones. Con 100 artículos y sus autores, son 101 consultas en lugar de una sola optimizada.
El eager loading recupera todas las relaciones en una o dos consultas como máximo, reduciendo drásticamente el tiempo de respuesta.
// 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'));
}
}El eager loading con with() anticipa las necesidades y carga los datos por adelantado. La diferencia de rendimiento se vuelve espectacular en colecciones grandes.
Eager loading condicional y restringido
Las relaciones extensas a veces requieren una carga parcial. Las restricciones sobre el eager loading limitan los datos recuperados evitando al mismo tiempo el 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'));
}
}Los métodos withCount() y withSum() añaden agregaciones sin cargar las colecciones completas, ideales para las estadísticas de un dashboard.
Query scopes para consultas reutilizables
Los query scopes encapsulan la lógica de filtrado dentro del modelo. Los scopes locales aportan flexibilidad mientras que los scopes globales se aplican automáticamente a todas las consultas.
Los scopes locales utilizan el prefijo scope en el modelo pero se invocan sin él: scopeActive() se convierte en 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();El encadenamiento de scopes produce consultas legibles y fáciles de mantener al centralizar la lógica de negocio en el modelo.
¿Listo para aprobar tus entrevistas de Laravel?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Accessors y mutators con Attribute
Laravel 9+ introduce una sintaxis unificada para los accessors y mutators a través de la clase Attribute. Este enfoque moderno reemplaza los métodos get*Attribute y 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"El método shouldCache() optimiza los accessors costosos al evitar múltiples recálculos sobre el mismo modelo.
Casts personalizados para tipos complejos
Los casts transforman automáticamente los valores entre PHP y la base de datos. Los casts personalizados encapsulan la lógica de serialización para tipos de negocio.
// 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 DBLos Value Objects combinados con casts personalizados garantizan la integridad de los datos de negocio manteniendo una API elegante.
Optimizar las consultas a gran escala
Las operaciones sobre millones de registros requieren técnicas específicas para evitar agotar la memoria. El chunking y los cursores procesan los datos por lotes.
Model::all() carga todos los registros en memoria. Sobre una tabla de 100 000 filas, esto puede consumir varios gigabytes de RAM y bloquear la aplicación.
// 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 elección entre chunk(), lazy() y cursor() depende del caso de uso: chunk() para modificaciones, lazy() para operaciones intermedias y cursor() para lectura simple con memoria mínima.
Relaciones polimórficas avanzadas
Las relaciones polimórficas permiten que un modelo pertenezca a varios tipos de modelos diferentes a través de una única relación. Esta flexibilidad es ideal para comentarios, etiquetas o archivos adjuntos.
// 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\VideoLas relaciones polimórficas evitan la duplicación de tablas y centralizan la lógica para funcionalidades transversales.
¿Listo para aprobar tus entrevistas de Laravel?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Traits y observers para lógica reutilizable
Los traits encapsulan comportamientos reutilizables entre modelos. Los observers centralizan los hooks sobre los eventos del ciclo de vida.
// 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'];
}Los métodos boot* de los traits se ejecutan automáticamente durante la inicialización del modelo, lo que permite una integración transparente.
Conclusión
Dominar Eloquent ORM se basa en comprender los mecanismos subyacentes y aplicar los patrones adecuados. Las técnicas presentadas convierten consultas ingenuas en código eficiente y fácil de mantener.
Checklist de optimización Eloquent:
✅ Utilizar sistemáticamente with() para las relaciones mostradas
✅ Aplicar withCount() en lugar de cargar las colecciones para contar
✅ Encapsular la lógica de filtrado en query scopes
✅ Preferir los accessors a los cálculos repetidos en las vistas
✅ Implementar casts personalizados para los Value Objects de negocio
✅ Usar chunk() o lazy() para las operaciones sobre tablas grandes
✅ Centralizar los efectos secundarios en los observers
✅ Extraer los comportamientos comunes a traits
Las herramientas php artisan telescope o laravel-debugbar permiten visualizar las consultas SQL generadas e identificar las optimizaciones que faltan.
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
Etiquetas
Compartir
Artículos relacionados

Preguntas de Entrevista sobre Laravel y PHP: Las 25 Principales en 2026
Las 25 preguntas mas comunes en entrevistas sobre Laravel y PHP. Eloquent ORM, middleware, artisan, colas, tests y arquitectura con respuestas detalladas y ejemplos de codigo.

Laravel 11: Construir una Aplicacion Completa desde Cero
Guia completa para construir una aplicacion con Laravel 11: autenticacion, API REST, Eloquent ORM y despliegue a produccion. Tutorial practico para desarrolladores principiantes e intermedios.

Laravel Middleware a fondo: Autenticación, Rate Limiting y Middleware personalizado
Guía completa sobre middleware en Laravel con ejemplos prácticos: guards de autenticación, limitación de tasa con throttle, creación de middleware personalizado y patrones avanzados para aplicaciones en producción.