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:

belongsTo va 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 de withCount().
  • Olvidar withPivot() y no poder acceder a los datos de la tabla intermedia.
  • attach() cuando querías sync(), 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.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp