PHPStan — How to Use It

PHPStan — How to Use It

1. What is PHPStan and Why Do You Need It?

PHPStan is a static analyzer for PHP. It reads your source code without running it and catches common errors even before you run tests or deploy. Besides PHPStan, there are other alternatives like Psalm and Phan. But PHPStan is much more popular, boasting more GitHub stars, more downloads, and significantly better performance. Key features worth highlighting:

  1.  Flexible strictness levels. Ten levels (0–10) allow you to approach the 'ideal' gradually, instead of breaking legacy code overnight.
  2. Performance. On a Laravel project with 3,000 classes, analysis takes just two to three minutes, even on mid-range CI agents; Phan is typically 1.5–2 times slower on the same codebase.
  3. Rich ecosystem. Ready-made extensions are available for all popular frameworks, including Laravel, Livewire, Eloquent, and Doctrine. Psalm has fewer extensions, and Phan is barely maintained.

Ultimately, PHPStan is quicker to integrate, easier to use for those familiar with the Laravel ecosystem, and crucially - it lets you climb the 'ladder' from level 0 to 10 without breaking old code.

 2. Installation in a Laravel Project

The easiest way to add PHPStan to a Laravel project is via Composer:

composer require --dev phpstan/phpstan phpstan/extension-installer
composer require --dev nunomaduro/larastan

Larastan is an extension that helps PHPStan understand the 'magic' of Eloquent, facades, Blade directives, and more.

The minimal phpstan.neon configuration file in the project root:

includes:
    - vendor/nunomaduro/larastan/extension.neon

parameters:
    level: 9
    paths:
        - app
        - database
    excludePaths:
        analyse:
            - storage/*
            - bootstrap/cache/*
    checkMissingCallableSignature: true
    missingCheckedExceptionInThrows: true
    stubFiles:
        - stubs/ThirdPartyPayment.stub
    treatPhpDocTypesAsCertain: false
    tmpDir: var/cache/phpstan
    parallel:
        maxProcessCount: 8

Starting project analysis:

./vendor/bin/phpstan analyse

If you want to check only modified files, you can use the following command:

./vendor/bin/phpstan analyse --memory-limit=1G --paths-file=$(git diff --name-only origin/main)

3. What you get

After running, PHPStan spits out a list of all detected issues. For Laravel, it also supports the `--error-format=github` format, which turns errors into annotations directly within the Pull Request.

Example output:

 ------ -----------------------------------------------------------------
  Line   app/Services/LeadService.php
 ------ -----------------------------------------------------------------
  89     Method App\Services\LeadService::assignUser() should return void
         but returns Illuminate\Database\Eloquent\Model.
 129     Call to an undefined method App\Models\Lead::approove().
 ------ -----------------------------------------------------------------

[ERROR] Found 2 errors

You immediately see the file, line number, and a human-readable description of the problem.

4. Additional features offered by PHPStan

Typing callables

When developing complex projects, we often need to pass parameters of the callable type:

final class DealService
{
    /**
     * @param callable(Deal): bool $filter
     * @return list<Deal>
     */
    public function getFiltered(callable $filter): array
    {
        return Deal::all()->filter($filter)->all();
    }
}

If you forget to specify that $filter expects a Deal object, PHPStan (using the checkMissingCallableSignature flag) will prompt you: “Specify the callable signature.” This makes lambdas self-documenting, and your IDE can autocomplete the model's properties within the anonymous function.

 Generics Support

Repositories pattern are common in Laravel. Generics solve the issue of return types:

/**
 * @template TModel of \Illuminate\Database\Eloquent\Model
 */
final class Repository
{
    /** @var class-string<TModel> */
    private string $model;

    /** @param class-string<TModel> $model */
    public function __construct(string $model)
    {
        $this->model = $model;
    }

    /** @return TModel */
    public function find(int $id)
    {
        return $this->model::query()->findOrFail($id);
    }
}

$leadRepo = new Repository(Lead::class);
/** @var Lead $lead */
$lead = $leadRepo->find(7);   // PHPStan knows, that here is Lead, not mixed

Utility Types

PHPStan introduces dozens of utility pseudo-types. Some of the most useful include:

- non-empty-string — comes in handy when the system stores a required external client ID.

/** @param non-empty-string $externalId */
function pushToERP(string $externalId): void {}

- class-string<PaymentInterface> — clear type for fabrics:

interface PaymentInterface
{
    public function charge(int $cents): void;
}

/** @param class-string<PaymentInterface> $driver */
function registerPaymentDriver(string $driver): void {}

 

- positive-int - useful for specifying record IDs that must be strictly positive

Precise typing for arrays and generators

Tired of guessing what an iterator contains?

/**
 * @return Iterator<int, Lead>
 */
