levelmax World 1 Strict Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 1.1 ~10 min Target: PHPStan level 1

declare(strict_types=1)

Here's a basket that takes an int quantity. Someone posts the form with quantity=1e3. PHP looks at the parameter type, sees a numeric string, and helpfully converts it. They've just ordered a thousand mugs.

Without strict_types, scalar type declarations are suggestions. PHP coerces whatever you pass into the declared type whenever it can:

You pass int parameter receives
"2" 2
"1e3" 1000
"2.5" 2, with a deprecation notice
" 2" 2
true 1
"two" TypeError

Only the last one fails. Everything else carries on with a value nobody asked for.

What strict_types changes

declare(strict_types=1); at the top of a file switches off that coercion for calls made from that file. Pass a string to an int parameter and you get a TypeError straight away, at the line that made the mistake.

That makes the type declaration a real contract. It also forces a decision PHP was making for you: when a string arrives from a form, a query string, or a CSV, something has to turn it into an int. With strict types on, that something is your code, in one place, at the boundary. That's where you can say what counts as valid.

Converting at the boundary

(int) $value isn't validation. (int) "1e3" is 1000 and (int) "two" is 0. Use something that can say no:

$quantity = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($quantity === false) {
    throw new InvalidArgumentException('quantity must be a whole number of at least 1');
}

FILTER_VALIDATE_INT accepts plain whole numbers, with surrounding whitespace. It returns false for 1e3, 2.5, two, and anything below min_range. It also rejects leading zeros, so 02 is false too. That's usually what you want, but worth knowing before a customer finds it.

Power-up

Before
<?php

final class RecordStockCount
{
    /** @param array<string, string> $form */
    public function handle(array $form): void
    {
        // "1e3" quietly becomes 1000 mugs on the shelf.
        $this->stock->record($form['sku'], $form['count']);
    }
}
After
<?php

declare(strict_types=1);

final class RecordStockCount
{
    /** @param array<string, string> $form */
    public function handle(array $form): void
    {
        $count = filter_var($form['count'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
        if ($count === false) {
            throw new InvalidArgumentException('count must be a whole number of 0 or more');
        }

        $this->stock->record($form['sku'], $count);
    }
}

Quick check

1. Without strict types, what does an int $quantity parameter receive when you pass "1e3"?
2. Basket.php declares strict_types=1. A controller without it calls $basket->add('2', '1'). What happens?
3. Which of these rejects "1e3" as a quantity?

CHALLENGE

AddToBasket passes form strings straight into Basket::add(int, int). Without strict types, PHP coerces them, so quantity=1e3 adds a thousand mugs.

Turn on strict types in src/AddToBasket.php, then convert product_id and quantity yourself. Accept whole numbers of at least 1. For anything else, throw an InvalidArgumentException before the basket changes.

Running on PHP 8.3…

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