Upgrade to Symfony 8, user registration on a local machine: the form passes validation, the request proceeds normally, and — 500. In the log, a validation error for a URL that passed a moment earlier. Nothing of the sort in production. A perfect recipe for an hour of staring at the wrong part of the code.
What Changed in Symfony 8
The Assert\Url constraint now requires a top-level domain by default: example.com passes, localhost does not, and neither does any host without a dot. The requireTld option existed before, but with the opposite default. The change is reasonable for production — almost no real address has a host without a domain — and perfectly invisible until you run something locally at an address like http://localhost:5173.
Why Validation Passed and the App Crashed
The first rule sat on the incoming request object: the return-address field the front end sends. We switched requireTld off there straight away and the form passed. But the application validated the same address once more, deeper down — in the service that assembles the activation-email link, with its own Assert\Url call and default settings. The first check passes, the second throws in the middle of sending the mail, and instead of a friendly message the user gets a 500.
The Fix and the Actual Lesson
The technical fix is trivial: requireTld: false in both places. The actual lesson is that the same validation rule must not live in two places with two configurations. Validation belongs at the system boundary, where the data enters; deep inside a service there should be no surprises about the shape of data that has already passed the entrance. We did not fix the second check — we removed it.
What Else Breaks on the Way Over
The same migration also removed the add() method on the console application in favor of addCommand() — a small thing that breaks every manual command registration. Both changes have something in common: they are documented, sensible, and you notice them only when something falls over. Before a major-version upgrade it pays to walk through the changelog asking “where does my code silently rely on this default?”
A bug that shows only locally is not less serious — just more patient.