6.9 Relaciones de Paso (HasOneThrough / HasManyThrough) en Laravel

6.9.1 Introducción

En Laravel existen relaciones que permiten acceder a un modelo a través de otro, aunque no exista una relación directa entre ellos. Estas se conocen como relaciones de paso o relaciones "through".

Laravel ofrece dos tipos:

  • hasOneThrough: una relación uno a uno a través de otro modelo.
  • hasManyThrough: una relación uno a muchos a través de otro modelo.

Estas relaciones son útiles cuando:

  • Queremos acceder a un modelo que está relacionado con otro modelo intermedio.
  • Evitamos consultas adicionales usando with() y definiciones en los modelos.

En este tema veremos ambos casos, reutilizando ejemplos anteriores.


6.9.2 Ejemplo 1: hasOneThrough (Usuario → Teléfono → SIMCard)

En este ejemplo partiremos de la solución del tema 12, donde un usuario tiene un teléfono y un telefono pertenece a un usuario. Ahora le añadiremos la tabla sim_cards (tarjetas SIM) y la relación entre teléfonos y tarjetas SIM.

6.9.2.1 Diagrama conceptual

erDiagram
  User ||--|| Phone : has
  Phone ||--|| SIMCard : has
  User ||--|| SIMCard : hasOneThrough

6.9.2.2 Paso 1: Crear el modelo SIMCard

php artisan make:model Simcard -m

Migración:

Ahora con la migraciòn crearemos la tabla sim_cards:

Migración SIMCards
1
2
3
4
5
6
Schema::create('s_i_m_cards', function (Blueprint $table) {
            $table->id();
            $table->string('serial_number')->unique();
            $table->string('provider');
            $table->timestamps();
        });

6.9.2.3 Paso 2: Modificar el modelo Phone

Añadir la clave foránea a la tabla phones:

Añadir sim_card_id a Phone

1
2
3
Schema::table('phones', function (Blueprint $table) {
    $table->foreignId('sim_card_id')->nullable()->constrained('s_i_m_cards')->onDelete('set null');
});

Modelo Phone:

public function simCard()
{
    return $this->belongsTo(SIMCard::class);
}

Modelo SIMCard:

public function phone()
{
    return $this->hasOne(Phone::class);
}

6.9.2.4 Paso 3: Definir relación de paso en User

public function simCard()
{
    return $this->hasOneThrough(
        SIMCard::class,
        Phone::class,
        'user_id',       // Foreign key on phones
        'id',            // Foreign key on sim_cards (default is id)
        'id',            // Local key on users
        'sim_card_id'    // Local key on phones
    );
}

Ahora vamos a crear un seeder para añadir datos a la tabla sim_cards y phones:

php artisan make:seeder SIMCardSeeder
SIMCardSeeder
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public function run(): void
    {
        SIMCard::Create([
            'serial_number' => '1234567890',
            'provider' => 'Verizon',
        ]);
        SIMCard::Create([
            'serial_number' => '1234567891',
            'provider' => 'Verizon',

        ]);
        SIMCard::Create([
            'serial_number' => '1234567892',
            'provider' => 'Verizon',
        ]);
    }

Modificamos el seederde Phone para añadir la relación con SIMCard:

PhoneSeeder
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 public function run(): void
    {
        Phone::create([
            'prefix' => '34',
            'number' => '1234567890',
            'user_id' => 1,
            'sim_card_id' => 1,
        ]);

        Phone::create([
            'prefix' => '34',
            'number' => '0987654321',
            'user_id' => 2,
            'sim_card_id' => 2,
        ]);

        Phone::create([
            'prefix' => '34',
            'number' => '1122334455',
            'user_id' => 3,
            'sim_card_id' => 3,
        ]);
    }   

Orden de las migraciones

Además fijate en el orden de ejecución de los seeders. Si es necesario modifica el nombre de la migración de los teléfonos (phones) para que se ejecute después de la migración de las tarjetas SIM (sim_cards). Puedes hacerlo renombrando el archivo de migración o modificando la fecha en el nombre del archivo.

No olvides añadir el seeder al DatabaseSeeder

Además fijate en el orden de ejecución de los seeders. Las tarjetas se tienen que crear antes que los teléfonos. Por lo que el DatabaseSeeder debería quedar así:

