laravel/framework: Fix: HasOneThrough::is() no longer compares the wrong keys
Contributed by xurshudyan
Part of the framework v13.35.0 release
When you upgrade from v13.34.0, you pick up a small but meaningful correctness fix for Eloquent's HasOneThrough relation (PR #61827). Until now, calling is() or isNot() on a has-one-through relation could return the wrong answer whenever the primary keys in your tables happened to line up — or happened not to. The fix overrides is() on HasOneThrough so the comparison is resolved through the relation query itself, meaning the result now reflects what the relation would actually return instead of comparing two unrelated identifiers.
The bug: keys from opposite ends of the chain
A has-one-through relation links a parent model to a far model through an intermediate one. Using the classic documentation setup, a Mechanic has one Owner through a Car: cars.mechanic_id points back to the mechanic, and owners.car_id points to the car.
The inherited is() implementation compared the parent's key with the related model's foreign key to the intermediate model. In this example, that means comparing the mechanic's primary key against owners.car_id. Those are values from two entirely different ID spaces — a mechanic ID and a car ID — so the outcome of the check depended on whether unrelated rows coincidentally shared a number.
That produced both false negatives and false positives:
- Mechanic
1works on car2, so the owner of that car hascar_id = 2. Comparing1against2fails, so$mechanic->carOwner()->is($owner)returnedfalseeven though it is his car's owner. - Conversely, a different mechanic with ID
2would appear to match an owner whosecar_id = 2, even if that car belonged to mechanic1entirely.
Anything built on top of this — authorization checks, conditional UI, assertions in your test suite — could silently behave incorrectly depending on which rows your fixtures created.
What the fix does
The PR adds an is() override to HasOneThrough that validates the candidate model and then asks the relation query directly:
public function is($model)
{
return ! is_null($model) &&
! is_null($this->getParentKey()) &&
$this->related->getTable() === $model->getTable() &&
$this->related->getConnectionName() === $model->getConnectionName() &&
(clone $this->query)->whereKey($model->getKey())->exists();
}
Three things are worth noting. First, the cheap sanity checks are preserved: a null model, a parent without a key, or a model from a different table or connection short-circuits to false without touching the database. Second, the actual match runs the full relation query — both joins and any query-time constraints the relation carries — and checks that the candidate model's key survives as an exists() result. The relation is cloned first, so repeated calls don't mutate the original. Third, because isNot() is derived from is(), it is corrected automatically.
Seeing it in practice
// Mechanic 1 works on car 2, so the owner row references car_id = 2.
// Before: compared mechanic key (1) with owner.car_id (2) -> false
// After: runs the relation query and finds the owner -> true
$mechanic->carOwner()->is($owner); // true
$mechanic->carOwner()->isNot($otherOwner); // true
// Before: a mechanic whose ID coincidentally equaled the car's ID
// could match an unrelated owner. After: no match.
The PR's integration test covers exactly this scenario: two positions, two users, and two contracts created through a has-one-through chain, asserting that each parent matches only its own far model in both directions.
of-many through relations are covered too
Has-one-through relations built with latestOfMany() or oldestOfMany() share this code path, so they get the corrected behavior as well. A new test, testIsMethodComparesTheFarParent, was added to the of-many integration suite to pin this down: two users each have logins created through intermediate records, and every combination of $user->latest_login()->is(...) must match only its own login. Before the fix, those comparisons could succeed or fail based on how the intermediate and far keys happened to be numbered.
Upgrade impact
For the vast majority of applications, this is a pure bug fix: is() and isNot() start returning the answers they always should have. There is no API change — the method signatures are identical, and no migrations or configuration are needed. Still, a few details are worth knowing when you upgrade from v13.34.0:
- Results may flip in both directions. Relations that correctly matched may now return
truewhere they returnedfalse, and coincidental matches will now correctly returnfalse. If any of your tests asserted the buggy outcomes, update those expectations. is()now executes anexists()query per call, whereas the old implementation was a pure in-memory comparison. In hot loops, compute the boolean once and reuse it rather than callingis()repeatedly.- Null-safety is explicit:
is(null)and calls where the parent's key isnullreturnfalsewithout issuing a query.
Takeaways
HasOneThrough::is()/isNot()now resolve the match through the relation query instead of comparing mismatched keys.- Results no longer depend on coincidental ID collisions between the parent's key and the intermediate foreign key.
- The fix also applies to
latestOfMany()/oldestOfMany()variants of has-one-through relations. - Each
is()call may run anexists()query; cache the result in performance-sensitive code paths. - No public API changes — upgrading is a drop-in correctness improvement.