Validation isn't exceptional
A customer adds a new delivery address. They leave out the town and pick France. The page says Enter your town or city. They do, and submit again. Now it says Sorry, we don't deliver there. They could have been told both things the first time.
The form works this way because AddressForm::parse() throws an InvalidArgumentException at the first problem it finds. Throwing ends the method, so the first problem is the only one anyone hears about. And nothing catches it, which is right for an InvalidArgumentException: it's a LogicException, the kind that means a bug. So the framework answers each typo with a 500.
Expected failures are answers
Exceptions are for failures the code in front of you can't deal with, which is why they jump straight past it. A customer making a mistake on a form isn't one of those. It's one of the two normal answers to "is this a good address?", and the caller wants it, in full, right now.
So return it. Collect every problem into a value, and give the method a return type that admits there are two outcomes:
/** @param array<string, mixed> $form */ public static function parse(array $form): Address|ValidationErrors
The union is what makes this safe. The caller can't use the result as an Address until it has ruled out the errors, and PHPStan checks that it has:
$address = AddressForm::parse($form); if ($address instanceof ValidationErrors) { return new Response(422, ['errors' => $address->all()]); } $this->book->add($address); // PHPStan knows it's an Address here
Forget the instanceof and PHPStan reports that ValidationErrors has no postcode. An exception would have let that caller forget silently.
Check once, at the edge
The mixed from the request is checked in one place, field by field, and what comes out is an Address whose fields are non-empty-string and Country. Nothing after this point checks them again, because the types already say they're fine. This is sometimes called parse, don't validate: don't just look at the input and say yes, turn it into a type that can only hold good values (2.2, 3.2 and 5.1 all did the same).
Some checks depend on others. You can't say whether D02 X285 is a good postcode until you know the country is Ireland. Check what you can, skip what depends on a field that's already wrong, and report the rest.
Power-up
<?php final class AddressForm { /** @param array<string, mixed> $form */ public static function parse(array $form): Address { $town = self::required($form, 'town', 'your town or city'); // throws, and that's the end of it $country = Country::tryFrom(self::required($form, 'country', 'a country')) ?? throw new InvalidArgumentException("Sorry, we don't deliver there"); // never reached // … } }
<?php final class AddressForm { /** @param array<string, mixed> $form */ public static function parse(array $form): Address|ValidationErrors { $errors = new ValidationErrors(); $town = self::required($form, 'town', 'your town or city', $errors); // adds an error, returns null // … every other field, each adding its own error … if ($name === null || $line1 === null || $town === null || $postcode === null || $country === null) { return $errors; // all of them, at once } return new Address($name, $line1, $town, $postcode, $country); } }