Static Analysis

PHPStan with the Magento extension, the rule level Magento itself runs, and a baseline that stops a legacy module blocking the build.

Syntaxvendor/bin/phpstan analyse --configuration phpstan.neon --level 1 app/code/Vendor/Loyalty

PHPStan 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

Example · yaml
# 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:
    level: 1

    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

    checkPhpDocMissingReturn: true
    reportUnmatchedIgnoredErrors: false

    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.

Example · bash
$ 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 errors

The 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.

Example · php
<?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.

Example · yaml
# 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

  • Be the first to comment on this lesson.