[Guía práctica completa] Diseñar con Laravel Eloquent — Relaciones, Scopes, prevención de N+1, agregación, separación de DTOs/Resources y diseño de modelos mantenible
Lo que aprenderás en este artículo (puntos clave)
- Cómo pasar de “usar Eloquent porque es conveniente” a “usarlo de una forma resistente y bien diseñada”
- Los fundamentos del diseño de relaciones (
belongsTo/hasMany/belongsToMany) y del nombrado - Patrones prácticos para evitar el problema N+1, y cuándo usar
with,withCountyloadMissing - Cómo organizar local scopes, accessors/mutators, casts y value objects
- Cómo evitar Fat Models separando responsabilidades en Service / Action / DTO / API Resource
- Cómo mantener legible la agregación, las condiciones de búsqueda, la paginación y la lógica de actualización
- Patrones de diseño con Eloquent que son fáciles de probar, y cómo se conectan con pantallas de lista/detalle accesibles
¿A quién beneficiará esto?
- Ingenieros Laravel de nivel principiante a intermedio: personas que ya pueden escribir Eloquent, pero están sufriendo a medida que los modelos se vuelven demasiado grandes
- Tech leads: personas que quieren alinear los límites de responsabilidad entre modelos, servicios y API Resources dentro de un equipo
- Personal de QA / mantenimiento: personas que quieren reducir errores causados por “no saber dónde se está formateando algo” en pantallas de lista y APIs
- Diseñadores / desarrolladores frontend: personas que quieren una base estable que proporcione los datos necesarios para las pantallas con una forma predecible
Nivel de accesibilidad: ★★★★☆
El tema principal de este artículo es el diseño de ORM, pero como la forma en que se presentan listas, vistas de detalle y resultados agregados afecta directamente a la claridad de la UI, esta guía también tiene en cuenta la estructura de encabezados, la visualización de conteos, la expresión de estados sin depender solo del color, y el formateo de datos fácil de interpretar con lectores de pantalla.
1. Introducción: Eloquent es conveniente, pero si escribes solo pensando en la conveniencia, la complejidad crece rápidamente
Laravel Eloquent es muy fácil de escribir, así que al principio es tentador pensar: “Si simplemente lo pongo en el modelo, de alguna manera funcionará”. De hecho, para pantallas pequeñas o APIs, a menudo funciona enseguida. Pero una vez que crece el número de pantallas, se ramifican las condiciones y empiezan a mezclarse agregación, permisos y formato de respuestas API, comienzan a aparecer problemas como los siguientes:
- El modelo se vuelve enorme y ya no sabes dónde está nada
- La vista de lista es rápida, pero la vista de detalle es lenta
- El formato de respuesta de la API está mezclado dentro del modelo
- Las mismas condiciones de búsqueda se copian y pegan en distintos controladores
- El problema N+1 sigue reapareciendo
- Cuando intentas probar, hay demasiados datos previos necesarios y lleva tiempo identificar la causa
Eloquent no es el problema. Al contrario, es poderoso. Lo importante es decidir qué dejar que Eloquent maneje, y qué no dejarle manejar. En este artículo, organizaremos patrones prácticos para un diseño con Eloquent que se mantenga mantenible con el paso del tiempo.
2. Política básica: decide primero qué entra en el modelo y qué no
Si decides tu política primero, las dudas disminuyen.
Cosas que encajan de forma natural en el modelo
- Mapeo a tablas
- Relaciones
- Scopes (condiciones de búsqueda reutilizables)
- Casts
- Pequeñas reglas de dominio
- Comprobaciones de estado (si está publicado, si ha expirado, etc.)
Cosas que es mejor mantener fuera del modelo
- Lógica larga de agregación
- Integraciones con APIs externas
- Envío de correos electrónicos
- Formato de respuesta específico para una sola pantalla
- Procesos de negocio grandes que abarcan múltiples modelos
- Lógica de visualización utilizada solo para una pantalla o API concreta
En otras palabras, el modelo se vuelve más estable si se mantiene centrado en “los datos y las pequeñas reglas a su alrededor”. Los procesos pesados y el formateo específico de presentación son más fáciles de leer si se trasladan a Services / Actions / DTOs / Resources.
3. Diseño de relaciones: cuanto más claros sean los nombres y direcciones, más fácil será el mantenimiento después
Como ejemplo, considera User, Post, Comment y Tag en un sistema de blog.
3.1 Relaciones básicas
// app/Models/Post.php
class Post extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function tags()
{
return $this->belongsToMany(Tag::class);
}
}
// app/Models/User.php
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class);
}
}
3.2 Bases del nombrado
- Singular:
belongsTo,hasOne - Plural:
hasMany,belongsToMany - Usa nombres con significado claro
user()es fácil de entender- Si
owner()encaja mejor con el contexto, entoncesowner()también está bien
- Si te desvías de las convenciones de nombres de tabla o claves foráneas, hazlo explícito
public function owner()
{
return $this->belongsTo(User::class, 'owner_id');
}
Los nombres de las relaciones también afectan la legibilidad de pantallas y APIs. Prioriza nombres cuyo significado siga siendo claro al releerlos más tarde.
4. El problema N+1: una habilidad práctica de Eloquent que conviene desarrollar pronto
El problema de rendimiento más común en Eloquent es N+1.
4.1 Ejemplo incorrecto
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->user->name;
}
Este código obtiene primero la lista de posts y luego vuelve a obtener user para cada post, así que el número de consultas SQL sigue creciendo.
4.2 Ejemplo correcto
$posts = Post::with('user')->latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->user->name;
}
4.3 Si solo quieres conteos, usa withCount
$posts = Post::with('user')
->withCount('comments')
->latest()
->paginate(20);
4.4 Reduce a solo las columnas necesarias para mostrar
$posts = Post::query()
->select(['id', 'user_id', 'title', 'published_at'])
->with(['user:id,name'])
->withCount('comments')
->latest()
->paginate(20);
Puntos clave
- En vistas de lista, casi nunca necesitas todas las columnas
- Si también reduces los datos relacionados a solo lo necesario, como
id,name, se vuelve más ligero - La prevención de N+1 no debería ser “algo que optimizar más adelante”, sino un hábito que desarrolles al escribir consultas de listas
5. Cómo usar with, load y loadMissing
Hay varios métodos similares, así que ayuda organizar en qué se diferencian.
with
Carga relaciones junto con la consulta inicial
Post::with('user')->get();
load
Carga relaciones adicionales para un modelo ya recuperado
$post->load('comments');
loadMissing
Carga solo si aún no se ha cargado
$post->loadMissing('user');
En la práctica:
- Usa
withpara la primera consulta en vistas de lista/detalle - Usa
loadcuando quieras añadir relaciones de forma condicional - Usa
loadMissingen código compartido o reutilizable
Este suele ser el patrón más manejable.
6. Scopes: mueve las condiciones de búsqueda fuera de los controladores
Si las mismas condiciones where están dispersas por controladores y APIs, se vuelve fácil pasar por alto cambios. Ahí es donde ayudan los local scopes.
6.1 Ejemplo: publicaciones publicadas
// app/Models/Post.php
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
Se usa así:
$posts = Post::published()->latest()->paginate(20);
6.2 Ejemplo: búsqueda por palabra clave
public function scopeKeyword($query, ?string $keyword)
{
if (!$keyword) {
return $query;
}
return $query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('body', 'like', "%{$keyword}%");
});
}
$posts = Post::published()
->keyword($request->string('q')->toString())
->latest()
->paginate(20);
Puntos clave
- Mantén los scopes centrados en condiciones que se reutilizan
- Si metes cada ordenación compleja específica de pantalla dentro de scopes, pueden volverse más difíciles de leer
- Lo mejor es extraer solo condiciones de uso común a nombres cortos y claros
7. Accessors, mutators y casts: absorbe pequeñas preocupaciones de formato dentro del modelo
7.1 Casts
protected function casts(): array
{
return [
'published_at' => 'datetime',
'is_featured' => 'boolean',
'meta' => 'array',
];
}
Usar casts reduce la necesidad de Carbon::parse() o json_decode() en controladores y vistas, lo que mejora la legibilidad.
7.2 Métodos de comprobación de estado
public function isPublished(): bool
{
return !is_null($this->published_at) && $this->published_at->isPast();
}
Pequeñas comprobaciones de estado como esta encajan de forma natural en el modelo. Evitan reescribir la misma condición en pantallas y APIs.
7.3 No abuses de los accessors
Por ejemplo, devolver una etiqueta de visualización puede ser conveniente.
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->isPublished() ? 'Published' : 'Draft'
);
}
Sin embargo, una vez que los accessors empiezan a devolver HTML o contienen lógica larga de texto, el modelo queda demasiado ligado a preocupaciones de presentación. Es más seguro mover el formato específico de visualización a un ViewModel o Resource.
8. appends, hidden, fillable, guarded: detalles silenciosos, pero importantes
8.1 fillable
protected $fillable = [
'title',
'body',
'published_at',
];
Ser explícito aquí ayuda a proteger frente a riesgos de asignación masiva.
8.2 hidden
Oculta valores que no quieres incluir en arrays o respuestas API.
protected $hidden = [
'password',
'remember_token',
];
8.3 appends
Puedes usar esto para añadir automáticamente valores de accessors a arrays, pero si agregas demasiados, puede volverse pesado. Si cada API necesita una forma diferente, normalmente es más fácil de gestionar mediante Resources.
9. API Resources y DTOs: separa el formato de pantalla/API del modelo
Si sigues agregando “campos solo para esta API” o “formato para esta pantalla” al modelo, sus responsabilidades empiezan a colapsar. Los API Resources y DTOs ayudan aquí.
9.1 Ejemplo de API Resource
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (string) $this->id,
'title' => $this->title,
'author' => [
'id' => (string) $this->user->id,
'name' => $this->user->name,
],
'comments_count' => $this->comments_count,
'status' => $this->isPublished() ? 'published' : 'draft',
'published_at' => optional($this->published_at)?->toIso8601String(),
];
}
}
9.2 Lado del controlador
public function index()
{
$posts = Post::with('user')
->withCount('comments')
->published()
->latest()
->paginate(20);
return PostResource::collection($posts);
}
Con esta estructura, el modelo se mantiene enfocado en los datos y pequeñas reglas, mientras que la forma en que se presenta la API se completa del lado del Resource.
10. Service / Action: mueve fuera del modelo los procesos que abarcan múltiples modelos
Si empiezas a escribir lógica como “al guardar un post, también actualizar tags, enviar notificaciones y escribir un log de auditoría” directamente dentro del modelo, rápidamente se convierte en un Fat Model. Estos procesos son más fáciles de entender si se mueven a un Service o Action.
10.1 Ejemplo: Action para crear un post
// app/Actions/CreatePostAction.php
class CreatePostAction
{
public function execute(User $user, array $data): Post
{
return DB::transaction(function () use ($user, $data) {
$post = $user->posts()->create([
'title' => $data['title'],
'body' => $data['body'],
'published_at' => $data['published_at'] ?? null,
]);
if (!empty($data['tag_ids'])) {
$post->tags()->sync($data['tag_ids']);
}
return $post;
});
}
}
10.2 Controlador
public function store(StorePostRequest $request, CreatePostAction $action)
{
$post = $action->execute($request->user(), $request->validated());
return redirect()->route('posts.show', $post)
->with('status', 'The post has been created.');
}
En lugar de forzar todo dentro del modelo, extraerlo como “la operación de negocio de guardar” también facilita las pruebas.
11. Agregación y reporting: no hagas que el modelo cargue demasiado SQL solo por las pantallas
Los dashboards y las pantallas de lista suelen necesitar agregados. Si empiezas a colocar métodos enormes de agregación en el modelo, las responsabilidades se vuelven poco claras.
11.1 Para agregación ligera, una consulta basta
$total = Order::whereDate('created_at', today())->sum('total_amount');
11.2 Si se vuelve complejo, sepáralo en un Query Service
// app/Services/DashboardStatsService.php
class DashboardStatsService
{
public function todayStats(): array
{
return [
'orders_count' => Order::whereDate('created_at', today())->count(),
'sales_total' => Order::whereDate('created_at', today())->sum('total_amount'),
];
}
}
Mantén el modelo cerca de “la naturaleza de un solo registro” y de “las condiciones reutilizables”, y mueve los agregados a clases separadas para mayor claridad.
12. Cómo escribir Eloquent para pantallas de lista rápidas y comprensibles
En las vistas de lista importan tanto la legibilidad como el rendimiento.
12.1 Ejemplo: lista de posts en admin
$posts = Post::query()
->select(['id', 'user_id', 'title', 'published_at', 'created_at'])
->with(['user:id,name'])
->withCount('comments')
->keyword($request->string('q')->toString())
->latest()
->paginate(20)
->withQueryString();
12.2 Cómo conecta esto con la UI
- Mostrar conteos explícitamente como números
- Mostrar el estado de publicación con texto, no solo con color
- Hacer visibles en pantalla las condiciones de ordenación y filtrado
- Usar
withQueryString()para conservar las condiciones durante la paginación
Si Eloquent prepara el mínimo de datos necesarios para la lista, entonces las condiciones en Blade o frontend se simplifican, y la accesibilidad es más fácil de mantener.
13. Procesamiento de actualizaciones: casos en los que update() por sí solo no basta
Para actualizaciones simples, update() es suficiente.
$post->update($request->validated());
Sin embargo, ten cuidado en casos como estos:
- Necesitas dejar un log de auditoría antes y después de la actualización
- También hay que actualizar tablas relacionadas
- Deben dispararse notificaciones o eventos de forma condicional
- Hace falta una transacción para preservar la consistencia
En esos casos, mover la lógica a un Action es más seguro.
14. Diseño con Eloquent fácil de probar: mantén pequeños los factories y requisitos previos
Cuando el diseño con Eloquent es bueno, las pruebas también se vuelven más fáciles de escribir.
14.1 Ejemplo de prueba para scope
public function test_published_scope_returns_only_published_posts()
{
Post::factory()->create(['published_at' => now()->subDay()]);
Post::factory()->create(['published_at' => null]);
$posts = Post::published()->get();
$this->assertCount(1, $posts);
}
14.2 Ejemplo de prueba para Action
public function test_create_post_action_creates_post_and_syncs_tags()
{
$user = User::factory()->create();
$tags = Tag::factory()->count(2)->create();
$post = app(CreatePostAction::class)->execute($user, [
'title' => 'New Post',
'body' => 'Body',
'tag_ids' => $tags->pluck('id')->all(),
]);
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New Post']);
$this->assertCount(2, $post->tags);
}
Cuando las responsabilidades están separadas, queda mucho más claro qué debe probarse exactamente.
15. Errores comunes y cómo evitarlos
- Escribir llamadas a APIs externas o envío de correos directamente en el modelo
- Evítalo separándolo en Action / Service
- Reintroducir N+1 en las vistas de lista una y otra vez
- Evítalo haciendo de
withun hábito por defecto en listas
- Evítalo haciendo de
- Tener tantos scopes que nadie sabe qué hacen
- Evítalo extrayendo solo condiciones cortas y reutilizables
- Devolver HTML o lógica larga de texto mediante accessors
- Evítalo moviendo el formato de visualización a Resource / ViewModel
- Repartir el formato de arrays de API entre distintos modelos
- Evítalo estandarizando con API Resources
- Dejar
fillableambiguo y mantener riesgos de asignación masiva- Evítalo gestionándolo explícitamente
16. Checklist (para compartir)
Modelo
- [ ] Los nombres de las relaciones coinciden con su significado
- [ ]
fillable/hidden/castsestán organizados - [ ] Los métodos de comprobación de estado son pequeños y fáciles de entender
Rendimiento
- [ ] Las consultas de lista están escritas teniendo en cuenta
with,withCountyselect - [ ] Las pantallas en las que es probable N+1 están identificadas
- [ ] Se usan paginación y
withQueryString()
Separación de responsabilidades
- [ ] Las condiciones reutilizables están agrupadas en scopes
- [ ] Las operaciones grandes de guardado están separadas en Actions / Services
- [ ] El formato de la API lo manejan Resources
- [ ] La agregación se mueve a Query Services o clases dedicadas
Testing
- [ ] Hay pruebas unitarias para scopes
- [ ] Hay pruebas para Actions
- [ ] Los conteos y la estructura en listas/APIs están protegidos por tests de Feature
Accesibilidad
- [ ] Los conteos y estados en listas son comprensibles como texto
- [ ] La representación del estado no depende solo del color
- [ ] La pantalla recibe datos con una granularidad fácil de leer
17. Resumen
Eloquent está en el corazón de la productividad de Laravel. Precisamente por eso, en lugar de escribir todo dentro del modelo, el código es más fácil de mantener con el tiempo si decides qué pertenece al modelo y qué no. Las relaciones, scopes, casts y comprobaciones de estado pueden quedarse en el modelo. Las operaciones grandes de guardado, agregaciones, formato de API e integraciones externas pertenecen a Actions / Services / Resources. Además, simplemente convertir with y withCount en un hábito dentro de las consultas de lista evita N+1 desde el principio y mejora significativamente tanto el rendimiento como la legibilidad. Eloquent es fuerte no cuando se usa “de forma ligera”, sino cuando se usa con las responsabilidades correctas. Incluso en tu proyecto actual, prueba primero reorganizando una pantalla de lista y una operación de guardado.
