Greenlight

Documentation

Extend

On this page

Static analysis with PHPStan

Greenlight includes a PHPStan extension. The extension supplies information about custom expectation matchers and data-provider shape rules. It also supplies native matcher constraints that the PHP type system cannot express.

Setup

Install PHPStan 2.2 or a later 2.x release as a development dependency:

composer require --dev phpstan/phpstan:^2.2

Include the extension in your PHPStan configuration. Set the Greenlight configuration files:

includes:
    - vendor/greenlight/greenlight/extension.neon

parameters:
    greenlight:
        configFiles:
            - greenlight.php

Use configFiles only for custom matcher checks. The data-provider and native matcher rules work without it.

If you use phpstan/extension-installer, it registers the include for you. Set only the greenlight.configFiles parameter for custom matcher checks.

Double checks

The extension checks a constant method name in Doubles::callsTo() against the doubled type:

$events = $doubles->spy(EventPublisher::class);

$doubles->callsTo($events, 'publish'); // checked against EventPublisher
$doubles->callsTo($events, 'publsih'); // fails analysis: unknown method

PHP method names are not case-sensitive. Thus, the check accepts a method name with different letter case. Greenlight checks a dynamic method name at run time.

Method errors use the greenlight.doubles.callsToMethod identifier.

For a constant method name, the extension also supplies the declared argument types for each recorded call:

$calls = $doubles->callsTo($events, 'publish');
$event = $calls[0][0]; // the declared type of EventPublisher::publish() argument 1

The result keeps optional parameters and variadic parameters. A dynamic method name keeps the documented list<list<mixed>> return type.

The extension also checks known types in mock(), stub(), and spy(). Final classes, readonly classes, enums, and traits cause analysis errors. Use an interface or a non-final class.

Factory type errors use the greenlight.doubles.doubleableType identifier.

Attribute argument checks

The extension reports constant attribute arguments that Greenlight cannot use:

Errors have identifiers under greenlight.attributeArgument.* (retry, skipUnless, timeout, resource).

Float argument checks

PHPStan does not have float range types. The extension checks constant values for these method arguments:

Coverage errors use greenlight.coverageBuilderArgument.*. Expectation errors use greenlight.expectationArgument.tolerance and greenlight.expectationArgument.duration. Greenlight checks unresolved values at run time.

Native matcher constraints

toThrow() can constrain the exception message with an exact string or a regular expression. A Throwable instance requires the exact object. A typed callback can specify and check the throwable:

Expect::calling($callback)->toThrow(DomainException::class, message: 'Exact message');
Expect::calling($callback)->toThrow(DomainException::class, matching: '/message/i');
Expect::calling($callback)->toThrow($failure);
Expect::calling($callback)->toThrow(
    static function (DomainException $error): void {
        Expect::value($error->getPrevious())->toBeInstanceOf(LengthException::class);
    },
);

A call that supplies both message: and matching: causes the greenlight.toThrow.messageConstraint error. Greenlight also rejects the call at run time. Thus, the constraint does not depend on PHPStan.

A call that supplies a message constraint with a throwable callback causes the greenlight.toThrow.callbackConstraint error. The generic closure signature reports incompatible parameter and return types. The greenlight.toThrow.callback error reports constraints that the signature cannot express. Greenlight applies all callback checks at run time.

A call that supplies a message constraint with a Throwable instance causes the greenlight.toThrow.instanceConstraint error. Greenlight also rejects the call at run time.

Expect::calling() requires a callable through its native PHP parameter type. PHPStan reports an ordinary argument-type error for a non-callable argument. Expect::value() does not expose toThrow(). PHPStan reports an undefined method for that call, even without the Greenlight extension.

Mock plan checks

The extension keeps the doubled type through the MockPlan configurator. It checks constant method names before a test runs.

The extension reports these mock plan errors:

The extension checks the value type of ArgumentMatcher<T>. It reports an error when a known T cannot overlap the declared parameter type. It leaves mixed and unresolved matcher types for run-time validation.

Errors use the greenlight.mockPlan.* identifiers. The final identifier part is method, arity, argument, cardinality, answer, or capturePosition. Greenlight validates each plan at run time when PHPStan cannot resolve a method name or argument list.

For a constant method name and capture position, captureArgument() keeps the selected parameter type. Its value() and values() results use that type. A dynamic method name or position keeps the documented mixed value type.

Custom matcher checks

These checks apply when a plugin adds matchers through ExpectationExtension. See plugins. Built-in matchers such as toBe() are real methods on immediate and temporal value expectations. Their signatures do not require the extension.

Custom matcher calls use __call() at run time. PHPStan cannot infer their signatures from __call(). The extension loads configuration files and reflects each custom matcher closure.

The declared closure return type must be compatible with bool. PHPStan leaves an absent or mixed return type unresolved.

For example, use a plugin with these matchers:

final class DigestMatchers implements ExpectationExtension
{
    public function matchers(): array
    {
        return [
            'toBeValidUuid' => static fn(string $subject): bool =>
                \preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $subject) === 1,
            'toHaveDigestLength' => static fn(string $subject, int $length): bool =>
                \strlen($subject) === $length,
        ];
    }
}

The extension checks calls against those closure signatures:

Expect::value($id)->toBeValidUuid();     // checked: name, arguments, types
Expect::value($id)->toBeValidUuuid();    // fails analysis: unknown matcher
Expect::value($hash)->toHaveDigestLength('six'); // fails analysis: expects int
Expect::value(123)->toBeValidUuid();      // fails analysis: expects a string subject

