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
<?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"); } }
<?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"); } }