On this page
An old project with no types
Many PHP projects began before PHP had types. Functions take any value and return any value. The code works most of the time, and the mistakes hide until a rare case reaches them.
PHPStan is a static analyzer. It reads your code without running it and reports mistakes. It also works on imperfect projects, and it has a way to start on old code today. This post shows that way on a small shop project that I wrote on purpose. The project has six mistakes that are common in old code.
Here are its three files.
<?php
class Customer
{
public $name;
public $email;
private $discount;
public function __construct($name, $email, $discount = 0)
{
$this->name = $name;
$this->email = $email;
$this->discount = $discount;
}
public function getDiscount()
{
return $this->discount;
}
public function domain()
{
// strrchr() returns false when there is no @ in the address.
return substr(strrchr($this->email, '@'), 1);
}
}<?php
class Order
{
private $lines = array();
private $customer;
public function __construct($customer)
{
$this->customer = $customer;
}
public function add($sku, $price, $qty = 1)
{
$this->lines[] = array('sku' => $sku, 'price' => $price, 'qty' => $qty);
}
public function total()
{
$sum = 0;
foreach ($this->lines as $line) {
$sum += $line['price'] * $line['qty'];
}
return $sum - $sum * $this->customer->getDiscount() / 100;
}
public function findLine($sku)
{
foreach ($this->lines as $line) {
if ($line['sku'] == $sku) {
return $line;
}
}
}
public function describe($sku)
{
$line = $this->findLine($sku);
return $line['sku'] . ' x ' . $line['qty'];
}
public function customerName()
{
return $this->custmer->name;
}
}<?php
function format_money($amount)
{
return number_format($amount, 2) . ' CAD';
}
function load_order($id)
{
if ($id > 0) {
return new Order(new Customer('Ann', '[email protected]', 10));
}
}
function print_total($id)
{
$order = load_order($id);
echo format_money($order->total(), 'USD'), PHP_EOL;
echo $order->customer()->domain(), PHP_EOL;
}The mistakes are these:
Order::customerName()reads$this->custmer, a typo.print_total()givesformat_money()a second argument that it does not take.Customer::domain()passes the result ofstrrchr()tosubstr(). That result isfalsewhen the address has no@.Order::findLine()returns nothing when no line matches, anddescribe()reads the result as an array.load_order()returns nothing when the id is 0, andprint_total()calls a method on the result.print_total()calls$order->customer(), a method that does not exist.
Rule levels
PHPStan checks code on levels from 0 to 10. Level 0 is the loosest and level 10 is the strictest. Each level includes the checks of the levels below it. The documentation (opens in a new tab) says that level 6 reports missing type hints, and level 8 reports calls on values that can be null. Level 10 is the strictest about mixed, the type that PHPStan gives to a value without a declared type.
The idea is that you can start low and go up. This small script counts the errors on each level. I used PHPStan 2.3.1 and PHP 8.3.
#!/bin/sh
# Counts the errors that PHPStan reports on each rule level, for the project in a folder.
# Run with: sh count-levels.sh shop "php /path/to/phpstan.phar"
project="$1"
phpstan="${2:-vendor/bin/phpstan}"
cd "$project" || exit 1
for level in 0 1 2 3 4 5 6 7 8 9 10; do
count=$($phpstan analyse src --level="$level" --no-progress --error-format=raw | grep -c ':[0-9]*:')
echo "level $level: $count"
doneI ran it on the project.
level 0: 1
level 1: 2
level 2: 2
level 3: 2
level 4: 2
level 5: 2
level 6: 29
level 7: 30
level 8: 30
level 9: 30
level 10: 51Level 0 finds the typo, and level 1 finds the extra argument. Then nothing new appears until level 6, where the 27 new errors are all missing types. Level 7 adds one more: the false from strrchr(). The project has six mistakes, and PHPStan sees only three of them.
The reason is the types. When a value has no declared type, PHPStan cannot know what it is, so it treats it as mixed and allows almost anything. The null problems and the missing method hide behind mixed. Only level 10 starts to complain about it, with 51 errors such as Cannot call method total() on mixed.
The baseline
You cannot fix 30 errors before the first commit, and you should not ignore them either. PHPStan has a tool for this: the baseline (opens in a new tab). It saves the current errors in a file and does not report them again. You see only the new errors.
First, write a config file. It sets the level and the folder.
parameters:
level: 8
paths:
- srcThen generate the baseline.
php phpstan.phar analyse --generate-baseline phpstan-baseline.neon [OK] Baseline generated with 30 errors.Add the baseline to the config.
includes:
- phpstan-baseline.neonA run now prints [OK] No errors. The file phpstan-baseline.neon has one entry for each error. An entry has the message, the file path and a count.
One warning. The baseline hides every error, including the real bugs. In my run, it also hid the typo and the extra argument, which are bugs that PHPStan found at level 0 and 1. Fix the errors of the low levels first, and then generate the baseline for the rest.
New code is still checked
The baseline covers old errors only. To see this, I added a new file with a new mistake.
<?php
function send_receipt(Order $order, string $to): void
{
echo $order->total(1), ' ', $to, PHP_EOL;
}PHPStan reported it at once, and nothing else.
src/Mail.php:5:Method Order::total() invoked with 1 parameter, 0 required.So a team can add PHPStan to the build, and the build fails when someone adds a new error.
Add types and the baseline shrinks
Now I typed Customer.php. I gave every property and every return value a type, and I used constructor promotion with readonly properties. The next run did not say "No errors". It listed the baseline entries that no longer match an error.
src/Customer.php:?:Ignored error pattern #^Method Customer\:\:domain\(\) has no return type specified\.$# (missingType.return) in path /shop/src/Customer.php was not matched in reported errors.There were 8 such lines, one for each missing type that I added. PHPStan treats an unused entry as an error, so a baseline can only shrink. I generated it again.
[OK] Baseline generated with 22 errors.From 30 errors, the baseline went down to 22. Each file that you type removes its entries.
What types reveal
To see the full effect, I typed all three files and fixed the two errors of level 0 and 1. This is Order.php after the change.
<?php
declare(strict_types=1);
final class Order
{
/** @var list<array{sku: string, price: float, qty: int}> */
private array $lines = [];
public function __construct(private readonly Customer $customer)
{
}
public function add(string $sku, float $price, int $qty = 1): void
{
$this->lines[] = ['sku' => $sku, 'price' => $price, 'qty' => $qty];
}
public function total(): float
{
$sum = 0.0;
foreach ($this->lines as $line) {
$sum += $line['price'] * $line['qty'];
}
return $sum - $sum * $this->customer->getDiscount() / 100;
}
/** @return array{sku: string, price: float, qty: int}|null */
public function findLine(string $sku): ?array
{
foreach ($this->lines as $line) {
if ($line['sku'] === $sku) {
return $line;
}
}
return null;
}
public function describe(string $sku): string
{
$line = $this->findLine($sku);
return $line['sku'] . ' x ' . $line['qty'];
}
public function customerName(): string
{
return $this->customer->name;
}
}The array has a shape in a comment, @var list<array{sku: string, price: float, qty: int}>. PHP has no way to write that type in code, but PHPStan reads the comment. Then I counted the errors again.
level 0: 0
level 1: 0
level 2: 1
level 3: 1
level 4: 1
level 5: 1
level 6: 1
level 7: 2
level 8: 5
level 9: 5
level 10: 7The errors of the typed project on level 8 are:
src/Customer.php:22:Parameter #1 $string of function substr expects string, string|false given.
src/Order.php:42:Offset 'qty' might not exist on array{sku: string, price: float, qty: int}|null.
src/Order.php:42:Offset 'sku' might not exist on array{sku: string, price: float, qty: int}|null.
src/functions.php:21:Cannot call method total() on Order|null.
src/functions.php:22:Cannot call method customer() on Order|null.Level 2 also reports Call to an undefined method Order::customer(). These are mistakes 4, 5 and 6, which the untyped project hid. The bugs were already there. Typing let PHPStan see them. The old project needed level 10 to get a hint, with 51 errors. The typed project shows them on level 2 and 8, with a handful of errors.
A plan for an old project
This is the order that I would follow:
- Install PHPStan, run level 0 and fix what it finds.
- Go up while the count is small. In my project, that was level 1 and 5, with two errors.
- Pick a level that gives a long list, such as 6 or 8, and generate a baseline.
- Add PHPStan to the build, so a new error fails it.
- Whenever you change a file, add types to it and generate the baseline again. The baseline gets shorter.
- When the baseline is small, raise the level.
The baseline page (opens in a new tab) says that this works best for a few dozen to a few hundred errors. For a project with thousands, use a lower level first.
Read more
- Rule levels (opens in a new tab) in the PHPStan documentation, with what each level checks
- The baseline (opens in a new tab) in the PHPStan documentation
- Type declarations (opens in a new tab) in the PHP manual
Was this post helpful?