Curso de Laravel
Relaciones en Eloquent: hasMany, belongsTo y belongsToMany
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Cerramos el módulo de consultas con las relaciones de Eloquent.
Esta es, en mi opinión, la lección más importante del curso. Consultar una tabla suelta lo hace cualquier ORM; recorrer relaciones como si fueran propiedades de un objeto es lo que hace que Eloquent valga la pena.
¡Empecemos!
El punto de partida
Las relaciones de tu base de datos —las que diseñaste en el diagrama entidad-relación— se declaran en los modelos como métodos.
Nuestro sistema tiene esta estructura:
especialidades ──< doctores ──< citas >── pacientes
│
└──< recetas
Vamos relación por relación.
Uno a muchos: hasMany y belongsTo
Un doctor tiene muchas citas; una cita pertenece a un doctor.
En el lado «uno»:
// app/Models/Doctor.php
public function citas(): HasMany
{
return $this->hasMany(Cita::class);
}
En el lado «muchos», donde vive la clave foránea:
// app/Models/Cita.php
public function doctor(): BelongsTo
{
return $this->belongsTo(Doctor::class);
}
Y al usarlas:
$doctor->citas; // colección de citas
$cita->doctor; // un objeto Doctor
$cita->doctor->nombre;
El nombre del método en plural o singular según lo que devuelva. No es capricho: es lo que hace legible el código que lo usa.
Eloquent deduce que la clave foránea es doctor_id. Si la tuya se llama distinto:
return $this->belongsTo(Doctor::class, 'id_doctor');
Dónde va cada uno
La regla que resuelve el 90 % de las dudas:
belongsTova siempre en el modelo cuya tabla tiene la columna_id.
Si citas tiene doctor_id, entonces Cita tiene belongsTo(Doctor::class).
Uno a uno: hasOne
Una cita tiene una receta.
// En Cita
public function receta(): HasOne
{
return $this->hasOne(Receta::class);
}
// En Receta
public function cita(): BelongsTo
{
return $this->belongsTo(Cita::class);
}
Es idéntico a hasMany salvo que devuelve un objeto en lugar de una colección. La clave foránea sigue estando en el mismo lado.
Muchos a muchos: belongsToMany
Una cita puede incluir varios estudios médicos, y un estudio se realiza en muchas citas.
// En Cita
public function estudios(): BelongsToMany
{
return $this->belongsToMany(Estudio::class);
}
// En Estudio
public function citas(): BelongsToMany
{
return $this->belongsToMany(Cita::class);
}
En ambos lados es belongsToMany, porque la relación es simétrica.
Eloquent busca la tabla pivote con los dos nombres en singular y orden alfabético: cita_estudio. Si la tuya se llama distinto:
return $this->belongsToMany(Estudio::class, 'estudios_realizados');
Datos en la tabla pivote
Como vimos en el curso de MySQL, la tabla intermedia suele llevar información propia: el resultado, la fecha, el precio del momento.
public function estudios(): BelongsToMany
{
return $this->belongsToMany(Estudio::class)
->withPivot('resultado', 'precio_aplicado')
->withTimestamps();
}
Y al consultarla:
foreach ($cita->estudios as $estudio) {
echo $estudio->nombre;
echo $estudio->pivot->resultado;
echo $estudio->pivot->created_at;
}
Ese ->pivot-> es la forma de llegar a las columnas de la tabla intermedia. Confunde la primera vez y después se vuelve natural.
Gestionar la relación
$cita->estudios()->attach($estudioId);
$cita->estudios()->attach($estudioId, ['resultado' => 'Normal']);
$cita->estudios()->attach([1, 2, 3]);
$cita->estudios()->detach($estudioId);
$cita->estudios()->detach(); // todos
$cita->estudios()->sync([1, 2, 3]); // deja exactamente esos
$cita->estudios()->toggle([1, 2]); // añade los que faltan, quita los que están
sync() es el que más vas a usar en formularios: el usuario marca casillas, tú le pasas los ids seleccionados y Eloquent se encarga de añadir y quitar lo que corresponda.
Relaciones a través de otra
Un caso frecuente: quieres las citas de una especialidad, pero citas no tiene especialidad_id. La relación pasa por doctores.
// En Especialidad
public function citas(): HasManyThrough
{
return $this->hasManyThrough(Cita::class, Doctor::class);
}
$especialidad->citas; // todas las citas de esa especialidad
Se salta el paso intermedio sin que tengas que escribir el join.
Relaciones polimórficas
Cuando un mismo modelo se relaciona con varios. Por ejemplo, comentarios que pueden ir en una cita o en un paciente:
// En Comentario
public function comentable(): MorphTo
{
return $this->morphTo();
}
// En Cita y en Paciente
public function comentarios(): MorphMany
{
return $this->morphMany(Comentario::class, 'comentable');
}
La tabla comentarios lleva dos columnas: comentable_id y comentable_type. Con eso, una sola tabla sirve para ambos casos.
$cita->comentarios;
$paciente->comentarios;
$comentario->comentable; // devuelve la Cita o el Paciente
Es muy útil para etiquetas, archivos adjuntos, comentarios y registros de auditoría.
Carga ansiosa: la parte crítica
Aquí está lo que separa una aplicación rápida de una lenta.
// Mal: una consulta por cada cita
$citas = Cita::all();
foreach ($citas as $cita) {
echo $cita->paciente->nombre;
}
Con 100 citas, eso son 101 consultas. Es el problema N+1, y es la causa número uno de reportes lentos.
// Bien: dos consultas en total
$citas = Cita::with('paciente')->get();
Y con varias relaciones:
Cita::with(['paciente', 'doctor', 'doctor.especialidad'])->get();
// La notación de punto para anidadas
Cita::with('doctor.especialidad')->get();
La regla: si sabes de antemano que vas a usar una relación, cárgala con with() desde el principio.
Filtrar lo que se carga
Doctor::with(['citas' => function ($consulta) {
$consulta->where('estado', 'programada')->orderBy('fecha');
}])->get();
Cargar después
Si ya tienes el objeto y descubres que necesitas la relación:
$doctor->load('citas');
$doctor->loadMissing('especialidad'); // solo si no está cargada
Consultar por la relación
Estos tres métodos resuelven consultas que de otro modo requerirían joins.
has() — que tenga al menos uno:
Doctor::has('citas')->get();
Doctor::has('citas', '>=', 5)->get();
Doctor::doesntHave('citas')->get();
whereHas() — que tenga uno que cumpla una condición:
Doctor::whereHas('citas', function ($consulta) {
$consulta->where('estado', 'atendida')
->whereMonth('fecha', now()->month);
})->get();
withCount() — contar sin cargar:
$doctores = Doctor::withCount('citas')->get();
foreach ($doctores as $doctor) {
echo $doctor->citas_count;
}
Fíjate en el nombre del atributo: la relación más _count. Y lo importante: cuenta en la base de datos, sin traer ni una sola cita.
También se puede contar con condición:
Doctor::withCount([
'citas',
'citas as atendidas_count' => fn ($q) => $q->where('estado', 'atendida'),
])->get();
Y agregar valores:
Doctor::withSum('citas', 'costo')->get(); // citas_sum_costo
Doctor::withAvg('citas', 'costo')->get();
Doctor::withMax('citas', 'fecha')->get();
Crear registros relacionados
// Desde el padre
$doctor->citas()->create([
'paciente_id' => 1,
'fecha' => '2026-06-01',
'hora' => '09:00',
'costo' => 250,
]);
Fíjate en que no pasamos doctor_id: Eloquent lo pone solo. Es más seguro que escribirlo a mano.
// Asociar un objeto existente
$cita->doctor()->associate($doctor);
$cita->save();
$cita->doctor()->dissociate();
Una consulta completa
Juntando todo:
$doctores = Doctor::query()
->with('especialidad')
->withCount(['citas as atendidas' => fn ($q) => $q->where('estado', 'atendida')])
->withSum(['citas as ingresos' => fn ($q) => $q->where('estado', 'atendida')], 'costo')
->whereHas('citas', fn ($q) => $q->whereMonth('fecha', now()->month))
->orderByDesc('ingresos')
->get();
Doctores con al menos una cita este mes, con su especialidad cargada, cuántas atendieron y cuánto facturaron. Todo en una sola consulta, sin escribir un join.
Esto es lo que hace que Eloquent valga la pena.
Errores comunes
- Confundir dónde va
belongsTo. Siempre donde está la columna_id. - Nombre en plural para una relación que devuelve uno, o al revés.
- No usar
with()y provocar un N+1. - Contar con
$doctor->citas->count()en un bucle, en lugar dewithCount(). - Olvidar
withPivot()y no poder acceder a los datos de la tabla intermedia. attach()cuando queríassync(), y duplicar la relación.
Para cerrar
Las relaciones son la traducción de tu diagrama entidad-relación al código, y bien declaradas hacen que consultas que en SQL serían de veinte líneas quepan en tres.
Las tres cosas que quiero que te lleves: belongsTo va donde está la clave foránea, with() siempre que sepas que vas a usar la relación, y withCount() en lugar de contar en un bucle.
Con esto cerramos el módulo de consultas. En la siguiente lección empezamos a construir la aplicación web.
Saludos y éxitos.
Norvic Software
Desarrollamos el software que tu empresa necesita
Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp