Skip to content

date: 2026-06-18 tags: [laravel, eloquent, query-builder, orwhere, footgun, mutation-testing] status: active graduated_to:

orWhere([...]) ORs its own array members — it's not the AND-group where([...]) is

Symptom — A "reject a duplicate couple" rule over-matched: it flagged a brand-new couple as a duplicate whenever it merely shared one person with an existing couple (once Alice was coupled, you couldn't make a different couple involving Alice). A mutation-driven test (allows a person already coupled to pair with a different person) failed against the real code.

Root cause — The reverse-pair check used the array shorthand ->orWhere(['person_one_id' => $b, 'person_two_id' => $a]). where([...]) ANDs its members (default boolean and), so people assume orWhere([...]) does too. It does not: Laravel's addArrayOfWheres propagates the call's own boolean to each inner condition, so orWhere([...]) becomes OR (one = $b OR two = $a) — not OR (one = $b AND two = $a). The clause matched any row sharing a single column.

Fix — Rewrote the reverse clause as explicit nested closures so each = is an unambiguous AND, in app/Rules/UniqueRelationshipPair.php (commit e7be1fc):

php
->where(function (Builder $query) use ($personOne, $personTwo): void {
    $query->where('person_one_id', $personOne)->where('person_two_id', $personTwo);
})->orWhere(function (Builder $query) use ($personOne, $personTwo): void {
    $query->where('person_one_id', $personTwo)->where('person_two_id', $personOne);
});

Guard — Four feature tests in tests/Feature/RelationshipControllerTest.php assert a person already in a couple can still pair with a different partner in either position (the cases that fail under the OR-propagated form). More generally: for a compound OR (a AND b) group, pass a closure to where/orWhere, never the array shorthand — the array form only does what you mean when every member should share the call's boolean. If this recurs, graduate a line into .claude/rules/database.md.