function loadNewLeads(): Iterator
{
    foreach (Lead::where('status', 'new')->cursor() as $lead) {
        yield $lead->id => $lead;
    }
}

If you accidentally use Contact instead of Lead, the analyzer will catch it immediately.

Strict Exception Declarations

The missingCheckedExceptionInThrows: true parameter forces you to declare potential exceptions in PHPDoc. This approach will be familiar to Java developers.

use App\Exceptions\EmailGatewayException;

final class Mailer
{
    /**
     * @throws EmailGatewayException
     */
    public function sendInvoice(Invoice $invoice, User $user): void
    {
        if (!$this->gateway->push($invoice, $user->email)) {
            throw new EmailGatewayException('SMTP error');
        }
    }
}

If a RuntimeException crops up inside a method but isn't declared in @throws, PHPStan will report an error.

Adding covariance support to our project.

With a standard @template, the parameter is considered *invariant*: you can't use Repository<Lead> where Repository<Model> is expected, even though Lead extends Model. By using @template-covariant, we're telling PHPStan: "I only return objects of this type; I never accept them as input." This allows an instance with a more specific type to be safely assigned to a variable expecting a broader type.

The key rule is: a covariant template parameter must not appear in input arguments; doing so would be a type violation.

Example #1 — Exporter

namespace App\Export;

use Iterator;

/**
 * @template-covariant TModel of \Illuminate\Database\Eloquent\Model
 */
interface Exporter
{
    /**
     * @return Iterator<int, TModel>
     */
    public function export(): Iterator;
}

 

The exporter outputs models but doesn't accept them back, therefore covariance is allowed.

Concrete implementation:

namespace App\Export;

use App\Models\Lead;
use Generator;

final class LeadCsvExporter implements Exporter
{
    /**
     * @return Generator<int, Lead>
     */
    public function export(): Generator
    {
        foreach (Lead::cursor() as $lead) {
            yield $lead;  // PHPStan знает, что тут Lead
        }
    }
}

Usage:

/** @var Exporter<\Illuminate\Database\Eloquent\Model> $exp */
$exp = new LeadCsvExporter();   // ОК: Lead is Model

 

Without template-covariant, such an assignment would result in an error: the types are "too specific".

Example №2 — ReadonlyCollection

/**
 * @template-covariant T of object
 * @implements \IteratorAggregate<int, T>
 */
final class ReadonlyCollection implements \IteratorAggregate
{
    /** @var list<T> */
    private array $items;

    /** @param list<T> $items */
    public function __construct(array $items)
    {
        $this->items = $items;
    }

    /** @return \ArrayIterator<int, T> */
    public function getIterator(): \ArrayIterator
    {
        return new \ArrayIterator($this->items);
    }
}

The collection only yields elements; it has no add() or set() methods, so covariance is safe. Now:

$leads = new ReadonlyCollection([new Lead(), new Lead()]);
$models = new ReadonlyCollection([new Lead()]);

/** @var ReadonlyCollection<\Illuminate\Database\Eloquent\Model> $anyModels */
$anyModels = $leads;    // Valid thanks to covariance

Example №3 — Report paginator

/**
 * @template-covariant TReport of \App\DTO\Report
 */
interface Paginator
{
    /**
     * @return list<TReport>
     */
    public function page(int $page, int $perPage = 20): array;
}

LeadReportPaginator implements Paginator<LeadReport>, but we can safely pass it to a method expecting Paginator<Report>.

How to avoid mistakes

  1. Check that the parameter never appears in the input arguments.
  2. Don't assign it to properties if those properties can be overwritten externally.
  3. Provide clear constraints (of \BaseClass) — this helps both developers and PHPStan.

 

5. The hardest part — integrating it into an existing project

Most legacy projects have tens of thousands of lines without type hints. "Turning PHPStan up to the max" means getting thousands of errors and abandoning the idea. So, there's only one way — gradual integration.

1. Set the level to 0 and add a baseline:

./vendor/bin/phpstan analyse --generate-baseline

The `phpstan-baseline.neon` file contains all current errors and silences them. Now, new code must be written with zero errors from the start, while the old code is gradually cleaned up: every release or two, remove a few lines from the baseline and fix the specific issues.

2. Every couple of weeks, raise the level (`level: 2`, then `3`...). The main rule is: the build must always be green.

3. Most importantly, don't forget to implement stub files—they act as a shield against libraries you don't control.

Sometimes we can't modify third-party code, like a bank's SDK that violates every imaginable rule. The solution is a stub file.

stubs/ThirdPartyPayment.stub:

<?php declare(strict_types=1);

namespace Bank\SDK;

/**
 * @phpstan-type Currency string
 */
interface Gateway
{
    /**
     * @param non-empty-string $clientId
     * @param positive-int     $amountCents
     * @param Currency         $currency
     *
     * @throws \Bank\SDK\Exception\TransferFailed
     */
    public function transfer(string $clientId, int $amountCents, string $currency): void;
}

