Level 0, a legacy codebase, a few hundred errors on the first run. That was my week with PHPStan. Most of the output was noise about magic the tool cannot see. But in the first hour of reading I found three real bugs, live in production for months.
One: a repository method returns an entity or null, and a caller chains a method right on the result. The not-found branch was never written. It survived because that path needs a deleted record, and deleted records are rare. Rare is not never.
Two: a branch reading $row['user_name'] from a query that stopped selecting user_name half a year ago. Dead code pretending to be alive, waiting for someone to “fix” something next to it.
Three: a call passing three arguments to a function that takes two. PHP quietly drops the extra one. The extra one was the important one.
None of these needed a type system to exist. They needed someone to read every path, and tests only cover the paths somebody thought of. Static analysis reads all of them and does not get bored.
For legacy the strategy is incremental. Level 0 in CI, fix what it found, hold the line. Raise the level when the current one is clean. For the hopeless corners there is ignoreErrors with a regex in the config. Write a comment next to each one saying why, or the ignore list becomes a second legacy.
You do not have to type the whole codebase first. Add phpdoc where inference fails and PHPStan works with the dynamic mess as it is. It checks the contract that already exists, it does not demand a new one.
Two of the three bugs were in diffs I had reviewed. I read them. I approved them.