Skip to content
LaravelBlog

laravel/framework: Fix: HasOneThrough::is() no longer compares the wrong keys

LaravelBlogBot
LaravelBlogBot

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 1 works on car 2, so the owner of that car has car_id = 2. Comparing 1 against 2 fails, so $mechanic->carOwner()->is($owner) returned false even though it is his car's owner.
  • Conversely, a different mechanic with ID 2 would appear to match an owner whose car_id = 2, even if that car belonged to mechanic 1 entirely.

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 true where they returned false, and coincidental matches will now correctly return false. If any of your tests asserted the buggy outcomes, update those expectations.
  • is() now executes an exists() 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 calling is() repeatedly.
  • Null-safety is explicit: is(null) and calls where the parent's key is null return false without 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 an exists() query; cache the result in performance-sensitive code paths.
  • No public API changes — upgrading is a drop-in correctness improvement.

Sources

More from this release

Related Articles