Using PHPStan, Psalm and PHPUnit Together as a Quality Chain
AI generated
@test
assert
PHPStan · Psalm · PHPUnit · Quality Chain · CI/CD
PHPStan, Psalm and PHPUnit
used together as a quality chain

PHPUnit finds runtime errors, but only if the corresponding tests exist. PHPStan and Psalm find type errors statically, before the code is ever executed. Only the combination of both approaches closes the gaps that each tool leaves open on its own. This article shows how to integrate PHPStan, Psalm and PHPUnit as a continuous quality chain in PHP projects.

20 min read PHPStan Level · Psalm Baseline · PHPUnit Coverage · CI Integration PHP 8.x · PHPUnit 10/11 · PHPStan 1.x · Psalm 5.x

1. Why a quality chain achieves more than individual tools

Every quality tool for PHP has a different perspective on the same code. PHPUnit checks actual runtime behavior: Does the method return the expected value? Is the exception thrown? How does the system behave under specific inputs? PHPUnit is blind to types. If a method returns a string but the declared type is int, PHPUnit only catches this if a test actually executes that exact path and checks the return value.

PHPStan and Psalm analyze code without executing it. They understand PHP types, PHPDoc annotations and generics more deeply than PHP itself, and they find type errors, null dereferences and incorrect API usage immediately, without any test needing to cover the affected path. The combination is stronger than the sum of its parts: PHPStan and Psalm reduce the class of errors for which PHPUnit tests need to be written, while simultaneously raising the quality of the tests themselves by uncovering type errors in test classes. The result is a quality chain that catches errors across multiple layers.

2. PHPStan: using levels, baselines and extensions sensibly

PHPStan works with analysis levels from 0 to 10. Level 0 checks basic syntax issues; level 8 checks strict types including nullable returns and generic types; level 10 also includes implicit mixed types. In existing projects it is rarely possible to start directly at level 8 or 9, since the number of errors would be prohibitive. The baseline solves this problem: vendor/bin/phpstan analyse --generate-baseline writes all current errors into a phpstan-baseline.neon file, which is then registered in the configuration as known errors. From this point on, PHPStan only flags new errors, the existing technical debt is frozen and reduced step by step.

Extensions add framework-specific understanding to PHPStan. phpstan-magento understands Magento's dependency injection mechanism and the object manager, and prevents false-positive errors that PHPStan would otherwise report for Magento-specific constructs without this extension. phpstan-phpunit understands PHPUnit mock objects and checks whether test assertions make sense. These extensions are not optional extras, they are a prerequisite for correct analysis in framework projects.


# phpstan.neon - configuration for Magento PHPUnit projects

parameters:
  level: 8
  paths:
    - src/app/code/Mironsoft
  excludePaths:
    - src/app/code/Mironsoft/*/Test/Integration
  bootstrapFiles:
    - src/app/bootstrap.php
  ignoreErrors:
    # Suppress known third-party issues
    - '#Call to an undefined method Magento\\Framework\\.*#'
  checkMissingIterableValueType: false

includes:
  - phpstan-baseline.neon
  - vendor/phpstan/phpstan-phpunit/extension.neon
  - vendor/phpstan/phpstan-strict-rules/rules.neon

# Generate baseline for existing projects:
# vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon

# Run analysis:
# vendor/bin/phpstan analyse --memory-limit=512M

3. Psalm: stricter types, taint analysis and plugins

Psalm is stricter than PHPStan in some areas and offers features PHPStan does not have. Taint analysis tracks user input through the code and reports when data flows into SQL queries, HTML output or shell commands without sanitization, a capability that is valuable for security analysis. Psalm also supports template types (generics) with higher granularity and can check covariance and contravariance rules for generic classes.

The Psalm baseline works similarly to PHPStan's: vendor/bin/psalm --set-baseline=psalm-baseline.xml writes the current error state and suppresses it in subsequent runs. Psalm levels range from 1 (strictest) to 8 (most tolerant), the reverse direction from PHPStan, which occasionally causes confusion when both tools are used together. Level 3 to 4 is a realistic target for most production projects. The Psalm plugin for PHPUnit (psalm/plugin-phpunit) checks whether mock methods are typed correctly and whether assertions expect the right types.


<!-- psalm.xml - configuration for PHP 8.x projects -->
<?xml version="1.0"?>
<psalm
  errorLevel="4"
  resolveFromConfigFile="true"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns="https://getpsalm.org/schema/config"
  xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
  findUnusedVariablesAndParams="true"
  findUnusedCode="true"
>
  <projectFiles>
    <directory name="src/app/code/Mironsoft" />
    <ignoreFiles>
      <directory name="src/app/code/Mironsoft/*/Test/Integration" />
      <directory name="vendor" />
    </ignoreFiles>
  </projectFiles>
  <issueHandlers>
    <!-- Suppress issues that are known false positives in Magento context -->
    <MixedArgumentTypeCoercion errorLevel="suppress" />
  </issueHandlers>
  <plugins>
    <pluginClass class="Psalm\PhpUnitPlugin\Plugin" />
  </plugins>
  <basefile>psalm-baseline.xml</basefile>
</psalm>

4. PHPUnit and static analysis: checking types in tests

Tests are code and deserve the same quality checks as production code. PHPStan and Psalm can analyze test classes and verify whether mocks are typed correctly, whether return values match the expected types, and whether assertSame() and assertEquals() calls are type-consistent. Without static analysis in tests, a mock can return an int while the object under test expects a string, and the test still passes, because PHP types are sometimes automatically converted at runtime.

