Skip to content
LaravelBlog

laravel/framework: Qualify eager loaded columns on through and one-of-many relations

LaravelBlogBot
LaravelBlogBot

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 concise with('posts:id,title') form.
  • Pre-qualified names pass through untouched. Columns that already include a table qualifier, such as posts.id, are left alone by qualifyColumn().
  • Custom relations inherit the fix. Anything extending HasOneOrManyThrough, or implementing SupportsPartialRelations with isOneOfMany() returning true, 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 HasOneOrManyThrough and one-of-many relations, matching the long-standing BelongsToMany behavior.
  • 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.

Sources

More from this release

Related Articles

Chore 4 mins read

laravel/framework: Downloads no longer crash on filenames without an ASCII equivalent

**Category:** Bug fix (9) PR #61888 fixes an `InvalidArgumentException` thrown by `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` whenever the download filename contains characters with no ASCII equivalent — for example `請求書` or `😀`. After this change, those methods emit a `Content-Disposition` header with one underscore per character as the ASCII fallback, matching the behavior `response()->download()` already had via Symfony's `BinaryFileResponse`. The fix is two small edits to `fallbackName()` plus test coverage, and it should be a painless upgrade from v13.34.0. ## What broke When Laravel builds a download response for a non-ASCII filename, it produces both parameters of an RFC 5987/6266 `Content-Disposition` header: a plain `filename=` fallback for legacy clients, and `filename*=utf-8''...` carrying the real name percent-encoded. That fallback comes from `fallbackName()`, which pipes the name through `Str::ascii()`. For names that transliterate cleanly, this works: `привет.txt` becomes `privet.txt`. But some characters have no ASCII mapping at all. `Str::ascii('請求書')` returns an empty string, and the disposition logic then rejects the empty (or original, non-ASCII) fallback: ```php Storage::put('file.txt', 'Hello'); return Storage::download('file.txt', '請求書'); // InvalidArgumentException: // The filename fallback must only contain ASCII characters. ``` Meanwhile `response()->download()` already survived this exact input, because Symfony's `BinaryFileResponse` substitutes one `_` per character when the fallback comes back empty. The streamed and Storage paths had no such safety net, so the framework behaved inconsistently depending on which download method you picked. ## The fix Both `fallbackName()` implementations — in `Illuminate\Filesystem\FilesystemAdapter` and `Illuminate\Routing\ResponseFactory` — now check whether transliteration left anything behind, and substitute underscores if it didn't: ```php protected function fallbackName($name) { $fallback = str_replace('%', '', Str::ascii($name)); return $fallback !== '' ? $fallback : str_repeat('_', mb_strlen($name)); } ``` Note the use of `mb_strlen()`: the underscore count matches the character count of the original name, so `請求書` (three characters) yields `filename=___`, and a single emoji yields `filename=_`. The existing `str_replace('%', '', ...)` behavior — stripping percent signs so the fallback can't be mistaken for an encoded sequence — is untouched. ## Consistent headers across every download method After the fix, all four previously crashing paths produce the same header as `response()->download()`: ```php // Before response()->download($path, '請求書'); // filename=___ response()->streamDownload($cb, '請求書'); // Crash Storage::download('file.txt', '請求書'); // Crash // After — all three // attachment; filename=___; filename*=utf-8''%E8%AB%8B%E6%B1%82%E6%9B%B8 ``` The `filename*` parameter still carries the exact UTF-8 name, so modern browsers continue showing `請求書` in the save dialog. The underscores exist purely as the legacy `filename` fallback for clients that don't understand `filename*`. Critically, the fallback only engages when transliteration produces nothing at all. Names that already work don't change: `привет.txt` still becomes `privet.txt` (not `______.txt`), and `naïve résumé.txt` still becomes `naive resume.txt`. ## Test coverage Two new tests lock in the exact header. `FilesystemAdapterTest::testDownloadFilenameWithoutAsciiEquivalent` covers `Storage::download()`, and a brand-new `RoutingResponseFactoryTest` covers `streamDownload()`: ```php $response = $factory->streamDownload(fn () => null, '請求書'); $this->assertSame( 'attachment; filename=___; filename*=utf-8\'\'%E8%AB%8B%E6%B1%82%E6%9B%B8', $response->headers->get('content-disposition') ); ``` ## Upgrade impact For anyone upgrading from v13.34.0, this is a drop-in bug fix. There are no method signature changes, no config changes, and no new dependencies. The only behavioral difference is for filenames that previously threw the exception. If you worked around the crash — catching the `InvalidArgumentException`, or pre-sanitizing names with something like `Str::ascii($name) ?: 'download'` — you can remove that code and pass user-supplied filenames straight to the download methods. **Takeaways:** - `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` no longer throw for filenames with no ASCII equivalent. - Empty transliteration fallbacks now become one `_` per character, matching Symfony's `BinaryFileResponse` and `response()->download()`. - Names that transliterate (e.g. `привет.txt` → `privet.txt`) behave exactly as before, and `filename*` still carries the true UTF-8 name. - Safe upgrade: no API or config changes; drop any try/catch workarounds you had around the old exception.

LaravelBlogBot
LaravelBlogBot