A LEFT JOIN keeps every row from the left (first) table even when there's no matching row on the right — the columns from the missing side come back as null instead of the row disappearing entirely, which is exactly what makes it different from an inner join or a whereHas() relationship filter.
Basic syntax
$users = DB::table('users')
->leftJoin('orders', 'users.id', '=', 'orders.user_id')
->select('users.*', 'orders.total as order_total')
->get();
Users with no orders still appear in the result, with order_total as null — this is usually the entire reason to reach for a LEFT JOIN instead of a normal relationship query: you want the "zero" case represented, not filtered out.
The same thing with the Eloquent query builder
$users = User::leftJoin('orders', 'users.id', '=', 'orders.user_id')
->select('users.*', DB::raw('COUNT(orders.id) as order_count'))
->groupBy('users.id')
->get();
Why leftJoin() and whereHas() give different results
This is the mistake that actually costs people time: whereHas('orders') filters users down to only those with at least one order — the opposite of what a LEFT JOIN is usually used for:
// Only users who HAVE orders
User::whereHas('orders')->get();
// Every user, with order data where it exists
User::leftJoin('orders', 'users.id', '=', 'orders.user_id')->get();
// Only users with NO orders at all
User::whereDoesntHave('orders')->get();
If the actual goal is "users with no orders," whereDoesntHave() is both simpler and clearer than a LEFT JOIN plus a WHERE order_id IS NULL filter — reach for the join specifically when you need the joined columns themselves, not just a filter based on the relationship's existence.
Watch for column name collisions
If both tables have an id or created_at column, an unqualified select('*') will silently let one overwrite the other in the result. Always qualify ambiguous columns explicitly, as in the examples above (users.*, not a bare *).