The PHPStan extension for PHPUnit (phpstan/phpstan-phpunit) specifically checks whether the values passed to willReturn() match the mocked method's return type. This uncovers an entire class of test errors caused by poorly typed mocks that go unnoticed at runtime because PHP does not perform strict type checking inside the mock engine.


<?php
// Example: PHPStan catches mock type errors that PHPUnit misses at runtime

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Service;

use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\Model\Product;
use Mironsoft\Catalog\Service\PriceCalculator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class PriceCalculatorTest extends TestCase
{
    private MockObject&ProductRepositoryInterface $repository;
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(ProductRepositoryInterface::class);
        $this->calculator = new PriceCalculator($this->repository);
    }

    public function testCalculatesPriceWithTax(): void
    {
        $product = new Product(basePrice: 100.0, taxRate: 0.19);

        // PHPStan checks: does willReturn(Product) match getById(): ProductInterface ?
        $this->repository
            ->method('getById')
            ->with(42)
            ->willReturn($product); // PHPStan validates this is ProductInterface

        $result = $this->calculator->calculateGross(productId: 42);

        $this->assertSame(119.0, $result); // PHPStan validates float === float
    }
}

5. The interplay: what PHPStan finds, what PHPUnit finds

The interplay between static analysis and tests is most visible in how errors are classified. PHPStan finds: incorrect return types, null dereferences, calls to non-existent methods, faulty parameter types and unused imports. These errors exist regardless of what input the system receives at runtime, they are anchored in the code itself and can be checked statically. PHPUnit finds: incorrect runtime behavior for specific inputs, failed assertions, exceptions that are not thrown, and unexpected side effects. These errors are input-dependent and can only be discovered by actually running the code.

The overlap is small: a type error that PHPStan finds could theoretically also be caught by a PHPUnit test, but only if exactly the affected code path is covered by a test and the test checks the return value. In practice, no realistic test suite covers 100 percent of all type combinations. PHPStan complements PHPUnit wherever test coverage has gaps, and PHPUnit complements PHPStan wherever runtime behavior deviates from what can be statically analyzed, for example with dynamic type assignment, reflection, and Magento's object manager.

6. Quality chain in the CI pipeline

The quality chain in the CI pipeline consists of three consecutive stages: first the fast static analysis (PHPStan and Psalm), then unit tests, then integration tests. This order catches type errors already at the first stage, without needing to run the slower test suites. A pull request with PHPStan errors fails within the first minute, not after ten minutes of integration test runtime.

The staged structure also gives developers clear feedback about what kind of error occurred. A failed PHPStan stage signals a type error; a failed PHPUnit stage signals incorrect runtime behavior. This distinction is diagnostically valuable and speeds up error analysis. In GitLab CI the stages are configured as separate stages; in GitHub Actions as separate jobs with explicit dependencies (needs:).

Tool Error Class Timing Strength / Weakness
PHPStan Type errors, null deref, invalid API usage Static (before execution) Fast, input-independent; blind to runtime behavior
Psalm Generics, taint, unused code Static (before execution) Stricter than PHPStan; taint analysis for security
PHPUnit Incorrect runtime behavior At runtime (test execution) Input-dependent; only covers types where coverage exists
Combination Types + behavior + security Stages in CI pipeline Maximum coverage, fast feedback

7. Gradual rollout in existing projects

In an existing project with no static analysis, the first run of PHPStan or Psalm is often alarming: hundreds or thousands of errors, most of them legitimate type problems that have accumulated over the years. The baseline is the pragmatic solution: it freezes the current error state and makes it possible to integrate the quality chain into the CI pipeline immediately, without first having to fix all existing errors.

The strategy for a gradual rollout: first introduce PHPStan at level 4 with a baseline. In the following weeks, fix new errors (introduced during development) directly instead of adding them to the baseline. At the same time, reduce the baseline error count step by step, ten errors per sprint. After a few months, the baseline scope is small enough that the level can be raised to 6 or 8, again with a new baseline. Psalm can be introduced in parallel or afterward to deepen the analysis.

9. Summary

PHPStan, Psalm and PHPUnit form a quality chain that is stronger than any single tool. PHPStan and Psalm find type errors statically, without requiring test coverage. PHPUnit checks actual runtime behavior for concrete inputs. The combination closes the gaps of both approaches: static analysis finds the errors that no tests cover; PHPUnit finds the errors that static analysis cannot uncover without execution.

The practical rollout begins with the baseline, which freezes the current error state and enables an immediate start. PHPStan at level 4 to 6 is a realistic target for most PHP 8.x projects; Psalm adds taint analysis and stricter generics. The CI pipeline runs static analysis before unit tests, so type errors fail immediately without waiting for slow integration tests. This quality chain is the foundation for sustainably maintainable PHP code.

PHPStan + Psalm + PHPUnit - The Essentials at a Glance

PHPStan Baseline

--generate-baseline freezes existing errors. Enables an immediate start in CI without fixing all errors first. Raise the level step by step.

Psalm Taint Analysis

Psalm tracks user input through the code and statically finds SQL injection and XSS vulnerabilities, without test coverage.

PHPUnit Extensions

phpstan/phpstan-phpunit checks mock types and assertion consistency. psalm/plugin-phpunit adds PHPUnit-specific type rules.

CI Order

Static analysis first (fast), then unit tests, then integration. Type errors fail in minute one, not after 10 minutes of tests.