1
2
3
4
5
$this->call([
    UserSeeder::class,
    SIMCardSeeder::class,
    PhoneSeeder::class,
]);

Si has cambiado el nombre de alguna migración tendrás que modificar la tabla de migraciones manualmente para que no falle al ejecutar las migraciones. O eliminar manualmente todas las tablas de la base de datos y volver a crearla.

Ahora ejecutamos las migraciones y nos aseguramos que se han creado las tablas y los datos de prueba:

php artisan migrate:fresh --seed

Con esto podemos acceder directamente:

$user = User::with('simCard')->find(1);
echo $user->simCard->serial_number;

Modificación de la vista:

Ahora modificamos la vista que muestra los datos del usuario para incluir el número de serie de la tarjeta SIM:

@foreach ($users as $user)
    <div>
        <h2>{{ $user->name }}</h2>
        <p>Teléfono: {{ $user->phone->number }}</p>
        <p>Tarjeta SIM: {{ $user->simCard->serial_number }}</p>
    </div>
@endforeach

Una vez modificada la vista, podemos ver el número de serie de la tarjeta SIM junto al número de teléfono del usuario.

http://localhost:8000/users

6.9.3 Ejemplo 2: hasManyThrough (Usuario → Teléfonos → SIMCards)

6.9.3.1 Diagrama conceptual

erDiagram
  User ||--o{ Phone : has
  Phone ||--|| SIMCard : has
  User ||--o{ SIMCard : hasManyThrough

Este ejemplo parte del proyecto donde un usuario tiene muchos teléfonos (relación uno a muchos). Cada teléfono tiene una tarjeta SIM.

6.9.3.2 Paso 1: Asegurarnos que la tabla phones tiene sim_card_id

Ya visto en el ejemplo anterior. Si no lo tiene, aplicarlo.

6.9.3.3 Paso 2: Relación de paso en el modelo User

Modificamos las propiedades del modelo User para incluir la relación `hasMany para los teléfonos y la relación hasManyThrough para las tarjetas SIM:

public function phones()
{
    return $this->hasMany(Phone::class);
}

public function simCards()
{
    return $this->hasManyThrough(
        SIMCard::class,
        Phone::class,
        'user_id',        // Foreign key on phones
        'id',             // Foreign key on sim_cards (default is id)
        'id',             // Local key on users
        'sim_card_id'     // Local key on phones
    );
}

En el controlador UserController, debemos modificar la lacy eager loading para incluir las tarjetas los teléfonos (ojo en plural), ahora la relación con phones es uno a muchos.

public function index()
{
    $users = User::with(['phones'])->get();
    return view('users.index', compact('users'));
}
Para acceder a las sims de un usuario directamente utilizando HasManythrough, podemos hacer lo siguiente:

$users = User::with('simCards')->find(1);
foreach ($user->simCards as $sim) {
    echo $sim->serial_number;
}

Vamos a modificar nuestra vista para que muestre las SimCards de cada usuario. En el primer bucle seguimos las relaciones de la base de datos, un usuario tiene teléfonos y cada teléfono tiene una tarjeta SIM. En el segundo bucle accedemos a las tarjetas SIM de cada usuario a través de la relación hasManyThrough.

@foreach ($users as $user)
    <div>
        <h2>{{ $user->name }}</h2>
        <p>Teléfonos:</p>
        <ul>
            @foreach ($user->phones as $phone)
                <li>{{ $phone->number }} ({{ $phone->simCard->serial_number }})</li>
            @endforeach
        </ul>
        <p>Tarjetas SIM:</p>
        <ul>
            @foreach ($user->simCards as $simCard)
                <li>{{ $simCard->serial_number }} ({{ $simCard->provider }})</li>
            @endforeach
        </ul>
    </div>

6.9.4 Conclusiones

  • Las relaciones hasOneThrough y hasManyThrough permiten acceder a modelos intermedios sin necesidad de definir manualmente la lógica.
  • Requieren entender bien la estructura de claves foráneas.
  • Pueden mejorar el rendimiento al evitar consultas adicionales si se usan con with().
  • Son ideales cuando el modelo final depende de una cadena de relaciones lógica.