levelmax World 6 Errors & Edges
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 6.4 ~15 min Target: PHPStan max

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

Before
<?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

        // …
    }
}
After
<?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);
    }
}

Quick check

1. Why does throwing at the first bad field make a worse form?
2. parse() returns Address|ValidationErrors. The caller reads $result->postcode without checking. What happens?
3. Where should the request's mixed values be checked and turned into proper types?

CHALLENGE

The new-address form tells the customer about one problem at a time. Leave out the town and give a French postcode, and they're told to enter a town. They do, submit again, and only then find out we don't deliver to France. Each mistake also reaches the controller as an InvalidArgumentException, which the framework turns into a 500.

Make AddressForm::parse() return an Address when the form is good, and ValidationErrors listing every problem when it isn't, with a message for each one. AddressController (read only) is already written against that. Check the postcode with Country::normalisePostcode(), which only makes sense once you know the country.

REFERENCE
public static function parse(array $form): Address|ValidationErrors

PHPStan narrows each variable separately. $errors->isEmpty() doesn't tell it that $name isn't null, but $name === null does.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.