Three days. That’s how long I spent debugging a payment reconciliation bug that PHPStan would have caught in three seconds.
Or: How I Learned to Stop Worrying and Love the Type System
The bug was subtle. Our API wrapper returned null during maintenance windows,
but only for certain transaction types. Our tests used mocks that never returned
null. Code review focused on the calculation logic, not the null-safety.
Production? Production crashed at 3 AM on a Sunday.
PHPStan Level 9 would have screamed at us: “Parameter #2 $bankData of function reconcileTransaction() expects BankData, BankData|null given.”
That 3 AM incident changed how I think about type safety. Today, I’m going to show you how we eliminated 90% of our type-related bugs without writing a single additional test. The secret? Your compiler is smarter than your tests.
Why Static Analysis Changes Everything
Remember the last time you saw this in production?
Fatal error: Uncaught Error: Call to a member function
format() on null in /var/www/app/PaymentProcessor.php:142
This happens because PHP is dynamically typed. Variables can be anything at runtime. Your IDE might warn you, your tests might catch 80% of cases, but that 20% finds its way to production at the worst possible time.
Static analysis tools like PHPStan analyze your code without running it. They build a complete type graph of your application and tell you exactly where things will break before you deploy.
The difference is profound:
Traditional Testing:
- Write code
- Write tests
- Run tests
- Deploy
- Fix production bugs you didn’t test for
Static Analysis:
- Write code
- Run PHPStan
- Fix type errors
- Deploy with confidence
- Sleep through the night
The PHPStan Level Journey (0-9)
PHPStan offers 10 levels of strictness, from “barely checking anything” to “your code is mathematically proven to not have type errors.” Each level catches progressively more subtle bugs.
Level 0: Basic Existence Checks
The gentlest introduction. PHPStan verifies that classes, functions, and methods you call actually exist.
// Level 0 catches this
$user = new UserrModel(); // Typo in class name
$user->getName(); // Class doesn't exist, method doesn't exist
// Level 0 allows this (but shouldn't)
function process($data) {
return $data->value; // What if $data is null?
}
What it catches: Fatal typos, undefined classes, undefined methods What it misses: Everything related to types
Level 1-2: Unknown Classes and Methods
Checks that you’re calling methods that exist on the right objects.
// Level 2 catches this
$user = getUserById(123);
$user->nonExistentMethod(); // Method doesn't exist
// Level 2 allows this
function formatUser($user) {
return $user->getName(); // What type is $user?
}
What it catches: Calling methods on wrong object types What it misses: Missing type hints, magic methods
Level 3-4: Basic Type Checking
Now we’re getting serious. PHPStan starts requiring basic type hints and checking them.
// Level 4 catches this
function processUsers(array $users): void {
foreach ($users as $user) {
echo $user->getName(); // $user could be anything!
}
}
// Level 4 requires this
/**
* @param User[] $users
*/
function processUsers(array $users): void {
foreach ($users as $user) {
echo $user->getName(); // Now PHPStan knows $user is User
}
}
What it catches: Missing parameter types, missing return types What it misses: Nullable types, union types, complex generics
Level 5-6: Dead Code and Strict Comparisons
PHPStan starts checking your logic, not just your types.
// Level 5 catches this
function getUser(int $id): ?User {
$user = $this->repository->find($id);
if ($user === null) {
return null;
}
return $user->getName(); // Wrong! Should return User, not string
}
// Level 6 catches this
$result = someFunction(); // Returns mixed
if ($result) { // Loose comparison
// PHPStan: "This might not work how you think"
}
What it catches: Wrong return types, dead code paths, loose comparisons What it misses: Complex null safety scenarios
Level 7-8: Advanced Type Safety
This is where most production apps should aim. Almost everything is checked.
// Level 7 catches this
function processPayment(?PaymentData $data): void {
// Accessing property on potentially null object
$amount = $data->amount;
}
// Level 7 requires this
function processPayment(?PaymentData $data): void {
if ($data === null) {
throw new InvalidArgumentException('Payment data required');
}
$amount = $data->amount; // Safe!
}
// Level 8 catches this
$users = getUsersFromCache(); // Returns mixed
foreach ($users as $user) { // Can't iterate mixed
// ...
}
What it catches: Most null pointer exceptions, type mismatches, unsafe mixed usage What it misses: The strictest mixed type rules
Level 9: Maximum Strictness
Welcome to the top. Level 9 is PHPStan’s strictest level (there’s also Level 10 in PHPStan v2, but that’s experimental).
At Level 9, PHPStan becomes almost obsessive about the mixed type:
“Be strict about the mixed type - the only allowed operation you can do with it is to pass it to another mixed.”
// Level 9 rejects this
function processData(mixed $data): void {
if (is_array($data)) {
count($data); // ERROR: Can't use mixed, even after is_array()
}
}
// Level 9 requires this
/**
* @param array<mixed> $data
*/
function processData(array $data): void {
count($data); // OK: $data is array, not mixed
}
// Level 9 catches this subtle bug
function getValue(): mixed {
return $this->cache->get('key');
}
function processValue(): void {
$value = $this->getValue();
// ERROR: Can't do ANYTHING with $value
echo $value; // Not allowed
$value->method(); // Not allowed
$value + 5; // Not allowed
}
What it catches: Every possible type ambiguity Production reality: Level 8 is often more practical, Level 9 is for those who want absolute safety
Here’s what we found in production when we went from Level 6 to Level 9:
// Our API client (worked fine for 2 years)
class ApiClient {
public function fetchTransaction(string $id) {
$response = $this->http->get("/transactions/{$id}");
return json_decode($response->getBody(), true);
// Returns mixed! Could be null, array, or anything
}
}
// Our reconciliation code
public function reconcile(string $transactionId): void {
$data = $this->api->fetchTransaction($transactionId);
// Level 9: "You can't access array keys on mixed"
$amount = $data['amount'];
// The fix
/**
* @return array{amount: float, currency: string}|null
*/
public function fetchTransaction(string $id): ?array {
$response = $this->http->get("/transactions/{$id}");
$data = json_decode($response->getBody(), true);
if (!is_array($data)) {
return null;
}
return $data;
}
}
That one change prevented the bug I mentioned at the start of this article.
The Baseline Strategy: Migrate Without Breaking Everything
Here’s the reality: you probably have a legacy codebase with thousands of PHPStan errors. You can’t fix them all before shipping your next feature.
The baseline feature is your escape hatch.
What is a Baseline?
A baseline is a snapshot of all current errors. PHPStan will ignore these errors in future runs, but it will scream at you about new errors.
This lets you:
- Set a high strictness level (like Level 8)
- Ignore all existing errors
- Force all new code to meet that standard
- Fix old errors incrementally
Generating Your First Baseline
# Generate baseline at Level 8
vendor/bin/phpstan analyse \
--level 8 \
--configuration phpstan.neon \
src/ tests/ \
--generate-baseline
This creates phpstan-baseline.neon:
parameters:
ignoreErrors:
- message:
"#^Method App\\\\PaymentProcessor::process\\(\\) has parameter \\$data
with no type specified\\.$#"
count: 1
path: src/PaymentProcessor.php
- message: "#^Property App\\\\User::\\$metadata has no type specified\\.$#"
count: 15
path: src/Models/User.php
- message:
"#^Method App\\\\ApiClient::fetchTransaction\\(\\) has no return type
specified\\.$#"
count: 1
path: src/ApiClient.php
Include the Baseline
Update phpstan.neon:
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
- tests
Now run PHPStan:
vendor/bin/phpstan analyse
Output:
[OK] No errors
Beautiful. Now write some new code with a type error:
// New feature added today
class InvoiceGenerator {
public function generate($invoice) { // Missing type hint
return $invoice->format();
}
}
Run PHPStan again:
vendor/bin/phpstan analyse
Output:
------ ---------------------------------------------------------------
Line src/InvoiceGenerator.php
------ ---------------------------------------------------------------
3 Method App\InvoiceGenerator::generate() has parameter
$invoice with no type specified.
------ ---------------------------------------------------------------
[ERROR] Found 1 error
This is the power of baselines. Old code? Ignored. New code? Must be perfect.
Incremental Improvement Strategy
Here’s how we migrated our 150,000 line codebase to Level 9 over 4 months:
Week 1-2: Generate Baseline at Level 6
vendor/bin/phpstan analyse --level 6 --generate-baseline
git add phpstan-baseline.neon
git commit -m "chore: add PHPStan baseline at level 6"
Week 3-8: Fix One Module Per Sprint
# Pick a small module
vendor/bin/phpstan analyse src/Billing/ --level 6
# Fix all errors in that module
# Then remove those errors from baseline
vendor/bin/phpstan analyse --generate-baseline
Week 9-12: Increase to Level 7
# Generate new baseline at Level 7
vendor/bin/phpstan analyse --level 7 --generate-baseline
# Continue fixing module by module
Week 13-16: Level 8 (Production Goal)
Month 4: Level 9 for Critical Paths
We didn’t go Level 9 everywhere. We used directory-specific configs:
# phpstan.neon
parameters:
level: 8
paths:
- src
- tests
# phpstan-strict.neon (for critical code)
includes:
- phpstan.neon
parameters:
level: 9
paths:
- src/Billing
- src/PaymentProcessing
- src/Security
Now we run two checks:
# Regular check (Level 8)
vendor/bin/phpstan analyse
# Strict check for critical code (Level 9)
vendor/bin/phpstan analyse -c phpstan-strict.neon
Custom Rules: Enforce Your Team’s Conventions
PHPStan’s built-in rules are excellent, but every team has unique conventions. Custom rules let you enforce them automatically.
Example: Factory Pattern Enforcement
Let’s say your team uses factories for domain models, and you want to prevent direct instantiation:
// Bad: Direct instantiation
$user = new User($name, $email);
// Good: Use factory
$user = UserFactory::create($name, $email);
Create a custom rule:
<?php declare(strict_types = 1);
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\New_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<New_>
*/
class NoDirectDomainModelInstantiationRule implements Rule
{
public function getNodeType(): string
{
return New_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->class instanceof Node\Name) {
return [];
}
$className = $node->class->toString();
// Check if this is a domain model
if (!str_starts_with($className, 'App\\Domain\\')) {
return [];
}
// Allow instantiation in factories and tests
$currentFile = $scope->getFile();
if (str_contains($currentFile, 'Factory') ||
str_contains($currentFile, 'Test')) {
return [];
}
return [
RuleErrorBuilder::message(
sprintf(
'Domain model %s should not be instantiated directly. Use %sFactory instead.',
$className,
$className
)
)
->identifier('app.domainModel.directInstantiation')
->build(),
];
}
}
Register it in phpstan.neon:
rules:
- App\PHPStan\Rules\NoDirectDomainModelInstantiationRule
Now PHPStan enforces your architecture:
// This triggers our custom rule
$user = new User('John', 'john@example.com');
// Error: Domain model App\Domain\User should not be
// instantiated directly. Use App\Domain\UserFactory instead.
More Practical Custom Rules
Prevent Slow Functions in Hot Paths:
/**
* @implements Rule<FuncCall>
*/
class NoSlowFunctionsInHotPathRule implements Rule
{
private const SLOW_FUNCTIONS = [
'sleep',
'usleep',
'file_get_contents', // Without caching
];
public function processNode(Node $node, Scope $scope): array
{
$functionName = $node->name->toString();
$currentFile = $scope->getFile();
// Check if we're in a hot path
if (!str_contains($currentFile, '/HotPath/')) {
return [];
}
if (in_array($functionName, self::SLOW_FUNCTIONS, true)) {
return [
RuleErrorBuilder::message(
sprintf('Slow function %s() should not be used in hot paths', $functionName)
)->build(),
];
}
return [];
}
}
Require Specific PHPDoc for Public APIs:
/**
* @implements Rule<ClassMethod>
*/
class PublicApiMustHaveExamplesRule implements Rule
{
public function processNode(Node $node, Scope $scope): array
{
// Only check public methods in src/Api
if (!$node->isPublic()) {
return [];
}
$currentFile = $scope->getFile();
if (!str_contains($currentFile, '/Api/')) {
return [];
}
$docComment = $node->getDocComment();
if ($docComment === null || !str_contains($docComment->getText(), '@example')) {
return [
RuleErrorBuilder::message(
'Public API methods must include @example in PHPDoc'
)->build(),
];
}
return [];
}
}
CI/CD Integration: Make It Automatic
Static analysis is only useful if it runs automatically. Here’s how to integrate PHPStan into your pipeline.
GitHub Actions
# .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
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse --error-format=github
- name: Run PHPStan (Strict) on Critical Code
run:
vendor/bin/phpstan analyse -c phpstan-strict.neon
--error-format=github
GitLab CI
# .gitlab-ci.yml
phpstan:
image: php:8.3-cli
stage: test
before_script:
- apt-get update && apt-get install -y git unzip
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install
script:
- vendor/bin/phpstan analyse --error-format=gitlab
artifacts:
reports:
codequality: phpstan-report.json
Pre-commit Hook (Local Development)
#!/bin/bash
# .git/hooks/pre-commit
echo "Running PHPStan..."
# Get list of PHP files in staging
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -z "$FILES" ]; then
exit 0
fi
# Run PHPStan only on changed files
vendor/bin/phpstan analyse $FILES --level=8
if [ $? -ne 0 ]; then
echo ""
echo "PHPStan found errors. Commit aborted."
echo "Fix the errors or use 'git commit --no-verify' to skip this check."
exit 1
fi
exit 0
Make it executable:
chmod +x .git/hooks/pre-commit
Progressive CI Strategy
We use a tiered approach:
# .github/workflows/quality.yml
name: Code Quality
on: [push, pull_request]
jobs:
phpstan-baseline:
name: PHPStan (Baseline)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- run: composer install
- run: vendor/bin/phpstan analyse
# This must pass (uses baseline)
phpstan-strict:
name: PHPStan (No Baseline)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- run: composer install
- run: vendor/bin/phpstan analyse --level 9
# This can fail (shows progress toward Level 9)
The first job blocks merging if new code has errors. The second job shows how many baseline errors remain, but doesn’t block.
PHPStan vs Psalm: Which Should You Choose?
Both are excellent. Here’s my take after using both in production:
PHPStan Strengths
✅ Larger ecosystem - More extensions (Doctrine, Symfony, Laravel) ✅ Better Laravel support - Larastan is built-in to Laravel 10+ ✅ Simpler configuration - NEON format is easier than Psalm’s XML ✅ More forgiving learning curve - Easier to adopt incrementally ✅ Better error messages - Usually clearer about what’s wrong
Psalm Strengths
✅ Taint analysis - Catches security issues (SQL injection, XSS) ✅ More advanced type inference - Sometimes smarter about generics ✅ Immutability checking - Built-in support for @psalm-immutable ✅ Language server - Better IDE integration out of the box
My Recommendation
Use PHPStan if:
- You’re using Laravel, Symfony, or Doctrine
- You want the easiest onboarding for your team
- You need extensive framework-specific extensions
- You prefer YAML/NEON over XML
Use Psalm if:
- Security is paramount (taint analysis is gold)
- You have complex generic types
- You want the strictest possible checking
- You prefer XML configuration
Use both if:
- You can afford the CI time
- You want maximum safety
- You have critical security requirements
We use PHPStan at Level 8 with strict rules for most code, and we run Psalm with taint analysis only on security-sensitive modules (authentication, payment processing, admin panels).
# .github/workflows/security.yml
security-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- run: composer require --dev vimeo/psalm
- run: vendor/bin/psalm --taint-analysis src/Security/
Real Production Impact: The Numbers
Here’s what happened when we introduced PHPStan Level 8 to our payment processing system:
Before PHPStan:
- Production errors: 15-20 per week
- Type-related bugs: ~60% of all bugs
- Average time to fix: 45 minutes
- P1 incidents: 1-2 per month
After PHPStan (6 months):
- Production errors: 2-3 per week (85% reduction)
- Type-related bugs: ~10% of all bugs (83% reduction)
- Average time to fix: 15 minutes (caught earlier)
- P1 incidents: 1 in 6 months
Developer Experience:
- Initial setup time: 3 days
- Baseline cleanup: 4 months (incremental)
- Daily overhead: ~2 minutes per PR (automated)
- Bugs prevented: Countless (literally, they never happened)
The ROI is undeniable. The 3 AM pages stopped. Code reviews became faster because we stopped discussing “what if this is null?” The confidence to refactor improved dramatically.
Your Migration Checklist
Ready to start your PHPStan journey? Here’s your roadmap:
Week 1: Setup
- [ ] Install PHPStan:
composer require --dev phpstan/phpstan - [ ] Create basic config:
phpstan.neon - [ ] Run Level 0:
vendor/bin/phpstan analyse --level 0 src/ - [ ] Fix all Level 0 errors (should be easy)
Week 2: Establish Baseline
- [ ] Choose target level (recommend Level 6 or 7)
- [ ] Generate baseline:
vendor/bin/phpstan analyse --level 7 --generate-baseline - [ ] Add baseline to config
- [ ] Integrate into CI
Month 1: Enforce on New Code
- [ ] All PRs must pass PHPStan
- [ ] No new baseline entries allowed
- [ ] Fix errors in files you touch
Month 2-3: Incremental Cleanup
- [ ] Pick 1-2 modules per sprint
- [ ] Remove from baseline
- [ ] Update baseline
Month 4+: Increase Strictness
- [ ] Regenerate baseline at Level 8
- [ ] Add strict rules extension
- [ ] Consider Level 9 for critical code
Bonus: Advanced
- [ ] Write custom rules for team conventions
- [ ] Split strict config for critical paths
- [ ] Add Psalm for security analysis
- [ ] Enable PHPStan Pro for better errors
The Bottom Line
Three days of debugging versus three seconds of static analysis. That’s the choice.
PHPStan Level 9 represents the pinnacle of type safety in PHP. It’s not always practical for every line of code, but the journey from Level 0 to Level 8+ will transform your codebase. You’ll catch bugs before they’re written. You’ll refactor with confidence. You’ll sleep through those Sunday nights.
The baseline strategy makes migration painless. Custom rules enforce your conventions automatically. CI integration makes it all automatic.
Start today. Run composer require --dev phpstan/phpstan. Generate that
baseline. Watch your production errors drop.
Your future self—the one not debugging null pointer exceptions at 3 AM—will thank you.
Resources
- PHPStan Documentation
- PHPStan Baseline Feature
- Fixing Legacy Code with PHPStan
- Achieving PHPStan Level 9
- PHPStan vs Psalm Comparison
- Static Analysis Best Practices
What’s your horror story with type-related bugs? Have you tried PHPStan or Psalm? What level are you running in production? I’d love to hear your migration strategies in the comments.