Static Analysis
PHPStan with the Magento extension, the rule level Magento itself runs, and a baseline that stops a legacy module blocking the build.
vendor/bin/phpstan analyse --configuration phpstan.neon --level 1 app/code/Vendor/LoyaltyPHPStan reads your code without running it and reports calls that cannot work: a method that does not exist on the type you injected, an argument of the wrong type, a return statement that is missing. It is the check that catches a renamed interface method before a test site does.
Why Magento needs an extension
Much of what Magento code calls does not exist as a file you wrote. Factories, proxies and interceptors are generated into generated/code when DI compiles, so plain PHPStan reports BalanceCollectionFactory::create() as an undefined method. bitexpert/phpstan-magento generates those types for the analysis and teaches PHPStan about extension attributes, magic method calls and ObjectManager return types. Install it with composer require --dev bitexpert/phpstan-magento phpstan/extension-installer. The installer registers the extension, so your config needs no includes line for it. It requires PHPStan 2.0 or newer.
A level you can reach
Rule levels run from 0 to the maximum, each adding checks. Magento's own static test runs level 1 — the constant RULE_LEVEL in dev/tests/static/framework/Magento/TestFramework/CodingStandard/Tool/PhpStan.php. Level 1 on a module is a reasonable place to stand, and you raise it one step at a time.
The baseline
Run vendor/bin/phpstan analyse --generate-baseline. Every error that exists today is written to phpstan-baseline.neon and ignored from then on, so new code is checked while an inherited module does not block the build. Nothing needs flushing afterwards; analysis never touches the database. If errors name classes under generated, run bin/magento setup:di:compile and analyse again.
Example
# phpstan.neon (project root, next to composer.json)
includes
# Create this first: `touch phpstan-baseline.neon`, or PHPStan refuses to start.
phpstan-baseline.neon
parameters
level1
paths
app/code/Vendor/Loyalty
excludePaths
app/code/Vendor/Loyalty/Test/*
app/code/Vendor/Loyalty/view/*
# Generated factories, proxies and interceptors live here after
# `bin/magento setup:di:compile`. Scanned, not analysed.
scanDirectories
generated/code
checkPhpDocMissingReturntrue
reportUnmatchedIgnoredErrorsfalse
ignoreErrors
# Magento's own config ignores this one: an argument kept for
# backward compatibility is unused on purpose.
'#Constructor of class [a-zA-Z0-9\\_]+ has an unused parameter#'When to use it
- Fail CI when a plugin's method signature no longer matches the method it intercepts.
- Adopt an inherited module without fixing four hundred existing errors first.
- Catch a factory whose create() return type you assumed wrongly.
- Find every call to a method that a Magento upgrade renamed, before deploying the upgrade.
More examples
First run on an existing module
The two-step pattern is the point: analyse, read, then baseline. Magento needs a raised memory limit for a wide scan; core's own static test passes --memory-limit=4G. Reach for the baseline when you adopt a module you did not write, and treat shrinking the file as ordinary maintenance rather than a project.
$ composer require --dev bitexpert/phpstan-magento phpstan/extension-installer
$ touch phpstan-baseline.neon
# See the real state of the module before deciding anything.
$ vendor/bin/phpstan analyse --memory-limit=4G
------ --------------------------------------------------------------
Line app/code/Vendor/Loyalty/ViewModel/Balance.php
------ --------------------------------------------------------------
48 Call to an undefined method
Magento\Catalog\Api\Data\ProductInterface::getQty().
------ --------------------------------------------------------------
[ERROR] Found 1 error
# Accept today's errors, then keep the build green from here on.
$ vendor/bin/phpstan analyse --generate-baseline
$ vendor/bin/phpstan analyse
[OK] No errorsThe error the extension removes
A collection factory class never exists in app/code; Magento generates it on demand from the collection class name. That is why plain PHPStan calls a perfectly correct Magento module broken, and why the extension is not optional. Note the filter value is passed as a condition array rather than concatenated into a string, which is what keeps the query bound.
<?php
// app/code/Vendor/Loyalty/Model/BalanceLookup.php
declare(strict_types=1);
namespace Vendor\Loyalty\Model;
use Vendor\Loyalty\Model\ResourceModel\Balance\CollectionFactory;
class BalanceLookup
{
public function __construct(
private readonly CollectionFactory $collectionFactory
) {
}
public function countForCustomer(int $customerId): int
{
$collection = $this->collectionFactory->create();
$collection->addFieldToFilter('customer_id', ['eq' => $customerId]);
return $collection->getSize();
}
}
// Without bitexpert/phpstan-magento:
// Call to an undefined method ...Balance\CollectionFactory::create().
// Instantiated class ...Balance\CollectionFactory not found.
// With it: no errors. The extension generates the factory type for analysis.A baseline you can read
The baseline is a list of exact errors with a count per file, not a blanket suppression. If a fourth untyped method appears in Points.php the count no longer matches and PHPStan reports it, so the file cannot quietly get worse. Regenerate it after a batch of fixes and watch the entry count fall; that number is a usable measure of progress on a legacy module.
# phpstan-baseline.neon (generated, then committed)
parameters:
ignoreErrors:
-
message: "#^Call to an undefined method Magento\\\\Catalog\\\\Api\\\\Data\\\\ProductInterface\\:\\:getQty\\(\\)\\.$#"
count: 1
path: app/code/Vendor/Loyalty/ViewModel/Balance.php
-
message: "#^Method Vendor\\\\Loyalty\\\\Model\\\\Points\\:\\:apply\\(\\) has no return type specified\\.$#"
count: 3
path: app/code/Vendor/Loyalty/Model/Points.php
Discussion