laravel/framework: Qualify eager loaded columns on through and one-of-many relations
Contributed by xurshudyan
Part of the framework v13.35.0 release
Since early Laravel, the with() method has supported a colon syntax for eager loading just a few columns: User::with('posts:id,title'). That works because the builder qualifies the column names behind the scenes — but only for BelongsToMany. For any relation whose query joins another table, the generated SQL contained bare column names and the database rejected it. Pull request #61885 extends the qualification to HasOneOrManyThrough relations and to one-of-many relations such as latestOfMany(), so the colon syntax finally behaves the same across all joined relation types. It's a pure bug fix for anyone on v13.34.0: no migrations, no configuration, no API changes — queries that previously threw now simply work.
The bug: joined queries make column names ambiguous
When you write with('posts:id,title'), Eloquent extracts the column list and passes it to the relation query's select(). On a plain hasMany that query only touches one table, so id and title resolve without trouble. Through relations and one-of-many relations are different: hasManyThrough() joins the intermediate table to hop across it, and latestOfMany() / oldestOfMany() join the related table against a grouped subquery of itself. On those joined queries a bare id or user_id exists in more than one table, and the database refuses to guess which one you mean:
// hasOne(Post::class)->latestOfMany()
User::with('latestPost:id,user_id')->get();
// 1052 Column 'user_id' in field list is ambiguous
// hasManyThrough(Post::class, User::class)
Team::with('posts:id,title')->get();
// 1052 Column 'id' in field list is ambiguous
Postgres and other engines raise their own "ambiguous column" variants of the same error. Until now the only escape hatches were to eager load full models, or to fall back to a closure and qualify every column by hand. BelongsToMany never had this problem because the builder special-cased it — this patch generalizes that special case.
What the patch changes
The change lives in Builder::createSelectWithConstraint(), the method that turns posts:id,title into a select constraint. It now qualifies each column with the related model's table name in three cases instead of one: BelongsToMany as before, plus HasOneOrManyThrough (covering both hasOneThrough() and hasManyThrough()), plus any relation implementing the SupportsPartialRelations contract that reports isOneOfMany() — which is exactly what latestOfMany(), oldestOfMany() and ofMany() produce.
// Before
$query instanceof BelongsToMany
? $query->getRelated()->qualifyColumn($column)
: $column;
// After
$query instanceof BelongsToMany
|| $query instanceof HasOneOrManyThrough
|| ($query instanceof SupportsPartialRelations && $query->isOneOfMany())
? $query->getRelated()->qualifyColumn($column)
: $column;
In practice, posts:id,title is now compiled with posts.id, posts.title on those relations, removing the ambiguity at the source. Two integration tests accompany the change — one for eager loading through a hasManyThrough relation, and one for a latestOfMany() relation that also asserts the hydrated model carries exactly ['id', 'user_id'] as its attributes, confirming you truly get lean models.
What your code looks like now
Given these relations:
class User extends Model
{
public function latestLogin(): HasOne
{
return $this->hasOne(Login::class)->latestOfMany();
}
public function teamMates(): HasManyThrough
{
return $this->hasManyThrough(User::class, Team::class);
}
}
Both column-constrained eager loads now succeed:
$user = User::with('latestLogin:id,user_id')->first();
$user = User::with('teamMates:id,name')->find($user->id);
The usual rule for this syntax is unchanged: include the primary key and the foreign key that matches the parent, or the models can't be matched back up during hydration.
Upgrade impact from v13.34.0
- Drop-in patch. There is nothing to migrate or configure, and the behavior only changes where an exception was previously thrown — queries that already worked keep working.
- No manual qualification needed. If you worked around the bug with something like
->with(['posts' => fn ($q) => $q->select('posts.id', 'posts.title')]), you can go back to the concisewith('posts:id,title')form. - Pre-qualified names pass through untouched. Columns that already include a table qualifier, such as
posts.id, are left alone byqualifyColumn(). - Custom relations inherit the fix. Anything extending
HasOneOrManyThrough, or implementingSupportsPartialRelationswithisOneOfMany()returningtrue, gets the same column qualification automatically — which is almost certainly what you want on a joined query.
Takeaways
- PR #61885 fixes ambiguous-column database errors when using
with('relation:columns')on through and one-of-many relations. - Columns are now qualified with the related table's name for
HasOneOrManyThroughand one-of-many relations, matching the long-standingBelongsToManybehavior. - Upgrading from v13.34.0 is safe: only previously failing queries are affected, and no public API changed.
- Any closure-based select workarounds for this bug can be replaced with the shorter string syntax.