orWhere() joins with or, and and binds tighter, so mixed chains mislead. A closure passed to where() parenthesizes its conditions; one passed to whereExists() becomes a subquery, and a builder works as a value, as in whereIn('id', $builder).
$wrong = DB::table('products')->where('stock', '<', 100)
->where('category_id', 3)->orWhere('price', '<', 15);
$right = DB::table('products')->where('stock', '<', 100)
->where(fn ($q) => $q->where('category_id', 3)->orWhere('price', '<', 15));
echo $wrong->toRawSql(), "\n", $wrong->pluck('sku'), "\n";
echo $right->toRawSql(), "\n", $right->pluck('sku'), "\n";
echo DB::table('customers as c')->whereNotExists(fn ($q) => $q->select(DB::raw(1))
->from('orders as o')->whereColumn('o.customer_id', 'c.id'))->pluck('name'), "\n";Output
select * from `products` where `stock` < 100 and `category_id` = 3 or `price` < 15 ["BK-SQL-01","BK-SQL-02","AC-MUG-01","AC-STK-01"] select * from `products` where `stock` < 100 and (`category_id` = 3 or `price` < 15) ["BK-SQL-01","BK-SQL-02","AC-MUG-01"] ["Gus Lindqvist","Hana Sato"]
The first query leaks the sticker pack (150 in stock): it means (stock < 100 and category_id = 3) or price < 15. whereColumn() compares columns unbound, correlating the subquery (Correlated Subqueries), and the anti-join finds LEFT and RIGHT OUTER JOIN's two customers. whereAny(['name', 'email'], 'like', 'ben%') writes a parenthesized or across columns (whereAll() an and), and when($term, fn ($q, $t) => ...) adds a clause only when $term is truthy.