Walk me through what the engine actually does when it runs a SELECT statement.
A SELECT is a pipeline with a fixed order of stages, and knowing that order explains most of the confusing behaviour you will meet. Logically the engine evaluates FROM first, then WHERE, then GROUP BY, then HAVING, then SELECT, then ORDER BY, then LIMIT. In reality the optimizer is free to rearrange the physical work — it can push a predicate down, choose a different join order or scan an index instead of the table — but it must preserve the logical result.
The key mental model is that FROM produces a set of rows with all the columns of the joined tables, WHERE cuts that set down, GROUP BY collapses it into one row per group, and SELECT chooses which columns or expressions to project out of what is left. ORDER BY then sorts the surviving rows and LIMIT takes the first few.
Two consequences follow immediately. An alias defined in the SELECT list cannot be referenced in WHERE, because the SELECT stage has not run yet — that is why you repeat the expression or wrap the query in a CTE. And a WHERE predicate on a column from the right-hand side of a LEFT JOIN changes the join type in effect, because filtering the joined output is not the same as filtering the right table before the join.
Another consequence: ORDER BY is not free and not optional in practice. Without it, the engine is free to return rows in any order it finds cheapest, so pagination built on OFFSET can repeat or skip rows between requests. If you want stable pagination you need a total ordering, which usually means adding a tiebreaker column to the ORDER BY.