kokoshneta's avatar

HasManyThrough when intermediate models BelongsTo final model

The table structure given in the docs for a HasManyThrough relationship between a Source model, an Intermediate model and a Final model have the specific structure that Intermediate->belongsTo(Source) and Final->belongsTo(Intermediate).

I have an existing database where there’s a similar setup, except that in my case, Intermediate->belongsTo(Final), rather than the other way around. In other words, my intermediate table looks like this:

intermediates
    id - integer
    source_id - integer
    final_id - integer

I have all the relationships between the directly related models set up, so this works as expected:

$sources = Source::with('intermediates.finals')->get();

This allows me to access $source->intermediates[$i]->finals, for example, but I would like to be able to access the finals directly through the source model. Since the individual relationships work, I naïvely thought that this would too:

// Source.php
public function finals() {
	return $this->through('intermediates')->has('finals');
}

// Controller
$models = Source::with('finals')->get();

– but it doesn’t. It throws an error: Call to undefined relationship [finals] on model [Source].

Is there a way to make such a HasManyThrough relationship work?

0 likes
9 replies
jaseofspades88's avatar

If the relationship is

Source -> Intermediate -> Final

and your schema is...

sources
- id

intermediates
- id
- source_id

finals
- id
- intermediate_id

then you can do...

//Source

public function finals(): HasManyThrough
{
    return $this->hasManyThrough(Final::class, Intermediate::class);
}
kokoshneta's avatar

@jaseofspades88 Yes, I know. That’s the structure the docs describe. But as I say in the question, that is not the schema I have in my database, so I cannot do that. This is my schema:

sources
- id

intermediates
- id
- source_id
- final_id

finals
- id

I cannot change the structure, since the relationship between the intermediates and the finals is many-to-one; that is, one final can relate to multiple intermediates, but not the other way around.

josecameselle's avatar

In a typical HasManyThrough relationship, the Intermediate model should belong to the Final model, not the other way around.

In your case, since the Intermediate model belongs to the Final model, you can try defining a custom relationship method in your Source model to retrieve the related Final models:

// Source.php
public function finals() {
    return Final::whereHas('intermediates', function ($query) {
        $query->where('source_id', $this->id);
    })->get();
}

This method uses the whereHas method to add a constraint to the query that checks if any related Intermediate models have a source_id that matches the current Source model’s id. The resulting collection of Final models is then returned.

You can then use this relationship like any other Eloquent relationship:

// Controller
$models = Source::with('finals')->get();
kokoshneta's avatar

@joselorenzo_pandago I haven’t tested this, but I don’t think it will work, because the method you define does not return a relationship. I could use it as a plain function ($finals = $model->finals()), but it wouldn’t work as a relationship method.

josecameselle's avatar

@kokoshneta

If you want to define a relationship method that returns a relationship object, you can try using the hasManyThrough method with a custom throughKey parameter:

// Source.php
public function finals() {
    return $this->hasManyThrough(
        Final::class,
        Intermediate::class,
        'source_id', //Foreign key on intermediate table
        'id', //Foreign key on final table
        'id', //Local key on source table
        'final_id' //Local key on intermediate table
    );
}

This should allow you to use the finals relationship like any other Eloquent relationship, including eager loading:

// Controller
$models = Source::with('finals')->get();
kokoshneta's avatar

@joselorenzo_pandago No, the custom keys only allow you to change the names of the keys in the various tables – they won’t have any effect on which tables Eloquent will look for the names.

josecameselle's avatar

@kokoshneta

In your case, since the Intermediate model belongs to the Final model, it is not possible to define a HasManyThrough relationship using the built-in Eloquent methods. You will need to define a custom relationship method to retrieve the related Final models.

One way to do this is by using a combination of the join and select methods to manually construct a query that retrieves the related Final models. Here’s an example of how you can do this:

// Source.php
public function finals() {
    return Final::join('intermediates', 'finals.id', '=', 'intermediates.final_id')
        ->where('intermediates.source_id', $this->id)
        ->select('finals.*')
        ->get();
}

This method joins the finals and intermediates tables on the final_id and id columns, respectively, and adds a constraint to the query that checks if any related Intermediate models have a source_id that matches the current Source model’s id. The resulting collection of Final models is then returned.

You can then use this relationship like any other Eloquent relationship:

// Controller
$models = Source::with('finals')->get();
kokoshneta's avatar

@joselorenzo_pandago Yeah, that’s just back to the initial problem. This reads like copy-pasted ‘solutions’ from ChatGPT, which is not a valid way to answer questions.

josecameselle's avatar

@kokoshneta

Sometimes it helps, but not always (not here...). Have you tried this:

// Source.php
public function intermediates() {
    return $this->hasMany(Intermediate::class);
}

// Intermediate.php
public function finals() {
    return $this->belongsToMany(Final::class, 'intermediates', 'id', 'final_id');
}

With these relationships defined, you can retrieve the related Final models for a given Source model like this:

$finals = $source->intermediates->flatMap(function ($intermediate) {
    return $intermediate->finals;
});

Of course the problem seems quite convoluted...

Please or to participate in this conversation.