The first closure parameter declares the accepted subject type. PHPStan gets this type from value() and return-value probes.

Each custom matcher returns the same typed chain. Thus, later custom matchers receive the same subject type.

The same checks apply to temporal expectations:

Expect::calling(fn(): string => $hash)->returnValue()->eventually()
    ->within(1.0)
    ->toHaveDigestLength(6);

Temporal value expectations declare native matcher methods through a PHP trait. Generic PHPDoc types preserve the probe return type across these methods.

If configuration files register one matcher name with different parameter or return types, analysis fails. PHPStan does not select one signature.

Subject-type errors use the greenlight.extensionMatcher.subjectType identifier. Return-type errors use the greenlight.extensionMatcher.returnType identifier. Matcher argument errors keep the PHPStan error identifier.

To give an IDE the same signatures, generate the helper file:

vendor/bin/greenlight ide-helper

Expectation subject types

PHPStan narrows the original subject after a synchronous type expectation passes:

/** @var FileCoverage|null $file */
Expect::value($file)->not()->toBeNull();

$file->coveredLines; // PHPStan knows that this value is FileCoverage.

PHPStan applies this refinement to these native matchers:

The call must contain Expect::value() or Greenlight\expect() and the matcher in the same expression. PHPStan also follows because() and not() in that expression.

A stored expectation does not narrow the original subject. A temporal expectation does not narrow a value outside its probe.

Constant expectation argument checks

The extension reports constant expectation arguments that Greenlight cannot use:

Errors have identifiers under greenlight.expectationArgument.* (pattern, json, reason). PHPStan checks constant values before run time. Greenlight checks unresolved values at run time.

Test method checks

The extension reports a #[Test] method that Greenlight cannot run. A test method must be public, non-static, and concrete.

A test method with required parameters must have a #[DataRow] or #[DataSet] attribute. Without a data set, Greenlight calls the method with no arguments.

Errors have identifiers under greenlight.testMethod.* (visibility, static, abstract, dataSet).

Method-level test metadata such as #[Group], #[Skip], #[DataRow], and #[NoExpectations] has no effect without #[Test]. The extension reports the unused attribute with greenlight.testAttribute.noEffect. Lifecycle and coverage attributes do not require #[Test].

Test constructor checks

A concrete class that contains or inherits a test can omit its constructor. If the class declares a constructor, the constructor must be public. Each required constructor parameter must have one class or interface type. A parameter must have a default value if it has a scalar, union, intersection, or object type, or no type. Greenlight can then resolve supported service types at run time.

Errors have identifiers under greenlight.testConstructor.* (visibility, parameter).

Lifecycle hook checks

The extension reports a #[Before] or #[After] method that Greenlight cannot run. A lifecycle hook must be public, non-static, and concrete. It must not require arguments.

Errors have identifiers under greenlight.lifecycleMethod.* (visibility, static, abstract, parameters).

Conditional skip checks

For #[SkipUnless], PHPStan checks the transferred arguments against the constructor of the referenced condition. It reports invalid argument counts and types before a worker evaluates the condition.

Errors use greenlight.skipUnlessCondition.arity and greenlight.skipUnlessCondition.argument.

Data provider checks

The extension validates data providers before a test runs. If you run analysis first, PHPStan reports a broken provider before a test can report the error:

#[Test]
#[DataSet('sums')]
#[DataRow([2, 2, 4])]
public function adds(int $left, int $right, int $expected): void { ... }

/** @return iterable<string, array{int, int, int}> */
public static function sums(): iterable
{
    yield 'ones' => [1, 1, 2];       // checked against (int, int, int)
}

Providers shared by multiple test classes receive the same checks:

#[DataSet(ArithmeticDataSets::class, 'sums')]

Typical messages:

Data provider sums() for adds() does not exist on PriceTest.
Data provider PriceTest::sums() must be public and static.
Data provider PriceTest::sums() must return an iterable of argument arrays. It returns string.
Data provider sums() row argument #3 for adds() has type string, but the parameter requires int.
#[DataRow] supplies 2 arguments, but adds() expects exactly 3.

Some rows have no exact shape in PHPStan. For example, a provider can have the type iterable<array<mixed>>. PHPStan requires only that each row is an array. Greenlight checks the array contents at run time.

Errors have identifiers under greenlight.dataProvider.* (provider, parameters, returnType, keyType, empty, duplicateKey, arity, argument). Thus, you can suppress a deliberate exception inline:

#[DataSet('doesNotExist')] // @phpstan-ignore greenlight.dataProvider.provider (proves the runtime error path)

Native matcher subject reference

Some native matchers accept only specified subject types. The extension reports a known incompatible subject before the test runs:

Required subjectMatchers
string or iterabletoContain()
array, Countable, or TraversabletoHaveCount()
string, array, Countable, or TraversabletoBeEmpty()
string, array, or CountabletoHaveLength()
array or ArrayAccesstoHaveKey()
arraytoContainSubset()
int or floatNumeric comparison matchers and toBeWithin()
stringString and JSON matchers

Subject errors use the greenlight.nativeMatcher.subjectType identifier. A string subject also requires a string toContain() needle. This error uses greenlight.toContain.needleType.

The extension does not report these errors for a mixed subject. Greenlight validates unresolved subject types at run time.