levelmax World 2 Honest Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 2.2 ~14 min Target: PHPStan level 7

Refined scalar types

int $percentOff accepts -40 and 9000. string $code accepts ''. The types are true, but they're not what the code means, so every method that uses them either checks again or hopes.

PHPStan has types for what you actually mean. They live in docblocks, and they cost nothing at runtime.

Types PHP doesn't have

Type Means
non-empty-string Any string except ''
positive-int 1 or more, also written int<1, max>
int<0, max> 0 or more, for counts and amounts in pence
int<1, 100> Any range you like
'email'|'sms' Exactly these strings and nothing else

A union of literal strings is a lightweight enum. It's handy for a value that arrives as a string and is used close by. Once it grows behaviour or travels far, make it a real enum, as in 1.6.

Level 7: "maybe" isn't good enough

Up to level 6, PHPStan only reports types that are definitely wrong. An int might be between 1 and 100, so passing one to an int<1, 100> parameter is a "maybe", and those are let through.

Level 7 reports the maybes. That covers a union with one wrong member, and also an int where a range is needed, a string where a literal is needed, and an array where a list is needed. It's the level where the types from 2.1 and this level start being checked.

The check is the evidence

You don't have to tell PHPStan that a value fits. It learns from the checks you'd write anyway:

if ($email === '') {
    throw new InvalidArgumentException('email is required');
}
// $email is now a non-empty-string

$days = filter_var($input, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 30]]);
if ($days === false) {
    throw new InvalidArgumentException('days must be 1 to 30');
}
// $days is now an int<1, 30>

if (!in_array($channel, ['email', 'sms'], true)) {
    throw new InvalidArgumentException('channel must be email or sms');
}
// $channel is now 'email'|'sms'

Check once, where the value comes in, and put the refined type on the object you build. Everything downstream can rely on it without checking again. That's the opposite of the defensive max(1, min(100, $percent)) scattered through a codebase. Clamping doesn't make bad data good. It turns "150% off" into "100% off", and nobody finds out why.

Power-up

Before
<?php

final class Reminder
{
    public function __construct(
        public readonly string $email,
        public readonly int $daysBefore,
    ) {}

    public function sendAt(DateTimeImmutable $due): DateTimeImmutable
    {
        $days = max(1, min(30, $this->daysBefore)); // just in case

        return $due->modify("-$days days");
    }
}
After
<?php

final class Reminder
{
    /**
     * @param non-empty-string $email
     * @param int<1, 30> $daysBefore
     */
    private function __construct(
        public readonly string $email,
        public readonly int $daysBefore,
    ) {}

    public static function fromForm(string $email, string $daysBefore): self
    {
        $email = trim($email);
        if ($email === '') {
            throw new InvalidArgumentException('email is required');
        }

        $days = filter_var($daysBefore, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 30]]);
        if ($days === false) {
            throw new InvalidArgumentException('daysBefore must be 1 to 30');
        }

        return new self($email, $days);
    }

    public function sendAt(DateTimeImmutable $due): DateTimeImmutable
    {
        return $due->modify("-{$this->daysBefore} days");
    }
}

Quick check

1. At level 6, you pass an int to a parameter documented as positive-int. What does PHPStan report?
2. $code is a string. After if ($code === '') { throw new InvalidArgumentException(); }, what is it?
3. A coupon's percentage must be 1 to 100. Where should that be checked?

CHALLENGE

Coupon::fromForm() accepts anything: a blank code, 150% off, a category called cofee. Discount and Redemptions (read only) were written for coupons that make sense, and their types say so.

In src/Coupon.php, check each field once, throwing an InvalidArgumentException for anything that makes no sense, and give the constructor refined types so PHPStan is clean at level 7. Don't add checks anywhere else.

Running on PHP 8.3…

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