In our code, PHPStan relies on stubs rather than the "messy" actual implementation. Errors in the stubs cannot be ignored, ensuring that types remain consistent.

6. How do you figure out the types and debug?

You've configured the levels, introduced generics, enabled `checkMissingCallableSignature`, and now you see this message in the console:

Parameter #1 $lead of method App\Services\LeadNotifier::notify()
expects App\Models\Lead, Iterator<int, Lead>&non-empty-string given.

You're looking at this hybrid type Iterator<int, Lead>&non-empty-string and wondering where non-empty-string came from. In cases like these, interactively dumping the actual, already inferred type directly in your code can help.

How \PHPStan\dumpType() works:

  1. The function exists only for the analyzer; it's not present in the runtime code.
  2. During analysis, PHPStan replaces the call with a "virtual" function and prints the type of the variable or expression.
  3. Once you've figured it out, you remove the call or wrap it in `if (false)` so it doesn't get in the way later.

Simple example

namespace App\Services;

use App\Models\Lead;

final class LeadExporter
{
    /**
     * @return list<Lead>
     */
    public function export(): array
    {
        $leads = Lead::query()
            ->where('status', 'new')
            ->pluck('id')    // ❌ typo: pluck returns Collection<string>
            ->all();

        \PHPStan\dumpType($leads); // We will see: array<int, non-empty-string>

        return $leads; // Now we understand problemm
    }
}

 

You can run it:

vendor/bin/phpstan analyse app/Services/LeadExporter.php

and the output shows the following line among the usual errors:

Dump of $leads:
array<int, non-empty-string>

It's obvious: we got non-empty-string instead of Lead, so we must have used the wrong ORM chain method.

Helpful tips and tricks

1. Check types step by step  

$result = $query->get();
\PHPStan\dumpType($result);         // Collection<int, Lead>
$result = $result->take(10);
\PHPStan\dumpType($result);         // Collection<int, Lead>
$result = $result->toArray();
\PHPStan\dumpType($result);         // array<int, Lead>

It's so easy to track down where the information about the keys or the generic parameter went wrong.

2. Gebug of complex generics  

/** @var Repository<Deal> $repo */
$repo = resolve(Repository::class);
$deal = $repo->find(42);
\PHPStan\dumpType($deal); // Expecting Deal, not mixed

3. Check union types in runtime flags

/** @var Lead|false $lead */
$lead = Lead::find($id);
\PHPStan\dumpType($lead);            // Lead|false

4. Use in tests or fixtures

Sometimes it's handy to leave "telemetry" in the test code:

if (false) {           // newer runs
    \PHPStan\dumpType(Lead::factory()->make());
}

The analyzer sees this code, but it's ignored in production.

The --debug flag ≠ dumpType, but it's also useful

The --debug CLI flag outputs a list of files, the exact processing time, and peak memory consumption. This helps identify which service is slowing down the analysis, but it doesn't show variable types. For the actual types, use dumpType().

When a project grows and generics with `class-string<...>` turn into chains of three or four intersections, it becomes hard to keep track of all the resulting types mentally. `\PHPStan\dumpType()` is a quick way to ask the analyzer, "How do you see this?" and find an error in a minute that might otherwise take an hour.

Key Takeaways

PHPStan integrates quickly into Laravel projects, especially with Larastan. It provides:

  • instant feedback on common type errors;
  • support for generics, callable signatures, and useful pseudo-types;
  • the ability to document exceptions like in Java;
  • a flexible scale of strictness levels and a baseline for legacy code.

Popular Posts

My most popular posts

Maximum productivity on remote job
Business

Maximum productivity on remote job

I started my own business and intentionally did my best to work from anywhere in the world. Sometimes I sit with my office with a large 27-inch monitor in my apartment in Cheboksary. Sometimes I’m in the office or in some cafe in another city.

Hello! I am Sergey Emelyanov and I am hardworker
Business PHP

Hello! I am Sergey Emelyanov and I am hardworker

I am a programmer. I am an entrepreneur in my heart. I started making money from the age of 11, in the harsh 90s, handing over glassware to a local store and exchanging it for sweets. I earned so much that was enough for various snacks.

Hire Professional CRM developer for $25 per hour

I will make time for your project. Knowledge of Vtiger CRM, SuiteCRM, Laravel, Vue.js, Wordpress. I offer cooperation options that will help you take advantage of external experience, optimize costs and reduce risks. Full transparency of all stages of work and accounting for time costs. Pay only development working hours after accepting the task. Accept PayPal and Payoneer payment systems. How to hire professional developer? Just fill in the form

Telegram
@sergeyem
Telephone
+4915211100235
Email