Cannot call method getName() on mixed. Type PHPStan has no idea what type this variable is. Trace it back to its source: add a return type to the method that produced it, or narrow the type with an instanceof check before calling the method.
Welcome, traveller
You've arrived at the wrong circle.
Or exactly the right one.
Looking for PHPStan, the static analysis tool? We're not it — but we know why you're here. Everyone has called it PHP Satan at least once.
The The First Error
You ran PHPStan for the first time and your terminal turned red. Welcome, sinner.
composer require phpstan/phpstan --dev
Create a phpstan.neon config file at the root of your project:
parameters:
level: 0
paths:
- src
Run the analysis:
vendor/bin/phpstan analyse
PHPStan errors follow a consistent format:
------ -----------------------------------------------------------
Line src/Services/UserService.php
------ -----------------------------------------------------------
42 Cannot call method getName() on App\Models\User|null.
78 Parameter #1 $id of method findUser() expects int, string given.
------ -----------------------------------------------------------
[ERROR] Found 2 errors
Each entry tells you:
The final line is the one that breaks your CI. We’ll fix that.
PHPStan has 11 levels (0–10). Each level is stricter than the last.
| Level | What it checks |
|---|---|
| 0 | Basic checks: unknown classes, functions, methods |
| 1 | Possibly undefined variables, unknown magic methods |
| 2 | Unknown methods checked on all expressions |
| 3 | Return types, types assigned to properties |
| 4 | Basic dead code checks |
| 5 | Checks argument types |
| 6 | Report missing typehints |
| 7 | Report partially wrong union types |
| 8 | Report calling methods on nullable types |
| 9 | Be strict about the mixed type |
| 10 | Level 9 on steroids — reports implicit mixed from missing types too |
On an existing project, find the highest level that produces zero errors and start there:
vendor/bin/phpstan analyse --level=0 # clean? try 1, 2, 3...
Once you’ve found your starting point, commit the config and bump by one level per sprint. Do not try to jump from 0 to 9 in a single afternoon. That way lies existential despair.
# Cleaner output in CI
vendor/bin/phpstan analyse --no-progress
# Increase memory if analysis runs out (common on large codebases)
vendor/bin/phpstan analyse --memory-limit=1G
# Analyse a specific file
vendor/bin/phpstan analyse src/Services/UserService.php
You have survived the First Circle. The errors you see are not punishment — they are information. PHPStan is, annoyingly, almost always right.
The The Void of Mixed
Cannot call method on mixed. You reach for @phpstan-ignore. PHPStan weeps.
mixed?mixed means PHPStan has no idea what type a variable is. It can be anything — a string, an object, null, a bowl of porridge. PHPStan refuses to make assumptions and will block any method call or property access on it.
Common sources of mixed:
$data['key'] — PHPStan doesn’t know what’s inside)The most direct solution. If you control the function, type it:
// Before — $data is mixed
function processUser($data): void {
echo $data->getName(); // Error: Cannot call method on mixed
}
// After
function processUser(User $data): void {
echo $data->getName(); // Fine
}
When the type genuinely varies, narrow it before use:
function renderValue(mixed $value): string {
if ($value instanceof User) {
return $value->getName();
}
if (is_string($value)) {
return $value;
}
if (is_array($value)) {
return implode(', ', $value);
}
return '';
}
PHPStan tracks the narrowed type through each branch. After instanceof User, it knows $value is a User.
When you can’t change the function signature (third-party code, interface constraints), use a @var annotation to tell PHPStan what you know:
/** @var User $user */
$user = $container->get(User::class);
echo $user->getName(); // PHPStan now knows $user is User
Use this sparingly — it’s a promise to PHPStan that you’re making, not something it can verify.
PHPStan supports detailed array type declarations in PHPDoc:
/**
* @param array{id: int, name: string, email: string} $data
*/
function createUser(array $data): User {
return new User($data['id'], $data['name'], $data['email']);
}
Or for lists of items:
/**
* @param array<int, User> $users
* @return array<int, string>
*/
function getUserNames(array $users): array {
return array_map(fn(User $u) => $u->getName(), $users);
}
If other parts of your code are seeing mixed, it’s often because a method doesn’t declare its return type. Add it:
// Before
class UserRepository {
public function find(int $id) { // returns mixed as far as PHPStan knows
return $this->db->query("SELECT ...");
}
}
// After
class UserRepository {
public function find(int $id): ?User {
// PHPStan now knows this returns User or null
}
}
assert() Escape HatchWhen you’re absolutely certain of a type and can’t narrow it any other way:
$result = $this->container->get('user');
assert($result instanceof User);
// $result is now typed as User
assert() throws at runtime if wrong, so this also improves runtime safety. PHPStan respects it for type narrowing.
The The Inferno of Level Nine
You enabled level 9 on a Monday. You disabled it on a Tuesday. There is no shame in this.
Levels 0–5 are relatively forgiving. Levels 6–9 are where PHPStan earns its nickname.
Level 6 starts requiring type hints on all functions and methods. If you have a large codebase without types, this is where the flood begins.
Level 8 reports calling methods or accessing properties on nullable types — if a value could be null, PHPStan requires you to handle that case explicitly.
Level 9 makes mixed essentially unusable. If PHPStan can’t prove the type, you cannot use it.
Level 10 (introduced in PHPStan 2.0) takes level 9 further — it also catches implicit mixed coming from missing type hints, not just explicit mixed declarations.
class OrderService {
public function getTotal(int $orderId): float {
$order = $this->repository->find($orderId); // returns ?Order
return $order->getTotal(); // Error: Cannot call method on Order|null
}
}
PHPStan is right. find() can return null. You have three options:
Option A — Handle null explicitly:
$order = $this->repository->find($orderId);
if ($order === null) {
throw new OrderNotFoundException($orderId);
}
return $order->getTotal(); // PHPStan is happy: $order is Order here
Option B — Use the null coalescing operator or nullsafe operator:
return $this->repository->find($orderId)?->getTotal() ?? 0.0;
Option C — Change your repository to throw instead of returning null:
public function findOrFail(int $id): Order {
$order = $this->find($id);
if ($order === null) {
throw new OrderNotFoundException($id);
}
return $order;
}
Option C is often the cleanest — it moves the null handling to one place.
// Level 6 error: Method has no return type specified
public function getUsers() {
return $this->db->fetchAll('SELECT * FROM users');
}
// Fix
public function getUsers(): array {
return $this->db->fetchAll('SELECT * FROM users');
}
If the method can return multiple types, use a union:
public function findUser(int $id): User|null {
// or the shorthand: ?User
}
Any method that doesn’t return a value should declare void. Level 6+ will flag this if missing:
// Before
public function sendWelcomeEmail(User $user) {
$this->mailer->send($user->getEmail(), '...');
}
// After
public function sendWelcomeEmail(User $user): void {
$this->mailer->send($user->getEmail(), '...');
}
Don’t try to go from level 5 to level 9 in one commit. Instead:
composer require phpstan/phpstan-strict-rules --dev
# phpstan.neon
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
parameters:
level: 8
This package adds rules like requiring booleans in conditions, disallowing loose comparisons (==), and requiring strict operand types in arithmetic. It is complementary to the level system — not a substitute for any particular level.
never Return TypeFor completeness: a method that always throws and never returns can use never:
public function fail(string $message): never {
throw new RuntimeException($message);
}
PHPStan understands this and will correctly narrow types after a call to such a method.
The The Ancient Swamp
You inherited 80,000 lines of untyped PHP and someone said 'just add PHPStan'. They are gone now.
You can’t fix 3,000 PHPStan errors before your next deploy. But you also can’t ignore them forever. The baseline is the solution: freeze the current errors so new code must be clean, while you chip away at the old.
vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon
This creates a phpstan-baseline.neon file that records every current error. Include it in your config:
# phpstan.neon
includes:
- phpstan-baseline.neon
parameters:
level: 5
paths:
- src
Now running PHPStan reports zero errors — because all existing errors are suppressed. But the moment you introduce a new error, it will be caught.
Commit both files. The baseline is not a shameful secret. It’s a debt tracker.
parameters:
ignoreErrors:
-
message: '#Cannot call method getName\(\) on App\\Models\\User\|null#'
path: src/Services/UserService.php
count: 3
-
message: '#Method App\\Repositories\\OrderRepository::find\(\) has no return type#'
path: src/Repositories/OrderRepository.php
count: 1
Each entry is scoped to a specific file and counts occurrences. If you fix some but not all, regenerate the baseline — it will reduce the count.
After fixing a batch of errors, update the baseline to reflect the improvement:
vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon
The baseline should shrink over time. Track it in git — watching the line count drop is oddly satisfying.
Step 1 — Get to zero errors at your target level with a baseline:
vendor/bin/phpstan analyse --level=6 --generate-baseline phpstan-baseline.neon
Step 2 — Add PHPStan to CI (it now passes because of the baseline).
Step 3 — Require new code to be clean. Establish a team norm: no new PHPStan errors without a fix. The baseline covers old sins; new sins are forbidden.
Step 4 — Reduce the baseline in sprints. Allocate time each sprint to fix a chunk of baseline errors. When you fix a file completely, the baseline auto-shrinks on next regeneration.
Step 5 — Raise the level. Once the baseline for level N is empty, generate a new baseline at level N+1 and repeat.
Auto-generated files (migrations, DTOs from API specs, Eloquent models) often produce many false errors. Exclude them:
parameters:
excludePaths:
- database/migrations/*
- app/Models/Generated/*
- src/Generated/*
phpstan-baseline.neon.The The Purgatory of @phpstan-ignore
You fought PHPStan for two hours. It turns out PHPStan was right. It usually is.
Before suppressing an error, take a moment. PHPStan’s false positive rate is genuinely low. If it’s flagging something, there’s often a real issue — or at minimum, code that could be clearer.
Ask yourself:
If the answer is “none of the above, and this is definitely fine at runtime” — then suppress it. But suppress it precisely.
/** @phpstan-ignore-next-line */
$result = $this->magicContainer->get('user'); // returns mixed for legitimate reasons
This is the nuclear option. It silences everything on the next line.
Suppress only a specific error identifier:
/** @phpstan-ignore argument.type */
$this->process($this->container->get(SomeService::class));
To find the right identifier, look at the error output — PHPStan prints it in brackets after the message (available since PHPStan 1.10):
42 Parameter #1 $service of method process() expects SomeService, mixed given. [argument.type]
Run PHPStan on the offending file and copy the identifier directly from the output. Don’t guess — identifiers vary by error type and guessing a wrong one will silently do nothing.
Targeted suppression is better than @phpstan-ignore-next-line because it will fail if the error changes — a safety net against suppressing the wrong thing.
@phpstan-ignore with ReasonEven better, add a reason:
// @phpstan-ignore argument.type (3rd-party SDK returns untyped mixed, see issue #142)
$client->callDynamic();
Future you will thank present you.
For systematic suppressions across the whole codebase, use phpstan.neon:
parameters:
ignoreErrors:
# Suppress a specific message pattern
- '#Property .+ has no typehint specified#'
# Suppress in a specific path only
-
message: '#Call to an undefined method .+::scopeActive\(\)#'
path: app/Models/*
# Suppress a specific error identifier
-
identifier: argument.type
path: app/Legacy/*
Use patterns (regex) to be precise. Overly broad patterns will hide real errors.
The single most impactful thing you can do when working with a framework is install its PHPStan extension. These add framework-specific type knowledge that PHPStan doesn’t have out of the box.
Laravel — Larastan:
composer require larastan/larastan --dev
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
Teaches PHPStan about Eloquent models, facades, collection generics, and magic methods.
Doctrine:
composer require phpstan/phpstan-doctrine --dev
Symfony:
composer require phpstan/phpstan-symfony --dev
PHPUnit:
composer require phpstan/phpstan-phpunit --dev
When a third-party library has no extension and returns mixed everywhere, write a stub file to declare the types yourself:
// stubs/ThirdPartyClient.php
<?php
namespace ThirdParty;
class Client {
/** @return array<string, mixed> */
public function request(string $method, string $url): array {}
}
Register it in your config:
parameters:
stubFiles:
- stubs/ThirdPartyClient.php
PHPStan will use these declarations instead of the actual library files.
PHP 8.2+ deprecated dynamic properties. If you’re using them (or your framework does), declare them explicitly or use #[\AllowDynamicProperties]:
// For your own classes, add a @property docblock
/**
* @property string $dynamicField
*/
class MyModel extends BaseModel {} The The Abyss of Blocked Deploys
Your most common commit message is 'fix phpstan'. You are not alone.
# .github/workflows/phpstan.yml
name: PHPStan
on: [push, pull_request]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none # faster — no need for coverage in static analysis
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G
--no-progress removes the animated dots. CI logs don’t need them, and they add noise.
PHPStan caches analysis results between runs. Without caching this on CI, every run re-analyses everything from scratch.
- name: Cache PHPStan
uses: actions/cache@v4
with:
path: /tmp/phpstan-cache
key: phpstan-${{ runner.os }}-${{ hashFiles('composer.lock', 'phpstan.neon') }}
restore-keys: |
phpstan-${{ runner.os }}-
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G
Add the cache path to your phpstan.neon:
parameters:
level: 8
paths:
- src
tmpDir: /tmp/phpstan-cache
On a warm cache, PHPStan only analyses changed files. On large codebases, this can cut analysis time by 80%.
PHPStan is memory-intensive on large codebases. The default PHP memory limit is often too low.
vendor/bin/phpstan analyse --memory-limit=1G
Or set it in phpstan.neon for consistency across environments:
parameters:
level: 8
paths:
- src
# Use a PHP ini override
php -d memory_limit=1G vendor/bin/phpstan analyse
When first introducing PHPStan to CI on an existing project, you might want it to report without blocking merges while you clear the baseline:
- name: Run PHPStan (non-blocking)
run: vendor/bin/phpstan analyse --no-progress || true
# The '|| true' means the step always passes
Once you have a clean baseline, remove the || true and let it block.
For speed on large monorepos, you can limit analysis to changed files. This is an advanced setup and has trade-offs (PHPStan can miss cross-file issues), but it’s useful for fast feedback in PRs:
- name: Get changed PHP files
id: changed-files
run: |
echo "files=$(git diff --name-only origin/main...HEAD | grep '\.php$' | tr '\n' ' ')" >> $GITHUB_OUTPUT
- name: Run PHPStan on changed files
if: steps.changed-files.outputs.files != ''
run: vendor/bin/phpstan analyse --no-progress ${{ steps.changed-files.outputs.files }}
PHPStan supports multiple output formats for CI integration:
# GitHub Actions annotations (errors appear as inline PR annotations)
vendor/bin/phpstan analyse --error-format=github
# JSON output for custom tooling
vendor/bin/phpstan analyse --error-format=json > phpstan-report.json
# Checkstyle for Jenkins/other tools
vendor/bin/phpstan analyse --error-format=checkstyle
For GitHub Actions, the github format is particularly useful — errors appear as inline annotations directly on the diff.
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G
A solid team config:
includes:
- phpstan-baseline.neon # if you have one
parameters:
level: 8
paths:
- src
- tests
tmpDir: /tmp/phpstan-cache
checkMissingIterableValueType: false # ease the pain on mixed arrays
reportUnmatchedIgnoredErrors: true # fail if an ignore rule no longer matches
reportUnmatchedIgnoredErrors: true is important — it ensures that when you fix an error, the ignore rule for it also gets cleaned up. Without it, your config accumulates dead suppressions.
If PHPStan is in CI, passing cleanly, with a shrinking baseline — you’ve done it. You’ve tamed PHP Satan. He’s now just a slightly grumpy coworker who catches your type errors before they reach production.
The most cursed PHPStan messages, demystified. You are not the first. You will not be the last.
Cannot call method getName() on mixed. Type PHPStan has no idea what type this variable is. Trace it back to its source: add a return type to the method that produced it, or narrow the type with an instanceof check before calling the method.
Cannot call method getTotal() on App\Models\Order|null. Type Your method can return null but you're calling a method on it directly. Handle the null case first: check with `if ($order === null)`, use the nullsafe operator `$order?->getTotal()`, or change the method to throw instead of returning null.
Parameter #1 $callback of function array_map expects callable(int): mixed, Closure(string): string given. Generics The array type and the closure parameter type don't match. If your array is `array<int, string>`, your closure parameter must be `string`, not `int`. Check the PHPDoc on the array or narrow it with a typed variable.
Offset 'name' does not exist on array{}. Type PHPStan knows this array is empty at this point — you're accessing a key that can't exist. Often caused by conditional array building where PHPStan tracks only the empty branch. Add the key before the access, or use a non-empty array type in the PHPDoc.
Variable $result might not be defined. Strict The variable is only assigned inside a conditional branch. PHPStan sees a code path where it never gets assigned. Initialize it before the condition (`$result = null;` or a sensible default), or ensure all branches assign it.
Dead catch - Foo\BarException is never thrown in the try block. Strict Nothing inside the `try` block throws that exception type. Either the exception class changed, the throwing method was removed, or this catch was copy-pasted from somewhere else. Remove it or fix the exception type.
Return type of App\Services\Foo::getData() is PHPDoc type array<string, mixed> but contains mixed. Generics At level 9, returning `mixed` is not allowed even in arrays. You need to be more specific about the value type: `array<string, string>`, `array<string, int>`, or use a typed DTO instead of a generic array.
Method App\Services\UserService::findById() has no return type specified. Strict Level 6+ requires all methods to declare a return type. Add `?User` if it can return null, `User` if it always returns one, or `void` if it returns nothing. This is often a quick fix across a codebase with a find+replace.
Instanceof between App\Foo and App\Bar will always evaluate to false. Strict PHPStan knows these two classes are unrelated — `App\Foo` can never be an instance of `App\Bar`. Usually a sign of copy-paste error, a wrong variable name, or a class hierarchy that changed. Check your class relationships.
Negated boolean expression is always false. Strict The expression being negated is always `true`, so negating it is always `false`. This is dead code. PHPStan tracked the type through all branches and determined this condition can never be triggered.
Cannot access offset 'id' on mixed. Type Same root cause as "cannot call method on mixed" but for array access. The variable type is unknown. Add a PHPDoc `@var array{id: int, ...} $data` annotation, or type the function that produced this value.
Call to an undefined method Illuminate\Database\Eloquent\Builder::whereActive(). Legacy PHPStan doesn't know about Eloquent local scopes or query macros. Install Larastan (`composer require larastan/larastan --dev`) and include its extension in your phpstan.neon. Larastan teaches PHPStan everything Laravel knows about Eloquent.
Method App\Repository::getAll() return type has no value type specified in iterable type array. Generics You declared `array` as a return type but didn't specify what's in the array. Use `array<int, User>` for a list, `array<string, mixed>` for an associative array, or suppress it globally with `checkMissingIterableValueType: false` if you're not ready for full generics yet.
Class App\Services\PaymentService constructor invoked with 2 parameters, 3 required. Type The constructor signature changed but the call site wasn't updated. Search all usages of `new PaymentService(` and update them. PHPStan caught a real bug here.
No error to ignore is matched by ignored error pattern #Call to undefined method .+::boot\(\)#. CI An ignore rule in your phpstan.neon or baseline no longer matches any real error — because you fixed it! Enable `reportUnmatchedIgnoredErrors: true` to catch these. Delete the stale rule.