laravel/framework: [13.x] Fix `findOrNew()` and `chunkMap()` on HasManyThrough and BelongsToMany
Contributed by xurshudyan
Part of the framework v13.35.0 release
PR #61855 is a quiet but important correctness fix in Eloquent's relationship layer. On HasManyThrough and HasOneThrough relations, findOrNew() was not defined on the relation class, so Laravel silently forwarded the call to the underlying Eloquent builder. The same was true for chunkMap() on through relations and on BelongsToMany. Because those queries join an intermediate or pivot table, the forwarded calls hydrated models with the wrong attributes. This release defines the missing methods on the relations themselves, so they run through the relation's own find() and chunk() logic. If you are upgrading from v13.34.0, code using these methods will now behave correctly — which may surface bugs you didn't know you had.
The mechanics: method forwarding meets a join
Relation classes implement a curated set of builder methods; anything else is forwarded to the Eloquent builder via __call. For simple relations that's harmless, but HasManyThrough joins an intermediate table and BelongsToMany joins a pivot table. When findOrNew() or chunkMap() was forwarded, it ran as a plain select * over that joined query, and identically named columns from the intermediate or pivot table overwrote the related model's columns during hydration.
Laravel already learned this lesson once: PR #22127 gave through relations and BelongsToMany their own chunk() and each() for exactly this reason. find() and findOrFail() were also handled — but findOrNew() and chunkMap() slipped through the cracks until now.
The dangerous part: findOrNew() updating the wrong row
The failure isn't loud; it's silent data corruption. Given Country hasManyThrough Post (through User):
$post = $country->posts()->findOrNew(7);
$post->id; // 2 — the user's id, not the post's
$post->title = 'Updated';
$post->save(); // updates post 2, not post 7
Because users.id won the column collision during hydration, the model claimed to be post 2 while you believed you were editing post 7. Saving it wrote your changes to a completely different record — no exception, no warning. On the flip side, when no row matched, you got a new instance rather than a correctly attributed one, and the new-instance path also had no chance to be relation-aware.
chunkMap(): missing pivot and hijacked ids
On BelongsToMany, a forwarded chunkMap() hydrated models through the builder, which means two problems. First, models came back without their pivot attribute, so $item->pivot->... inside your callback failed or returned null. Second, if the pivot table had an id column, that pivot row's id overwrote the related model's id. On through relations, the intermediate table's columns leaked into the models the same way. Any code that trusted ids or pivot data inside a chunkMap() callback was working with corrupted or incomplete models.
What PR #61855 changes
The PR adds findOrNew() and chunkMap() to HasOneOrManyThrough (which covers both HasManyThrough and HasOneThrough), and chunkMap() to BelongsToMany. Both go through the relation's own find() and chunk() — the same approach each() and chunk() have used since #22127:
public function findOrNew($id, $columns = ['*'])
{
if (is_null($instance = $this->find($id, $columns))) {
$instance = $this->related->newInstance();
}
return $instance;
}
After the fix, the same calls do what you always expected:
$post = $country->posts()->findOrNew(7);
$post->id; // 7
$titles = $project->collaborators()
->chunkMap(fn ($user) => $user->pivot->role, 500); // pivot is hydrated
Note one nuance: when findOrNew() on a through relation finds nothing, you get a bare instance of the related model with no foreign keys set. That's inherent to through relations — there is no single foreign key to fill, unlike HasOneOrMany::findOrNew(), which assigns its foreign key on the new instance.
Upgrade impact for v13.34.0 users
There are no signature changes, no new config, and no migrations — this is purely a correctness fix, and the new tests confirm ids, titles, and pivot attributes come back intact. Two behavioral details are worth checking in your own code:
- If any code unknowingly relied on intermediate or pivot columns leaking into models through these methods, those extra columns are no longer selected. Select them explicitly if you genuinely need them.
chunkMap()returns anIlluminate\Support\Collectionof whatever your callback returns — not an Eloquent Collection. That matches the builder's existingchunkMap()behavior, but wrap the result if you depend on Eloquent collection methods.
Takeaways
findOrNew()onHasManyThrough/HasOneThroughpreviously hydrated with intermediate-table columns — it could silently save the wrong row.chunkMap()on through relations andBelongsToManyreturned corrupted models and, onBelongsToMany, models withoutpivot.- Both methods now live on the relation classes and delegate to the relation's own
find()/chunk(). - Upgrade risk is low: no API changes, but code depending on the broken hydration (leaked columns, missing pivot) will behave differently — correctly.