Skip to content

Instantly share code, notes, and snippets.

@lunetics
Last active August 1, 2025 10:39
Show Gist options
  • Save lunetics/9d09fb54a4ec10429015e0bfa74b3dfb to your computer and use it in GitHub Desktop.
Save lunetics/9d09fb54a4ec10429015e0bfa74b3dfb to your computer and use it in GitHub Desktop.
Phpstan cheat sheet

PHPStan PHPDoc Types and Generics Guide

Table of Contents

  1. Introduction
  2. Basic Types
  3. Advanced Types
    1. Union Types
    2. Intersection Types
    3. Literal and Constant Types
    4. Type Aliases and Imports
    5. Type Narrowing
    6. Custom Type Assertions
  4. More Advanced Types
    1. key-of and value-of
    2. Conditional Return Types
    3. PHP 8 Enums
  5. Generics (Templates)
    1. Introduction to Generics
    2. Basic Templates
    3. Constraints and Bounds
    4. Multiple Type Parameters
    5. Variance (Covariance/Contravariance)
    6. Generic Methods vs Classes
    7. Fluent Interfaces with @phpstan-this
  6. Practical Patterns
  7. Working with Third-Party Code (Stubs)
  8. PHPDoc Annotations
  9. Integration with Modern PHP
    1. Readonly Properties
    2. First-Class Callables
  10. Tooling and Interoperability
    1. Psalm Compatibility
    2. IDE Integration
  11. Best Practices
  12. Quick Reference
  13. PHPStan Configuration
  14. CI/CD Integration
  15. Common Pitfalls
  16. DDD-Specific Typing Patterns
  17. Event Sourcing Types (Patchlevel)
  18. Project-Specific Type Patterns

Introduction

What is PHPStan?

PHPStan is a static analysis tool for PHP that finds bugs in your code without running it. It understands your code through:

  • Native PHP type declarations
  • PHPDoc annotations
  • Code structure analysis

Why Use PHPStan Types?

  1. Catch bugs before runtime - Find null pointer exceptions, undefined methods, and type mismatches during development
  2. Self-documenting code - Types serve as always-up-to-date inline documentation
  3. Confident refactoring - Know immediately when changes break type contracts
  4. Better IDE support - Modern IDEs use these types for intelligent autocompletion
  5. Enforce business rules - Express constraints like "positive integers only" or "non-empty strings"

How to Use This Guide

  • Beginners: Start with Basic Types and work through sequentially
  • Experienced users: Jump to specific sections or use the Quick Reference
  • Migrating projects: See Best Practices for migration strategies

Basic Types

PHPStan extends PHP's type system with more specific types that help catch bugs and express intent clearly.

String Types

String types help ensure data validity and prevent common errors:

Type Description Use Cases
string Any string, including empty General text, optional fields
non-empty-string Cannot be '' Required fields, IDs, usernames
non-falsy-string Cannot be '' or '0' Passwords, authentication tokens
numeric-string Must pass is_numeric() Form inputs for numbers, price strings
literal-string Only code-written strings SQL queries, file paths (security)
lowercase-string Only lowercase characters URL slugs, normalized identifiers
class-string Valid class name Dynamic instantiation
class-string<T> Class name of type T Type-safe factories

Examples

/**
 * User registration with appropriate string types
 */
class UserRegistration
{
    /**
     * @param non-empty-string $username Cannot be empty
     * @param non-falsy-string $password Cannot be '' or '0'
     * @param numeric-string $age Must be numeric for validation
     * @param non-empty-string $email Required email field
     */
    public function register(
        string $username,
        string $password,
        string $age,
        string $email
    ): void {
        // No need to check for empty - PHPStan ensures it
        $user = new User($username);
        
        // Password is guaranteed non-falsy
        $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
        
        // Age is guaranteed numeric, safe to cast
        $ageInt = (int) $age;
        if ($ageInt < 18) {
            throw new InvalidArgumentException('Must be 18 or older');
        }
        
        // Email is guaranteed non-empty
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email format');
        }
    }
}

/**
 * Security-focused example with literal-string
 */
class DatabaseQuery
{
    /**
     * @param literal-string $table Only hardcoded table names
     * @param array<string, mixed> $conditions
     */
    public function select(string $table, array $conditions): array
    {
        // $table is guaranteed to be written in code, not user input
        $sql = "SELECT * FROM {$table} WHERE 1=1";
        
        foreach ($conditions as $column => $value) {
            $sql .= " AND {$column} = ?";
        }
        
        return $this->execute($sql, array_values($conditions));
    }
}

// Usage:
$db = new DatabaseQuery();
$db->select('users', ['status' => 'active']); // ✅ OK - literal string

$table = $_GET['table'];
$db->select($table, []); // ❌ ERROR - not a literal string

Integer Types

Integer types prevent mathematical errors and enforce business rules:

Type Description Use Cases
int Any integer General numbers
positive-int Greater than 0 IDs, quantities, counts
non-negative-int Greater or equal to 0 Array indexes, offsets
negative-int Less than 0 Relative positions, depths
non-positive-int Less or equal to 0 Decrements, reverse counts
non-zero-int Not equal to 0 Divisors, multipliers
int<min, max> Range between min and max Percentages, bounded values

Examples

/**
 * E-commerce inventory management
 */
class InventoryService
{
    /**
     * @param positive-int $productId Product must exist (ID > 0)
     * @param non-negative-int $quantity Can be 0 (out of stock)
     * @param positive-int $warehouseId Warehouse must exist
     */
    public function updateStock(int $productId, int $quantity, int $warehouseId): void
    {
        // All IDs are guaranteed positive - no need to check
        $product = $this->productRepository->find($productId);
        $warehouse = $this->warehouseRepository->find($warehouseId);
        
        // Quantity is guaranteed non-negative
        $product->setStock($quantity);
    }
    
    /**
     * @param positive-int $price Price in cents (must be > 0)
     * @param int<0, 100> $discountPercent Valid percentage
     * @return positive-int Discounted price
     */
    public function calculateDiscount(int $price, int $discountPercent): int
    {
        // Safe calculation - types guarantee valid values
        $discount = (int) ($price * $discountPercent / 100);
        $finalPrice = $price - $discount;
        
        // Ensure we never return 0 or negative price
        return max(1, $finalPrice);
    }
}

/**
 * Pagination with type safety
 */
class Paginator
{
    /**
     * @param positive-int $page Current page (starts at 1)
     * @param positive-int $perPage Items per page
     * @param non-negative-int $total Total items
     * @return array{
     *     offset: non-negative-int,
     *     limit: positive-int,
     *     currentPage: positive-int,
     *     totalPages: positive-int,
     *     hasNext: bool,
     *     hasPrevious: bool
     * }
     */
    public function paginate(int $page, int $perPage, int $total): array
    {
        // Calculate offset - safe because types are constrained
        $offset = ($page - 1) * $perPage;
        
        // Calculate total pages - division is safe
        $totalPages = (int) ceil($total / $perPage) ?: 1;
        
        return [
            'offset' => $offset,
            'limit' => $perPage,
            'currentPage' => $page,
            'totalPages' => $totalPages,
            'hasNext' => $page < $totalPages,
            'hasPrevious' => $page > 1
        ];
    }
}

Array Types

Arrays are fundamental in PHP. PHPStan provides rich types to describe their structure:

Type Description Use Cases
array Any array Unknown structure
array<K, V> Array with key type K, value type V Typed arrays
non-empty-array Must have at least one element Required configurations
list<T> Sequential integer keys from 0 JSON arrays, indexed data
non-empty-list<T> List with at least one element Menu items, required options
array{...} Specific structure (shape) API responses, configs

Examples

/**
 * Configuration management with array shapes
 */
class ConfigurationManager
{
    /**
     * @param array{
     *     database: array{
     *         host: non-empty-string,
     *         port: int<1, 65535>,
     *         name: non-empty-string,
     *         user: non-empty-string,
     *         password: string
     *     },
     *     cache: array{
     *         enabled: bool,
     *         driver: 'redis'|'memcached'|'file',
     *         ttl: positive-int
     *     },
     *     features?: array<string, bool>
     * } $config
     */
    public function __construct(private array $config)
    {
        // Validate database connection
        $this->validateDatabaseConfig($config['database']);
        
        // Cache config is guaranteed to exist with all keys
        if ($config['cache']['enabled']) {
            $this->initializeCache(
                $config['cache']['driver'],
                $config['cache']['ttl']
            );
        }
        
        // Features are optional
        if (isset($config['features'])) {
            $this->loadFeatures($config['features']);
        }
    }
}

/**
 * Working with lists (sequential arrays)
 */
class DataProcessor
{
    /**
     * @param list<string> $items Sequential array
     * @return non-empty-list<string> Never empty result
     */
    public function processItems(array $items): array
    {
        if (count($items) === 0) {
            return ['default-item']; // Ensure non-empty
        }
        
        // Operations that preserve list structure
        $processed = array_map('strtoupper', $items);
        $filtered = array_values(array_filter($processed)); // array_values maintains list
        
        return $filtered ?: ['default-item'];
    }
    
    /**
     * @param non-empty-array<string, mixed> $data
     * @return mixed
     */
    public function getFirstValue(array $data): mixed
    {
        // Safe - array is guaranteed non-empty
        return reset($data); // No need to check for false
    }
}

Boolean and Special Types

Type Description Use Cases
bool true or false Flags, conditions
true Only true Success indicators
false Only false Failure indicators
null Only null Missing values
mixed Any type Unknown types
void No return value Side effects only
never Never returns Always throws/exits
resource PHP resource File handles
scalar int|float|string|bool Primitive values

Examples

/**
 * Authentication service with boolean types
 */
class AuthService
{
    /**
     * @return true Always succeeds or throws
     * @throws AuthenticationException
     */
    public function authenticate(string $token): bool
    {
        $user = $this->validateToken($token);
        
        if ($user === null) {
            throw new AuthenticationException('Invalid token');
        }
        
        $this->setCurrentUser($user);
        return true; // Always true if we get here
    }
    
    /**
     * @param mixed $credentials Unknown input format
     * @return array{user: User, token: string}|false
     */
    public function login(mixed $credentials): array|bool
    {
        if (!is_array($credentials)) {
            return false;
        }
        
        if (!isset($credentials['username'], $credentials['password'])) {
            return false;
        }
        
        $user = $this->findUser($credentials['username']);
        if (!$user || !$this->verifyPassword($credentials['password'], $user)) {
            return false;
        }
        
        return [
            'user' => $user,
            'token' => $this->generateToken($user)
        ];
    }
    
    /**
     * @return never Always throws
     */
    public function unauthorized(): void
    {
        header('HTTP/1.1 401 Unauthorized');
        throw new UnauthorizedException('Authentication required');
    }
}

Advanced Types

Union Types

Union types represent "either/or" relationships where a value can be one of several types.

When to Use Union Types

  • Optional values (T|null)
  • Multiple valid input formats (string|array)
  • Success/failure returns (Result|Error)
  • Flexible APIs that accept various types

Examples

/**
 * Flexible data parser supporting multiple formats
 */
class DataParser
{
    /**
     * Parse data from various sources
     * 
     * @param string|array|null $input JSON string, array, or null
     * @return array<string, mixed> Normalized data array
     */
    public function parse(string|array|null $input): array
    {
        // Handle null case
        if ($input === null) {
            return [];
        }
        
        // Handle string case - assume JSON
        if (is_string($input)) {
            $decoded = json_decode($input, true);
            if (!is_array($decoded)) {
                throw new ParseException('Invalid JSON string');
            }
            return $decoded;
        }
        
        // At this point, PHPStan knows $input is array
        return $this->normalizeArray($input);
    }
    
    /**
     * @param int|string $id Numeric ID or UUID string
     * @return User|null
     */
    public function findUser(int|string $id): ?User
    {
        if (is_int($id)) {
            return $this->userRepository->findById($id);
        }
        
        // PHPStan knows $id is string here
        if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id)) {
            return $this->userRepository->findByUuid($id);
        }
        
        return null;
    }
}

/**
 * API response handler with specific union types
 */
class ApiHandler
{
    /**
     * @param 200|201|204 $successCode Allowed success codes
     * @param array<string, mixed>|string $data Response data
     * @return array{status: 200|201|204, body: string}
     */
    public function successResponse(int $successCode, array|string $data): array
    {
        return [
            'status' => $successCode,
            'body' => is_array($data) ? json_encode($data) : $data
        ];
    }
    
    /**
     * @param 400|401|403|404|500 $errorCode Allowed error codes
     * @param non-empty-string|array{message: string, details?: mixed} $error
     * @return array{status: 400|401|403|404|500, error: array}
     */
    public function errorResponse(int $errorCode, string|array $error): array
    {
        $errorData = is_string($error) 
            ? ['message' => $error] 
            : $error;
            
        return [
            'status' => $errorCode,
            'error' => $errorData
        ];
    }
}

Intersection Types

Intersection types represent "both/and" relationships where a value must satisfy multiple type constraints.

When to Use Intersection Types

  • Multiple interface requirements
  • Adding constraints to existing types
  • Combining capabilities
  • Ensuring objects have required methods

Examples

/**
 * Content management with multiple capabilities
 */
interface Timestamped
{
    public function getCreatedAt(): DateTimeInterface;
    public function getUpdatedAt(): DateTimeInterface;
}

interface Publishable
{
    public function isPublished(): bool;
    public function getPublishedAt(): ?DateTimeInterface;
    public function publish(): void;
    public function unpublish(): void;
}

interface Versioned
{
    public function getVersion(): int;
    public function incrementVersion(): void;
    public function getVersionHistory(): array;
}

interface Authorable
{
    public function getAuthor(): User;
    public function setAuthor(User $author): void;
}

/**
 * Service requiring multiple capabilities
 */
class ContentPublisher
{
    /**
     * Publish content with all required traits
     * 
     * @param Timestamped&Publishable&Versioned&Authorable $content
     * @return array{
     *     id: int,
     *     version: int,
     *     publishedAt: string,
     *     author: string
     * }
     */
    public function publish(object $content): array
    {
        // PHPStan knows $content has all these methods
        
        if ($content->isPublished()) {
            throw new AlreadyPublishedException('Content is already published');
        }
        
        // Update version before publishing
        $content->incrementVersion();
        
        // Publish the content
        $content->publish();
        
        return [
            'id' => $content->getId(),
            'version' => $content->getVersion(),
            'publishedAt' => $content->getPublishedAt()->format('c'),
            'author' => $content->getAuthor()->getName()
        ];
    }
    
    /**
     * Archive old content
     * 
     * @param Timestamped&Publishable $content
     * @param DateTimeInterface $cutoffDate
     * @return bool
     */
    public function archiveIfOld(object $content, DateTimeInterface $cutoffDate): bool
    {
        if (!$content->isPublished()) {
            return false;
        }
        
        if ($content->getPublishedAt() < $cutoffDate) {
            $content->unpublish();
            return true;
        }
        
        return false;
    }
}

// Implementation example
class Article implements Timestamped, Publishable, Versioned, Authorable
{
    private int $id;
    private int $version = 1;
    private bool $published = false;
    private ?DateTimeInterface $publishedAt = null;
    private DateTimeInterface $createdAt;
    private DateTimeInterface $updatedAt;
    private User $author;
    
    // Implementation of all interface methods...
}

Literal and Constant Types

Literal types allow you to specify exact values, creating precise type constraints.

String and Integer Literals

/**
 * State machine with literal types
 */
class OrderStateMachine
{
    /**
     * @var 'draft'|'pending'|'processing'|'shipped'|'delivered'|'cancelled'
     */
    private string $status = 'draft';
    
    /**
     * @param 'draft'|'pending'|'processing'|'shipped'|'delivered'|'cancelled' $from
     * @param 'draft'|'pending'|'processing'|'shipped'|'delivered'|'cancelled' $to
     * @return bool
     */
    public function canTransition(string $from, string $to): bool
    {
        $transitions = [
            'draft' => ['pending', 'cancelled'],
            'pending' => ['processing', 'cancelled'],
            'processing' => ['shipped', 'cancelled'],
            'shipped' => ['delivered'],
            'delivered' => [],
            'cancelled' => []
        ];
        
        return in_array($to, $transitions[$from], true);
    }
    
    /**
     * @param 1|2|3|4|5 $priority Priority level
     * @param 'normal'|'express'|'overnight' $shipping
     * @return int<1, 10> Delivery days
     */
    public function estimateDelivery(int $priority, string $shipping): int
    {
        $baseDays = match($shipping) {
            'normal' => 5,
            'express' => 2,
            'overnight' => 1
        };
        
        // Higher priority = faster delivery
        return max(1, $baseDays - ($priority - 1));
    }
}

Class Constants

/**
 * HTTP status codes with class constants
 */
class HttpStatus
{
    public const OK = 200;
    public const CREATED = 201;
    public const NO_CONTENT = 204;
    public const BAD_REQUEST = 400;
    public const UNAUTHORIZED = 401;
    public const FORBIDDEN = 403;
    public const NOT_FOUND = 404;
    public const SERVER_ERROR = 500;
    
    // Success codes group
    public const SUCCESS_CODES = [
        self::OK,
        self::CREATED,
        self::NO_CONTENT
    ];
    
    /**
     * @param self::* $statusCode Any HTTP status constant
     * @return bool
     */
    public static function isSuccess(int $statusCode): bool
    {
        return in_array($statusCode, self::SUCCESS_CODES, true);
    }
    
    /**
     * @param self::OK|self::CREATED|self::NO_CONTENT $successCode
     * @return non-empty-string
     */
    public static function getSuccessMessage(int $successCode): string
    {
        return match($successCode) {
            self::OK => 'Request successful',
            self::CREATED => 'Resource created',
            self::NO_CONTENT => 'Request processed'
        };
    }
    
    /**
     * @param value-of<self::SUCCESS_CODES> $code
     */
    public static function handleSuccess(int $code): void
    {
        // $code is guaranteed to be 200, 201, or 204
    }
}

Type Aliases and Imports

As your application grows, you might find yourself repeating complex type definitions. PHPStan allows you to create aliases for these types, making your code cleaner and easier to maintain.

Local Type Aliases (@phpstan-type)

You can define a type alias within a class's PHPDoc. This alias can then be used in any PHPDoc within that class.

/**
 * @phpstan-type UserData array{id: positive-int, name: non-empty-string, email: non-empty-string}
 */
class UserAPI
{
    /**
     * @param UserData $data
     * @return User
     */
    public function createUser(array $data): User
    {
        // ...
    }

    /**
     * @return list<UserData>
     */
    public function fetchAllUsers(): array
    {
        // ...
    }
}

Global Type Aliases and Imports (@phpstan-import-type)

To share a type alias across multiple classes, define it in a central location (e.g., a dedicated types.php file or a core class) and import it where needed.

1. Define the alias in a central file (e.g., src/Types.php):

namespace App\Types;

/**
 * @phpstan-type UserData array{id: positive-int, name: non-empty-string, email: non-empty-string}
 */
class UserTypes {}

2. Import and use the alias in another class:

namespace App\Service;

use App\Types\UserTypes;

/**
 * @phpstan-import-type UserData from UserTypes
 */
class UserService
{
    /**
     * @param UserData $data
     */
    public function process(array $data): void
    {
        // PHPStan knows the shape of $data here
        echo $data['name'];
    }
}

Type Narrowing

Type narrowing is the process of refining types from general to specific through runtime checks.

Progressive Type Refinement

/**
 * Input validation with progressive type narrowing
 */
class InputValidator
{
    /**
     * Validate user registration data
     * 
     * @param mixed $input Unknown input
     * @return array{
     *     username: non-empty-string,
     *     email: non-empty-string,
     *     age: int<18, 150>,
     *     password: non-falsy-string,
     *     preferences: array<string, bool>
     * }
     * @throws ValidationException
     */
    public function validateRegistration(mixed $input): array
    {
        // Level 1: Ensure input is array
        if (!is_array($input)) {
            throw new ValidationException('Input must be an array');
        }
        
        // Level 2: Validate username
        if (!isset($input['username']) || !is_string($input['username']) || $input['username'] === '') {
            throw new ValidationException('Username is required and must be non-empty');
        }
        $username = $input['username']; // non-empty-string
        
        // Level 3: Validate email
        if (!isset($input['email']) || !is_string($input['email']) || $input['email'] === '') {
            throw new ValidationException('Email is required');
        }
        if (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
            throw new ValidationException('Invalid email format');
        }
        $email = $input['email']; // non-empty-string with valid format
        
        // Level 4: Validate age
        if (!isset($input['age']) || !is_int($input['age'])) {
            throw new ValidationException('Age must be an integer');
        }
        if ($input['age'] < 18 || $input['age'] > 150) {
            throw new ValidationException('Age must be between 18 and 150');
        }
        $age = $input['age']; // int<18, 150>
        
        // Level 5: Validate password
        if (!isset($input['password']) || !is_string($input['password'])) {
            throw new ValidationException('Password is required');
        }
        if ($input['password'] === '' || $input['password'] === '0') {
            throw new ValidationException('Password cannot be empty or "0"');
        }
        $password = $input['password']; // non-falsy-string
        
        // Level 6: Validate preferences
        $preferences = $input['preferences'] ?? [];
        if (!is_array($preferences)) {
            throw new ValidationException('Preferences must be an array');
        }
        
        foreach ($preferences as $key => $value) {
            if (!is_string($key) || !is_bool($value)) {
                throw new ValidationException('Preferences must be string => bool pairs');
            }
        }
        // $preferences is array<string, bool>
        
        return [
            'username' => $username,
            'email' => $email,
            'age' => $age,
            'password' => $password,
            'preferences' => $preferences
        ];
    }
}

/**
 * Safe operations through type narrowing
 */
class SafeOperations
{
    /**
     * Safe division with narrowing
     * 
     * @param int|float $dividend
     * @param int|float $divisor
     * @return float
     * @throws DivisionByZeroError
     */
    public function divide(int|float $dividend, int|float $divisor): float
    {
        if ($divisor == 0) {
            throw new DivisionByZeroError('Cannot divide by zero');
        }
        
        // PHPStan knows $divisor is non-zero here
        return $dividend / $divisor;
    }
    
    /**
     * Extract value from nested structure safely
     * 
     * @param mixed $data
     * @param non-empty-string $path Dot notation path
     * @return mixed
     */
    public function getNestedValue(mixed $data, string $path): mixed
    {
        $keys = explode('.', $path);
        $current = $data;
        
        foreach ($keys as $key) {
            if (!is_array($current) || !array_key_exists($key, $current)) {
                return null;
            }
            $current = $current[$key];
        }
        
        return $current;
    }
}

Custom Type Assertions (@phpstan-assert)

Sometimes you have custom validation logic that you want to reuse. With @phpstan-assert, you can create your own assertion functions that inform PHPStan about type refinements, just like is_string() or instanceof.

This is especially useful for domain-specific validations.

use Assert\Assertion;

class MyAssertions
{
    /**
     * Asserts that the input is a non-empty string.
     *
     * @phpstan-assert non-empty-string $value
     * @throws \Assert\AssertionFailedException
     */
    public static function assertNonEmptyString(mixed $value, string $message = 'Value must be a non-empty string.'): void
    {
        Assertion::notEmpty($value, $message);
        Assertion::string($value, $message);
    }
}

class UserController
{
    public function updateUsername(mixed $username): void
    {
        // Before this call, $username is `mixed`.
        MyAssertions::assertNonEmptyString($username);
        // After this call, PHPStan knows $username is `non-empty-string`.

        // This is now safe, no error from PHPStan.
        $user->setUsername(strtoupper($username));
    }
}

You can also make negative assertions with @phpstan-assert-if-true and @phpstan-assert-if-false.

Advanced Assertion Annotations

@param-out - Reference Parameters

The @param-out annotation is used when a function modifies a parameter passed by reference, changing its type:

/**
 * Parse data and normalize it
 * 
 * @param mixed $data
 * @param-out array{id: positive-int, name: non-empty-string, valid: true} $data
 */
function parseAndValidate(mixed &$data): void
{
    if (!is_array($data)) {
        $data = json_decode($data, true);
    }
    
    if (!isset($data['id']) || !is_int($data['id']) || $data['id'] <= 0) {
        throw new InvalidArgumentException('Invalid ID');
    }
    
    if (!isset($data['name']) || !is_string($data['name']) || $data['name'] === '') {
        throw new InvalidArgumentException('Invalid name');
    }
    
    $data['valid'] = true;
}

// Usage
$input = '{"id": 123, "name": "John"}';
parseAndValidate($input);
// PHPStan now knows $input is array{id: positive-int, name: non-empty-string, valid: true}

@phpstan-self-out - Object State Transformation

This annotation indicates that a method changes the type of the object itself:

/**
 * @template TState of 'draft'|'published'|'archived'
 */
class Document
{
    /**
     * @param TState $state
     */
    public function __construct(private string $state) {}
    
    /**
     * @phpstan-self-out self<'published'>
     * @throws InvalidStateException
     */
    public function publish(): void
    {
        if ($this->state !== 'draft') {
            throw new InvalidStateException('Can only publish drafts');
        }
        $this->state = 'published';
    }
}

$doc = new Document('draft');
$doc->publish();
// PHPStan now knows $doc is Document<'published'>

@phpstan-pure and @phpstan-impure

These annotations help track side effects in your code:

class Calculator
{
    private int $operationCount = 0;
    
    /**
     * Pure function - no side effects
     * 
     * @phpstan-pure
     */
    public function add(int $a, int $b): int
    {
        return $a + $b;
    }
    
    /**
     * Impure function - has side effects
     * 
     * @phpstan-impure
     */
    public function addAndCount(int $a, int $b): int
    {
        $this->operationCount++; // Side effect!
        return $a + $b;
    }
}

@readonly and @immutable

These annotations enforce immutability at the static analysis level:

/**
 * @immutable
 */
class Money
{
    /**
     * @readonly
     */
    public int $amount;
    
    /**
     * @readonly
     */
    public string $currency;
    
    public function __construct(int $amount, string $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }
    
    /**
     * @phpstan-pure
     */
    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch');
        }
        
        // Return new instance instead of modifying
        return new self($this->amount + $other->amount, $this->currency);
    }
}

@final - Final Methods in Non-Final Classes

class BaseRepository
{
    /**
     * This method should not be overridden
     * 
     * @final
     */
    public function beginTransaction(): void
    {
        // Critical transaction logic
    }
    
    // This method can be overridden
    public function save(object $entity): void
    {
        // Default implementation
    }
}

@no-named-arguments

Disable named arguments for specific methods:

class EventDispatcher
{
    /**
     * @no-named-arguments Order matters for performance
     */
    public function dispatch(object $event, int $priority = 0, bool $stopPropagation = false): void
    {
        // Implementation where argument order is critical
    }
}

// Usage
$dispatcher->dispatch($event, 10, true); // ✅ OK
// $dispatcher->dispatch(event: $event, stopPropagation: true); // ❌ ERROR with PHPStan

More Advanced Types

key-of and value-of

These utility types allow you to create types based on the keys and values of another type, typically an array shape or a class constant map.

Type Description Use Cases
key-of<T> Creates a union type of all keys of array T Ensuring a parameter is a valid key of a specific array
value-of<T> Creates a union type of all values of array T Ensuring a parameter is one of the allowed values from a list

Examples

/**
 * @phpstan-type UserShape array{
 *     id: int,
 *     name: string,
 *     email: string,
 *     role: 'admin'|'user'
 * }
 */
class UserAccessor
{
    /**
     * Get a property from a user data array safely.
     *
     * @param UserShape $user
     * @param key-of<UserShape> $field The key to retrieve.
     * @return value-of<UserShape> The value of the specified key.
     */
    public function getProperty(array $user, string $field): mixed
    {
        // PHPStan guarantees that $field can only be 'id', 'name', 'email', or 'role'.
        // The return type is int|string|'admin'|'user'.
        return $user[$field];
    }
}

// Usage:
$userShape = ['id' => 1, 'name' => 'John Doe', 'email' => '[email protected]', 'role' => 'admin'];
$accessor = new UserAccessor();

$name = $accessor->getProperty($userShape, 'name'); // ✅ OK, $name is string
$role = $accessor->getProperty($userShape, 'role'); // ✅ OK, $role is 'admin'|'user'
// $accessor->getProperty($userShape, 'password'); // ❌ ERROR: 'password' is not a key-of<UserShape>

Conditional Return Types

Sometimes, a function's return type depends on the value of an argument. You can express this relationship using conditional return types.

Example: A get_option function

Imagine a function that can return a default value if a setting is not found. The return type depends on whether a default is provided.

/**
 * @param non-empty-string $key
 * @param TDefault $default
 * @return ($default is null ? string|null : string|TDefault)
 * @template TDefault
 */
function get_option(string $key, mixed $default = null): mixed
{
    // ... implementation
}

// PHPStan can now infer the correct types:
$value1 = get_option('site_url'); // string|null
$value2 = get_option('site_url', 'https://example.com'); // string
$value3 = get_option('retries', 0); // string|int

Example: A factory based on an argument

/**
 * @param 'user'|'product' $type
 * @return ($type is 'user' ? User : Product)
 */
function createModel(string $type): User|Product
{
    if ($type === 'user') {
        return new User();
    }
    return new Product();
}

$user = createModel('user'); // User
$product = createModel('product'); // Product

PHP 8 Enums

PHP 8.1 introduced native enumerations. PHPStan fully understands them, allowing you to type them precisely.

Examples

// Backed Enum
enum UserStatus: string
{
    case Pending = 'pending';
    case Active = 'active';
    case Banned = 'banned';
}

// Pure Enum
enum LogLevel
{
    case Debug;
    case Info;
    case Error;
}

class UserActions
{
    /**
     * @param UserStatus $status The user's new status.
     */
    public function changeStatus(UserStatus $status): void
    {
        // $status is guaranteed to be one of the UserStatus cases.
        $this->saveStatus($status->value); // $status->value is 'pending', 'active', or 'banned'
    }

    /**
     * @param LogLevel $level
     * @return non-empty-string
     */
    public function getLoggerChannel(LogLevel $level): string
    {
        // $level->name is 'Debug', 'Info', or 'Error'
        return 'logs-' . strtolower($level->name);
    }
}

Advanced Type Patterns

Callable Signatures

PHPStan supports precise callable signatures to ensure type safety when passing functions around:

class CallbackProcessor
{
    /**
     * @param callable(int, string): bool $validator
     * @param callable(mixed): never $errorHandler
     */
    public function process(
        callable $validator,
        callable $errorHandler
    ): void {
        if (!$validator(123, 'test')) {
            $errorHandler('Validation failed');
        }
    }
    
    /**
     * @param callable(User, DateTimeInterface): void $logger
     * @return callable(User): void
     */
    public function createUserLogger(callable $logger): callable
    {
        return function (User $user) use ($logger): void {
            $logger($user, new DateTime());
        };
    }
}

// Usage
$processor = new CallbackProcessor();
$processor->process(
    fn(int $id, string $name): bool => $id > 0 && $name !== '',
    fn($error): never => throw new RuntimeException($error)
);

Object Shape Types (Duck Typing)

Sometimes you need to accept objects with specific methods without requiring a specific interface:

class FlexibleHandler
{
    /**
     * @param object{
     *     getId(): positive-int,
     *     getName(): non-empty-string,
     *     isActive(): bool
     * } $entity
     */
    public function handle(object $entity): void
    {
        if ($entity->isActive()) {
            echo "Processing entity {$entity->getId()}: {$entity->getName()}";
        }
    }
}

// Any object with these methods will work
class User
{
    public function getId(): int { return 1; }
    public function getName(): string { return 'John'; }
    public function isActive(): bool { return true; }
}

class Product
{
    public function getId(): int { return 100; }
    public function getName(): string { return 'Widget'; }
    public function isActive(): bool { return false; }
}

$handler = new FlexibleHandler();
$handler->handle(new User());    // ✅ Works
$handler->handle(new Product()); // ✅ Works

Array Shapes with Optional Keys

Array shapes can have optional keys, useful for configuration arrays and API responses:

class ConfigManager
{
    /**
     * @param array{
     *     host: non-empty-string,
     *     port: int<1, 65535>,
     *     username: non-empty-string,
     *     password?: non-empty-string,
     *     database?: non-empty-string,
     *     options?: array<string, mixed>
     * } $config
     */
    public function connect(array $config): void
    {
        $dsn = "mysql:host={$config['host']};port={$config['port']}";
        
        // Optional keys need to be checked
        if (isset($config['database'])) {
            $dsn .= ";dbname={$config['database']}";
        }
        
        // Connect with or without password
        $password = $config['password'] ?? null;
        // ... connection logic
    }
}

// Valid configurations
$manager = new ConfigManager();
$manager->connect([
    'host' => 'localhost',
    'port' => 3306,
    'username' => 'root'
]); // ✅ Minimal config

$manager->connect([
    'host' => 'localhost',
    'port' => 3306,
    'username' => 'root',
    'password' => 'secret',
    'database' => 'myapp',
    'options' => ['charset' => 'utf8mb4']
]); // ✅ Full config

Non-empty Collections

Distinguish between potentially empty and guaranteed non-empty collections:

class CollectionProcessor
{
    /**
     * @param non-empty-list<User> $users
     * @return User
     */
    public function getFirstUser(array $users): User
    {
        // No need to check for empty array
        return $users[0];
    }
    
    /**
     * @param non-empty-array<string, mixed> $data
     * @return mixed
     */
    public function getFirstValue(array $data): mixed
    {
        // reset() is guaranteed to return a value, not false
        return reset($data);
    }
    
    /**
     * @param list<string> $items
     * @return non-empty-list<string>
     * @throws InvalidArgumentException
     */
    public function ensureNonEmpty(array $items): array
    {
        if (count($items) === 0) {
            throw new InvalidArgumentException('List cannot be empty');
        }
        
        // PHPStan now knows this is non-empty-list<string>
        return $items;
    }
}

Class String Types for Traits and Interfaces

class DynamicLoader
{
    /**
     * @template T of object
     * @param class-string<T> $className
     * @return T
     */
    public function create(string $className): object
    {
        return new $className();
    }
    
    /**
     * @param interface-string $interface
     * @return list<class-string>
     */
    public function findImplementors(string $interface): array
    {
        $classes = [];
        foreach (get_declared_classes() as $class) {
            if (is_subclass_of($class, $interface)) {
                $classes[] = $class;
            }
        }
        return $classes;
    }
    
    /**
     * @template T
     * @param object $object
     * @param trait-string<T> $trait
     * @return bool
     */
    public function usesTrait(object $object, string $trait): bool
    {
        return in_array($trait, class_uses($object) ?: [], true);
    }
}

Generics (Templates)

Generics allow you to write reusable code that works with different types while maintaining type safety.

Introduction to Generics

Generics solve the problem of writing the same code for different types. Instead of creating StringCollection, IntCollection, UserCollection, you create one generic Collection<T>.

Benefits of Generics

  1. Type Safety - Catch type errors at analysis time
  2. Code Reuse - Write once, use with any type
  3. Better IDE Support - Autocompletion knows exact types
  4. Self-Documenting - Types explain themselves

Basic Templates

/**
 * Simple generic container
 * 
 * @template T The type of value stored
 */
class Box
{
    /**
     * @param T $value
     */
    public function __construct(private mixed $value) {}
    
    /**
     * @return T
     */
    public function get(): mixed
    {
        return $this->value;
    }
    
    /**
     * @param T $value
     */
    public function set(mixed $value): void
    {
        $this->value = $value;
    }
}

// Usage - type is inferred from constructor
$stringBox = new Box('hello');           // Box<string>
$intBox = new Box(42);                   // Box<int>
$userBox = new Box(new User('John'));    // Box<User>

// Type safety in action
$name = $stringBox->get();               // string
$stringBox->set('world');                // ✅ OK
$stringBox->set(123);                    // ❌ ERROR: expected string

// Explicit type declaration
/** @var Box<User> $userBox */
$userBox = new Box(new User('Jane'));

Constraints and Bounds

Templates can be constrained to specific types or interfaces:

/**
 * Generic logger that only accepts Throwable types
 * 
 * @template T of \Throwable
 */
class ExceptionLogger
{
    /**
     * @var array<int, T>
     */
    private array $exceptions = [];
    
    /**
     * @param T $exception
     */
    public function log(\Throwable $exception): void
    {
        $this->exceptions[] = $exception;
        
        error_log(sprintf(
            "[%s] %s: %s in %s:%d",
            get_class($exception),
            date('Y-m-d H:i:s'),
            $exception->getMessage(),
            $exception->getFile(),
            $exception->getLine()
        ));
    }
    
    /**
     * @return list<T>
     */
    public function getExceptions(): array
    {
        return array_values($this->exceptions);
    }
    
    /**
     * @param class-string<T> $type
     * @return list<T>
     */
    public function getExceptionsOfType(string $type): array
    {
        return array_values(array_filter(
            $this->exceptions,
            fn($e) => $e instanceof $type
        ));
    }
}

// Usage
/** @var ExceptionLogger<\Exception> $logger */
$logger = new ExceptionLogger();

$logger->log(new \RuntimeException('Runtime error'));
$logger->log(new \InvalidArgumentException('Invalid arg'));
// $logger->log('not an exception'); // ❌ ERROR

$runtimeErrors = $logger->getExceptionsOfType(\RuntimeException::class);

Multiple Type Parameters

/**
 * Generic key-value pair
 * 
 * @template TKey
 * @template TValue
 */
class Pair
{
    /**
     * @param TKey $key
     * @param TValue $value
     */
    public function __construct(
        private mixed $key,
        private mixed $value
    ) {}
    
    /**
     * @return TKey
     */
    public function getKey(): mixed
    {
        return $this->key;
    }
    
    /**
     * @return TValue
     */
    public function getValue(): mixed
    {
        return $this->value;
    }
}

/**
 * Generic map with key-value constraints
 * 
 * @template TKey of array-key
 * @template TValue
 */
class Map
{
    /**
     * @var array<TKey, TValue>
     */
    private array $items = [];
    
    /**
     * @param TKey $key
     * @param TValue $value
     */
    public function put(mixed $key, mixed $value): void
    {
        $this->items[$key] = $value;
    }
    
    /**
     * @param TKey $key
     * @return TValue|null
     */
    public function get(mixed $key): mixed
    {
        return $this->items[$key] ?? null;
    }
    
    /**
     * @return list<Pair<TKey, TValue>>
     */
    public function pairs(): array
    {
        $pairs = [];
        foreach ($this->items as $key => $value) {
            $pairs[] = new Pair($key, $value);
        }
        return $pairs;
    }
}

Variance (Covariance/Contravariance)

Variance determines how generic types relate in inheritance hierarchies.

/**
 * Covariance - for "producers" (return types)
 * 
 * @template-covariant T
 */
interface Factory
{
    /**
     * @return T
     */
    public function create(): mixed;
}

/**
 * Contravariance - for "consumers" (parameter types)
 * 
 * @template-contravariant T
 */
interface Processor
{
    /**
     * @param T $item
     */
    public function process(mixed $item): void;
}

// Example with animals
abstract class Animal
{
    abstract public function makeSound(): string;
}

class Dog extends Animal
{
    public function makeSound(): string { return 'Woof!'; }
    public function wagTail(): void { echo "Wagging...\n"; }
}

class Cat extends Animal
{
    public function makeSound(): string { return 'Meow!'; }
}

/**
 * @implements Factory<Dog>
 */
class DogFactory implements Factory
{
    public function create(): Dog
    {
        return new Dog();
    }
}

/**
 * @implements Processor<Animal>
 */
class AnimalFeeder implements Processor
{
    public function process(mixed $item): void
    {
        if ($item instanceof Animal) {
            echo "Feeding " . get_class($item) . "\n";
        }
    }
}

// Covariance allows this:
/**
 * @param Factory<Animal> $factory
 */
function createAnimal(Factory $factory): Animal
{
    return $factory->create();
}

$dogFactory = new DogFactory();
$animal = createAnimal($dogFactory); // ✅ Works! Dog is an Animal

// Contravariance allows this:
/**
 * @param Processor<Dog> $processor
 */
function processDog(Processor $processor, Dog $dog): void
{
    $processor->process($dog);
}

$animalFeeder = new AnimalFeeder();
processDog($animalFeeder, new Dog()); // ✅ Works! AnimalFeeder accepts any Animal

Generic Methods vs Classes

When to Use Generic Methods

Use generic methods when the type varies per method call:

class ArrayHelper
{
    /**
     * Get first element matching predicate
     * 
     * @template T
     * @param array<T> $array
     * @param callable(T): bool $predicate
     * @return T|null
     */
    public function findFirst(array $array, callable $predicate): mixed
    {
        foreach ($array as $item) {
            if ($predicate($item)) {
                return $item;
            }
        }
        return null;
    }
    
    /**
     * Transform array preserving keys
     * 
     * @template TKey of array-key
     * @template TValue
     * @template TNewValue
     * @param array<TKey, TValue> $array
     * @param callable(TValue, TKey): TNewValue $mapper
     * @return array<TKey, TNewValue>
     */
    public function mapWithKeys(array $array, callable $mapper): array
    {
        $result = [];
        foreach ($array as $key => $value) {
            $result[$key] = $mapper($value, $key);
        }
        return $result;
    }
}

// Each method call can use different types
$helper = new ArrayHelper();

$users = [new User('John'), new User('Jane')];
$firstAdmin = $helper->findFirst($users, fn(User $u) => $u->isAdmin());

$numbers = ['one' => 1, 'two' => 2, 'three' => 3];
$strings = $helper->mapWithKeys($numbers, fn(int $n) => (string) $n);

When to Use Generic Classes

Use generic classes when the type is fixed for the instance lifetime:

/**
 * Generic collection - type is fixed per instance
 * 
 * @template T
 * @implements \IteratorAggregate<int, T>
 * @implements \Countable
 */
class TypedCollection implements \IteratorAggregate, \Countable
{
    /**
     * @var array<int, T>
     */
    private array $items = [];
    
    /**
     * @param T ...$items
     */
    public function __construct(...$items)
    {
        $this->items = array_values($items);
    }
    
    /**
     * @param T $item
     */
    public function add(mixed $item): void
    {
        $this->items[] = $item;
    }
    
    /**
     * @return T
     * @throws \OutOfBoundsException
     */
    public function get(int $index): mixed
    {
        if (!isset($this->items[$index])) {
            throw new \OutOfBoundsException("No item at index {$index}");
        }
        return $this->items[$index];
    }
    
    /**
     * @param callable(T): bool $predicate
     * @return self<T>
     */
    public function filter(callable $predicate): self
    {
        return new self(...array_filter($this->items, $predicate));
    }
    
    /**
     * @template TNew
     * @param callable(T): TNew $mapper
     * @return TypedCollection<TNew>
     */
    public function map(callable $mapper): TypedCollection
    {
        return new TypedCollection(...array_map($mapper, $this->items));
    }
    
    /**
     * @return \Iterator<int, T>
     */
    public function getIterator(): \Iterator
    {
        return new \ArrayIterator($this->items);
    }
    
    public function count(): int
    {
        return count($this->items);
    }
}

// Type is fixed for each instance
$numbers = new TypedCollection(1, 2, 3, 4, 5);
$numbers->add(6);                                    // ✅ OK
// $numbers->add('seven');                          // ❌ ERROR

$evenNumbers = $numbers->filter(fn($n) => $n % 2 === 0);
$strings = $numbers->map(fn($n) => "Number: {$n}"); // TypedCollection<string>

Generic Type Aliases with @phpstan-type

You can create reusable generic type aliases, which is extremely useful for complex structures like API responses.

/**
 * @template T
 * @phpstan-type ApiResponse<T> array{data: T, success: true}|array{message: string, success: false}
 */
class ApiClient
{
    /**
     * @return ApiResponse<User>
     */
    public function getUser(int $id): array
    {
        // ...
    }

    /**
     * @return ApiResponse<list<Post>>
     */
    public function getPosts(): array
    {
        // ...
    }
}

Fluent Interfaces with @phpstan-this

For fluent interfaces (method chaining), using @return $this is good, but @phpstan-this is even better. It correctly resolves the type in child classes, ensuring that the chain continues to return the most specific type.

class QueryBuilder
{
    /**
     * @return $this
     * @phpstan-return $this
     */
    public function where(string $condition): static
    {
        // ...
        return $this;
    }
}

class UserQueryBuilder extends QueryBuilder
{
    /**
     * @return $this
     * @phpstan-return $this
     */
    public function onlyAdmins(): static
    {
        return $this->where('role = "admin"');
    }
}

$qb = new UserQueryBuilder();
// The return type is correctly inferred as UserQueryBuilder, not the parent QueryBuilder.
$adminQuery = $qb->where('active = 1')->onlyAdmins();

Practical Patterns

Real-world examples of how to use PHPStan types effectively.

Collections and Lists

/**
 * Type-safe collection with advanced operations
 * 
 * @template T
 */
class Collection
{
    /**
     * @param list<T> $items
     */
    public function __construct(private array $items = []) {}
    
    /**
     * @param T $item
     * @return static
     */
    public function add(mixed $item): static
    {
        $new = clone $this;
        $new->items[] = $item;
        return $new;
    }
    
    /**
     * @param T ...$items
     * @return static
     */
    public function push(mixed ...$items): static
    {
        $new = clone $this;
        array_push($new->items, ...$items);
        return $new;
    }
    
    /**
     * @return T|null
     */
    public function first(): mixed
    {
        return $this->items[0] ?? null;
    }
    
    /**
     * @return T|null
     */
    public function last(): mixed
    {
        $count = count($this->items);
        return $count > 0 ? $this->items[$count - 1] : null;
    }
    
    /**
     * @param callable(T): bool $callback
     * @return T|null
     */
    public function firstWhere(callable $callback): mixed
    {
        foreach ($this->items as $item) {
            if ($callback($item)) {
                return $item;
            }
        }
        return null;
    }
    
    /**
     * @template TKey of array-key
     * @param callable(T): TKey $keyBy
     * @return array<TKey, T>
     */
    public function keyBy(callable $keyBy): array
    {
        $result = [];
        foreach ($this->items as $item) {
            $key = $keyBy($item);
            $result[$key] = $item;
        }
        return $result;
    }
    
    /**
     * @template TGroupKey of array-key
     * @param callable(T): TGroupKey $groupBy
     * @return array<TGroupKey, non-empty-list<T>>
     */
    public function groupBy(callable $groupBy): array
    {
        $groups = [];
        foreach ($this->items as $item) {
            $key = $groupBy($item);
            $groups[$key][] = $item;
        }
        return $groups;
    }
    
    /**
     * @param positive-int $size
     * @return list<non-empty-list<T>>
     */
    public function chunk(int $size): array
    {
        return array_chunk($this->items, $size);
    }
    
    /**
     * @return list<T>
     */
    public function unique(): array
    {
        return array_values(array_unique($this->items));
    }
    
    /**
     * @param Collection<T> $other
     * @return static
     */
    public function merge(Collection $other): static
    {
        return new static(array_merge($this->items, $other->items));
    }
    
    /**
     * @param callable(T, T): int $comparator
     * @return static
     */
    public function sort(callable $comparator): static
    {
        $new = clone $this;
        usort($new->items, $comparator);
        return $new;
    }
}

// Usage examples
$users = new Collection([
    new User(1, 'John', '[email protected]'),
    new User(2, 'Jane', '[email protected]'),
    new User(3, 'Bob', '[email protected]'),
]);

// Type-safe operations
$byId = $users->keyBy(fn(User $u) => $u->id);           // array<int, User>
$byDomain = $users->groupBy(fn(User $u) => 
    explode('@', $u->email)[1]                           // array<string, non-empty-list<User>>
);

$admins = $users->filter(fn(User $u) => $u->isAdmin()); // Collection<User>
$names = $users->map(fn(User $u) => $u->name);          // Collection<string>

Repository Pattern

/**
 * Generic repository base
 * 
 * @template TEntity of object
 * @template TId
 */
abstract class Repository
{
    /**
     * @param class-string<TEntity> $entityClass
     */
    public function __construct(
        protected readonly string $entityClass,
        protected readonly EntityManager $em
    ) {}
    
    /**
     * @param TId $id
     * @return TEntity|null
     */
    public function find(mixed $id): ?object
    {
        return $this->em->find($this->entityClass, $id);
    }
    
    /**
     * @param TId $id
     * @return TEntity
     * @throws EntityNotFoundException
     */
    public function findOrFail(mixed $id): object
    {
        $entity = $this->find($id);
        if ($entity === null) {
            throw new EntityNotFoundException(
                "{$this->entityClass} with ID {$id} not found"
            );
        }
        return $entity;
    }
    
    /**
     * @param array<string, mixed> $criteria
     * @param array<string, 'asc'|'desc'>|null $orderBy
     * @param positive-int|null $limit
     * @param non-negative-int $offset
     * @return list<TEntity>
     */
    public function findBy(
        array $criteria,
        ?array $orderBy = null,
        ?int $limit = null,
        int $offset = 0
    ): array {
        return $this->em->getRepository($this->entityClass)
            ->findBy($criteria, $orderBy, $limit, $offset);
    }
    
    /**
     * @param array<string, mixed> $criteria
     * @return TEntity|null
     */
    public function findOneBy(array $criteria): ?object
    {
        return $this->em->getRepository($this->entityClass)
            ->findOneBy($criteria);
    }
    
    /**
     * @return non-empty-list<TEntity>
     * @throws NoResultException
     */
    public function findAllOrFail(): array
    {
        $results = $this->findAll();
        if (count($results) === 0) {
            throw new NoResultException('No entities found');
        }
        return $results;
    }
    
    /**
     * @return list<TEntity>
     */
    public function findAll(): array
    {
        return $this->findBy([]);
    }
    
    /**
     * @param TEntity $entity
     */
    public function save(object $entity): void
    {
        $this->em->persist($entity);
        $this->em->flush();
    }
    
    /**
     * @param TEntity $entity
     */
    public function remove(object $entity): void
    {
        $this->em->remove($entity);
        $this->em->flush();
    }
}

/**
 * Specific user repository
 * 
 * @extends Repository<User, positive-int>
 */
class UserRepository extends Repository
{
    public function __construct(EntityManager $em)
    {
        parent::__construct(User::class, $em);
    }
    
    /**
     * @param non-empty-string $email
     * @return User|null
     */
    public function findByEmail(string $email): ?User
    {
        return $this->findOneBy(['email' => $email]);
    }
    
    /**
     * @param non-empty-string $role
     * @return list<User>
     */
    public function findByRole(string $role): array
    {
        return $this->createQueryBuilder('u')
            ->where('u.roles LIKE :role')
            ->setParameter('role', '%' . $role . '%')
            ->getQuery()
            ->getResult();
    }
    
    /**
     * @param positive-int $days
     * @return list<User>
     */
    public function findInactiveSince(int $days): array
    {
        $date = new \DateTime("-{$days} days");
        
        return $this->createQueryBuilder('u')
            ->where('u.lastLoginAt < :date')
            ->setParameter('date', $date)
            ->getQuery()
            ->getResult();
    }
}

// Usage
$userRepo = new UserRepository($entityManager);

$user = $userRepo->find(123);                        // User|null
$user = $userRepo->findOrFail(123);                  // User (throws if not found)
$admins = $userRepo->findByRole('ROLE_ADMIN');       // list<User>
$inactive = $userRepo->findInactiveSince(30);        // list<User>

Event System

/**
 * Type-safe event dispatcher
 * 
 * @template TEvent of object
 */
class EventDispatcher
{
    /**
     * @var array<class-string<TEvent>, list<callable(TEvent): void>>
     */
    private array $listeners = [];
    
    /**
     * @var array<class-string<TEvent>, list<int>>
     */
    private array $priorities = [];
    
    /**
     * Register an event listener
     * 
     * @template T of TEvent
     * @param class-string<T> $eventClass
     * @param callable(T): void $listener
     * @param int $priority Higher priority listeners execute first
     */
    public function listen(string $eventClass, callable $listener, int $priority = 0): void
    {
        $this->listeners[$eventClass][] = $listener;
        $this->priorities[$eventClass][] = $priority;
        
        // Sort by priority
        array_multisort(
            $this->priorities[$eventClass],
            SORT_DESC,
            $this->listeners[$eventClass]
        );
    }
    
    /**
     * Register a one-time listener
     * 
     * @template T of TEvent
     * @param class-string<T> $eventClass
     * @param callable(T): void $listener
     */
    public function once(string $eventClass, callable $listener): void
    {
        $wrapper = function (object $event) use ($eventClass, $listener, &$wrapper): void {
            $listener($event);
            $this->off($eventClass, $wrapper);
        };
        
        $this->listen($eventClass, $wrapper);
    }
    
    /**
     * Remove a listener
     * 
     * @template T of TEvent
     * @param class-string<T> $eventClass
     * @param callable(T): void $listener
     */
    public function off(string $eventClass, callable $listener): void
    {
        if (!isset($this->listeners[$eventClass])) {
            return;
        }
        
        $index = array_search($listener, $this->listeners[$eventClass], true);
        if ($index !== false) {
            unset($this->listeners[$eventClass][$index]);
            unset($this->priorities[$eventClass][$index]);
            
            $this->listeners[$eventClass] = array_values($this->listeners[$eventClass]);
            $this->priorities[$eventClass] = array_values($this->priorities[$eventClass]);
        }
    }
    
    /**
     * Dispatch an event
     * 
     * @template T of TEvent
     * @param T $event
     */
    public function dispatch(object $event): void
    {
        $eventClass = get_class($event);
        
        // Direct listeners
        if (isset($this->listeners[$eventClass])) {
            foreach ($this->listeners[$eventClass] as $listener) {
                $listener($event);
            }
        }
        
        // Parent class and interface listeners
        foreach ($this->listeners as $class => $listeners) {
            if ($class !== $eventClass && $event instanceof $class) {
                foreach ($listeners as $listener) {
                    $listener($event);
                }
            }
        }
    }
}

// Event definitions
class UserRegisteredEvent
{
    public function __construct(
        public readonly User $user,
        public readonly DateTimeInterface $registeredAt
    ) {}
}

class UserLoginEvent
{
    public function __construct(
        public readonly User $user,
        public readonly string $ipAddress,
        public readonly DateTimeInterface $loginAt
    ) {}
}

abstract class UserEvent
{
    abstract public function getUser(): User;
}

// Usage

Working with Third-Party Code (Stubs)

You don't always have control over the code you use. If a third-party library has missing or incorrect PHPDoc types, you can provide them externally using stub files.

A stub file is a .stub file that contains class/function/method signatures with the correct PHPDoc. PHPStan will read these stubs and apply the types to the vendor code during analysis, without you having to modify the original files in vendor/.

When to Use Stubs

  • A library has no typehints or PHPDocs.
  • A library has incorrect PHPDocs (e.g., @return array when it's actually list<User>).
  • A library uses magic methods like __get or __call that you want to make explicit.

How to Create a Stub File

  1. Create a file with a .stub extension (e.g., project.stubs.php).
  2. Replicate the class/method structure of the library code, but only include the signatures and the correct PHPDoc. Omit the method bodies.
  3. Include the stub file in your phpstan.neon configuration.

Example: A library with a magic __get

Vendor Code (vendor/some/library/src/MagicObject.php):

class MagicObject {
    private $data = [];
    public function __get($name) { return $this->data[$name]; }
}

Your Stub File (stubs/MagicObject.stub):

<?php

namespace Some\Library;

/**
 * @property-read non-empty-string $name
 * @property-read int $id
 */
class MagicObject {}

Your phpstan.neon:

parameters:
    stubFiles:
        - stubs/MagicObject.stub

Now, PHPStan will correctly analyze this code:

$obj = new \Some\Library\MagicObject();
$name = $obj->name; // PHPStan knows this is a non-empty-string
$id = $obj->id;     // PHPStan knows this is an int

PHPDoc Annotations

This section is a quick reference for common PHPDoc annotations used with PHPStan.

Annotation Description
@param Documents a function parameter's type.
@return Documents a function's return type.
@var Documents a variable's type.
@property Documents a magic property on a class.
@method Documents a magic method on a class.
@throws Documents that a function may throw an exception.
@template Defines a generic type parameter for a class or method.
@extends Specifies the generic types for a parent class.
@implements Specifies the generic types for an interface.

PHPStan-Specific Annotations

PHPStan introduces its own set of powerful annotations. Here are some of the most common ones:

Annotation Description
@phpstan-type Creates a local or global type alias.
@phpstan-import-type Imports a global type alias from another class.
@phpstan-assert Asserts that a variable has a specific type after a function call.
@phpstan-assert-if-true Asserts a variable's type if the function returns true.
@phpstan-assert-if-false Asserts a variable's type if the function returns false.
@phpstan-return A more explicit version of @return, often used with $this.
@phpstan-this Refers to the type of the current object, useful in fluent interfaces.
@phpstan-ignore-next-line Tells PHPStan to ignore any errors on the next line.
@phpstan-ignore-line Tells PHPStan to ignore any errors on the current line.
@param-out Indicates a parameter will have a different type after the function call.
@phpstan-self-out Indicates the object will have a different type after the method call.
@phpstan-pure Marks a function as pure (no side effects).
@phpstan-impure Marks a function as impure (has side effects).
@readonly Marks a property as readonly.
@immutable Marks a class as immutable.
@final Marks a method as final in a non-final class.
@no-named-arguments Disables named arguments for a method.
@phpstan-consistent-constructor Ensures constructor compatibility in inheritance.
@phpstan-require-extends Requires a specific parent class for traits.
@phpstan-require-implements Requires specific interfaces for traits.

Integration with Modern PHP

PHPStan is continuously updated to support the latest PHP features.

Readonly Properties

PHPStan understands readonly properties introduced in PHP 8.1. It will flag attempts to modify a readonly property after it has been initialized in the constructor, preventing runtime errors. This works seamlessly with its type analysis, ensuring that once a readonly property is set, its type and value remain constant.

First-Class Callables

PHPStan correctly infers the type of first-class callables, introduced in PHP 8.1. It understands the signature of the callable, allowing it to check that the arguments and return types are correct when the callable is eventually invoked. This is especially powerful when passing callables to functions like array_map or your own generic helpers.

Tooling and Interoperability

Psalm Compatibility

PHPStan and Psalm share many similar PHPDoc annotations. While there are differences, a large subset of types are compatible.

  • Basic types, union types, and array shapes are generally compatible.
  • Generics syntax is similar (@template, @param T).
  • Psalm uses @psalm- prefixes for its specific annotations, while PHPStan uses @phpstan-.

For projects that need to support both, it's often possible to use the common subset of annotations.

IDE Integration

Modern IDEs like PhpStorm have excellent support for PHPStan annotations. They use these types to provide:

  • Smarter autocompletion
  • Inline error detection
  • Better code navigation
  • More accurate refactoring tools

Installing the "PHPStan" and "Deep-assoc" plugins in PhpStorm can further enhance this integration.

Best Practices

  1. Be Specific: Prefer non-empty-string over string. Prefer list<User> over array.
  2. Use Array Shapes: For structured arrays (like configs or API responses), always define an array{...} shape.
  3. Embrace Generics: Don't repeat yourself. Use generics for collections, repositories, and factories.
  4. Centralize Complex Types: Use @phpstan-type and @phpstan-import-type to keep complex type definitions DRY.
  5. Trust the Analyzer: If PHPStan reports an error, don't rush to ignore it. Investigate first. The error often points to a real potential bug.
  6. Incrementally Adopt: When adding PHPStan to a legacy project, start at level 0 and gradually increase the level as you fix errors. Use a baseline file to ignore existing errors temporarily.
  7. Combine with Native Types: Use native PHP typehints wherever possible and augment them with more specific PHPDoc types.

Quick Reference

Scenario Type / Annotation
Required string non-empty-string
Positive ID positive-int
List of items list<T>
Key-value map array<K, V>
Structured array array{...}
Optional value `T
Multiple types `T1
Multiple interfaces T1&T2
Generic class @template T
Generic method @template T
Fluent interface @phpstan-return $this
Type alias @phpstan-type
Constant value self::*

PHPStan Configuration

phpstan.neon Best Practices

PHPStan configuration allows fine-tuning analysis for your specific project needs. The configuration file phpstan.neon is the central place for all settings.

Basic Configuration Structure

parameters:
    level: 9  # Maximum strictness
    
    paths:
        - src
        - tests
    
    excludePaths:
        - src/Legacy/*
        - vendor/*
    
    bootstrapFiles:
        - tests/bootstrap.php
    
    # Parallel processing for speed
    parallel:
        processTimeout: 600.0
        maximumNumberOfProcesses: 4
        minimumNumberOfJobsPerProcess: 8
        jobSize: 32

Level Progression Strategy

Start low and progressively increase levels:

# Start with level 0 for legacy projects
parameters:
    level: 0  # Basic checks only
    
# Gradually increase as you fix issues
# Level 1: Basic type checks
# Level 2: Unknown methods on all expressions
# Level 3: Return types, unknown methods and properties on $this
# Level 4: Dead code checks
# Level 5: Checking types of arguments passed to functions
# Level 6: Reports missing typehints
# Level 7: Reports union types
# Level 8: Reports nullable types
# Level 9: Mixed type checks - strictest level

Baseline Files for Gradual Adoption

Generate a baseline to ignore existing errors:

vendor/bin/phpstan analyse --generate-baseline

This creates phpstan-baseline.neon:

includes:
    - phpstan-baseline.neon

parameters:
    level: 9  # Can use max level with baseline

Extensions and Rules

includes:
    # Symfony support
    - vendor/phpstan/phpstan-symfony/extension.neon
    # Doctrine ORM
    - vendor/phpstan/phpstan-doctrine/extension.neon
    # PHPUnit assertions
    - vendor/phpstan/phpstan-phpunit/extension.neon
    # Strict rules
    - vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
    symfony:
        containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
    
    doctrine:
        objectManagerLoader: tests/object-manager.php

Custom Rules Registration

services:
    -
        class: App\PHPStan\Rules\Domain\NoDomainDependencyOnInfrastructureRule
        tags:
            - phpstan.rules.rule
    
    -
        class: App\PHPStan\Rules\NoDebugStatementsRule
        tags:
            - phpstan.rules.rule

Performance Tuning

parameters:
    # Result cache for faster subsequent runs
    resultCachePath: var/cache/phpstan
    
    # Temporary directory
    tmpDir: var/phpstan
    
    # Memory limit
    memoryLimitFile: .phpstan-memory-limit
    
    # Disable expensive checks for speed
    checkMissingIterableValueType: false
    checkGenericClassInNonGenericObjectType: false

CI/CD Integration

GitHub Actions Integration

name: PHPStan Analysis

on: [push, pull_request]

jobs:
  phpstan:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: mbstring, intl
          coverage: none
      
      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
      
      - name: Cache PHPStan results
        uses: actions/cache@v3
        with:
          path: var/cache/phpstan
          key: ${{ runner.os }}-phpstan-${{ hashFiles('**/phpstan.neon') }}
      
      - name: Install dependencies
        run: composer install --no-progress --no-suggest
      
      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --no-progress --no-interaction

GitLab CI Integration

phpstan:
  stage: test
  image: php:8.2-cli
  
  before_script:
    - apt-get update && apt-get install -y git unzip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --no-progress --no-suggest
  
  script:
    - vendor/bin/phpstan analyse --no-progress --no-interaction
  
  cache:
    paths:
      - vendor/
      - var/cache/phpstan/
  
  only:
    - merge_requests
    - master
    - develop

Pre-commit Hook

#!/bin/sh
# .git/hooks/pre-commit

# Run PHPStan on staged PHP files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep ".php$")

if [ -n "$STAGED_FILES" ]; then
    echo "Running PHPStan..."
    vendor/bin/phpstan analyse $STAGED_FILES
    
    if [ $? -ne 0 ]; then
        echo "PHPStan found errors. Please fix them before committing."
        exit 1
    fi
fi

Docker Integration

# Dockerfile
FROM php:8.2-cli

# Install PHPStan globally
RUN curl -L https://github.com/phpstan/phpstan/releases/latest/download/phpstan.phar -o /usr/local/bin/phpstan \
    && chmod +x /usr/local/bin/phpstan

# Or via Composer in your project
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader

# Copy source code
COPY . .

# Run analysis
RUN vendor/bin/phpstan analyse

Common Pitfalls

Type Inference Limitations

The Mixed Type Problem

// ❌ BAD: PHPStan can't infer the type
function processData($data) {
    // $data is 'mixed' - PHPStan can't help
    return $data->getName(); // Error: Cannot call method on mixed
}

// ✅ GOOD: Explicit type declaration
function processData(User $data): string {
    return $data->getName(); // PHPStan knows this is safe
}

Array Access Uncertainty

// ❌ BAD: PHPStan doesn't know if key exists
function getConfig(array $config): string {
    return $config['debug']; // Possibly undefined index
}

// ✅ GOOD: Use array shapes or check existence
/**
 * @param array{debug: bool, name: string} $config
 */
function getConfig(array $config): string {
    return $config['name']; // PHPStan knows this exists
}

// ✅ ALSO GOOD: Runtime check
function getConfig(array $config): string {
    if (!isset($config['name'])) {
        throw new InvalidArgumentException('Missing name');
    }
    return $config['name']; // Safe after check
}

Dealing with Dynamic Code

Magic Methods

// ❌ BAD: PHPStan can't analyze magic methods
class MagicClass {
    public function __call($method, $args) {
        // Dynamic implementation
    }
}

$magic = new MagicClass();
$magic->someMethod(); // PHPStan error: Method not found

// ✅ GOOD: Document with @method
/**
 * @method string someMethod()
 * @method void setData(array $data)
 */
class MagicClass {
    public function __call($method, $args) {
        // Implementation
    }
}

Dynamic Properties

// ❌ BAD: Dynamic property access
class DynamicClass {
    public function __get($name) {
        return $this->data[$name] ?? null;
    }
}

$obj = new DynamicClass();
echo $obj->unknownProperty; // PHPStan can't verify

// ✅ GOOD: Use @property annotations
/**
 * @property string $name
 * @property int $age
 * @property-read string $id
 */
class DynamicClass {
    // Implementation
}

Framework-Specific Challenges

Symfony Service Injection

// ❌ BAD: PHPStan doesn't know about container services
class MyController {
    public function index() {
        $service = $this->get('my.service'); // Returns mixed
        $service->doSomething(); // Error
    }
}

// ✅ GOOD: Use constructor injection
class MyController {
    public function __construct(
        private MyService $myService
    ) {}
    
    public function index() {
        $this->myService->doSomething(); // Type-safe
    }
}

Doctrine Entity Proxies

// ❌ PROBLEM: Doctrine returns proxy classes
$user = $entityManager->find(User::class, 1);
// $user might be Proxies\__CG__\App\Entity\User

// ✅ SOLUTION: Use interface or base class
/**
 * @return User (not the proxy)
 */
public function getUser(int $id): User {
    /** @var User $user */
    $user = $this->entityManager->find(User::class, $id);
    return $user;
}

Performance Optimization Tips

1. Use Result Cache

parameters:
    resultCachePath: var/cache/phpstan

2. Limit Scope During Development

# Analyze only changed files
vendor/bin/phpstan analyse src/Controller/UserController.php

# Use --debug to find slow spots
vendor/bin/phpstan analyse --debug

3. Optimize Memory Usage

parameters:
    # Increase memory for large codebases
    memoryLimitFile: .phpstan-memory-limit
    
    # Disable memory-intensive features if needed
    checkMissingIterableValueType: false

4. Parallel Processing

parameters:
    parallel:
        maximumNumberOfProcesses: 4  # Adjust based on CPU cores
        minimumNumberOfJobsPerProcess: 8
        jobSize: 32

Debugging PHPStan Issues

Understanding Error Messages

# Get more context with -vvv
vendor/bin/phpstan analyse -vvv

# See what rules are triggered
vendor/bin/phpstan analyse --debug

# Check your configuration
vendor/bin/phpstan analyse --ansi --debug

Common Error Patterns

  1. "Call to an undefined method" - Usually missing @method annotation or wrong type inference
  2. "Access to an undefined property" - Missing @property annotation or typo
  3. "Parameter #1 expects X, Y given" - Type mismatch, might need type assertion
  4. "Method X() should return Y but returns Z" - Wrong return type annotation

Using Assertions for Complex Cases

// When PHPStan can't infer the type
if (!$value instanceof ExpectedType) {
    throw new LogicException('Unexpected type');
}
// Now PHPStan knows $value is ExpectedType

// Or use assert for development
assert($value instanceof ExpectedType);

Integration with Legacy Code

Gradual Type Introduction

// Step 1: Add @param annotations only
/**
 * @param string $name
 * @param int $age
 */
function createUser($name, $age) {
    // Legacy implementation
}

// Step 2: Add return type annotation
/**
 * @param string $name
 * @param int $age
 * @return User
 */
function createUser($name, $age) {
    // Implementation
}

// Step 3: Add native types when possible
function createUser(string $name, int $age): User {
    // Fully typed
}

Handling Third-Party Code Without Types

// Create stub files for libraries without types
// stubs/SomeLibrary.stub
namespace SomeLibrary;

class Client {
    public function request(string $method, string $url): Response {}
}

class Response {
    public function getBody(): string {}
    public function getStatusCode(): int {}
}

Then reference in phpstan.neon:

parameters:
    stubFiles:
        - stubs/SomeLibrary.stub

DDD-Specific Typing Patterns

Domain-Driven Design requires careful typing to maintain domain integrity and express business rules clearly.

Value Objects

/**
 * @phpstan-immutable
 */
final class Money
{
    /**
     * @param positive-int $amount Amount in cents
     * @param non-empty-string $currency ISO 4217 code
     */
    private function __construct(
        private int $amount,
        private string $currency
    ) {}
    
    /**
     * @param numeric-string $amount
     * @param non-empty-string $currency
     * @throws InvalidArgumentException
     */
    public static function fromString(string $amount, string $currency): self
    {
        $cents = (int) bcmul($amount, '100', 0);
        
        if ($cents <= 0) {
            throw new InvalidArgumentException('Amount must be positive');
        }
        
        return new self($cents, strtoupper($currency));
    }
    
    /**
     * @return array{amount: positive-int, currency: non-empty-string}
     */
    public function toArray(): array
    {
        return [
            'amount' => $this->amount,
            'currency' => $this->currency,
        ];
    }
}

Entities and Aggregate Roots

/**
 * @template TId of EntityId
 */
abstract class Entity
{
    /**
     * @param TId $id
     */
    protected function __construct(
        private EntityId $id
    ) {}
    
    /**
     * @return TId
     */
    public function getId(): EntityId
    {
        return $this->id;
    }
}

/**
 * @extends Entity<UserId>
 */
final class User extends Entity
{
    /**
     * @var list<DomainEvent>
     */
    private array $events = [];
    
    /**
     * @param non-empty-string $email
     * @param non-empty-string $name
     */
    public function __construct(
        UserId $id,
        private string $email,
        private string $name
    ) {
        parent::__construct($id);
        
        $this->recordEvent(new UserCreated($id, $email, $name));
    }
    
    /**
     * @return list<DomainEvent>
     */
    public function pullEvents(): array
    {
        $events = $this->events;
        $this->events = [];
        
        return $events;
    }
    
    private function recordEvent(DomainEvent $event): void
    {
        $this->events[] = $event;
    }
}

Repository Interfaces

/**
 * @template T of AggregateRoot
 * @template TId of AggregateId
 */
interface Repository
{
    /**
     * @param TId $id
     * @return T|null
     */
    public function find(AggregateId $id): ?AggregateRoot;
    
    /**
     * @param T $aggregate
     */
    public function save(AggregateRoot $aggregate): void;
    
    /**
     * @param TId $id
     * @throws AggregateNotFoundException
     */
    public function remove(AggregateId $id): void;
}

/**
 * @implements Repository<User, UserId>
 */
class UserRepository implements Repository
{
    /**
     * @param UserId $id
     * @return User|null
     */
    public function find(AggregateId $id): ?AggregateRoot
    {
        // Implementation
    }
    
    /**
     * @param non-empty-string $email
     * @return User|null
     */
    public function findByEmail(string $email): ?User
    {
        // Domain-specific finder
    }
}

Domain Services

/**
 * Domain service for complex business operations
 */
final class PricingService
{
    /**
     * @param list<OrderItem> $items
     * @param DiscountCode|null $discountCode
     * @return Money
     */
    public function calculateTotal(
        array $items,
        ?DiscountCode $discountCode = null
    ): Money {
        /** @var positive-int $total */
        $total = array_reduce(
            $items,
            fn(int $sum, OrderItem $item): int => $sum + $item->getSubtotal(),
            0
        );
        
        if ($discountCode !== null) {
            $total = $this->applyDiscount($total, $discountCode);
        }
        
        return Money::fromCents($total, 'USD');
    }
    
    /**
     * @param positive-int $amount
     * @return positive-int
     */
    private function applyDiscount(int $amount, DiscountCode $code): int
    {
        $discounted = (int) ($amount * (1 - $code->getPercentage() / 100));
        
        return max(1, $discounted); // Ensure positive
    }
}

Event Sourcing Types (Patchlevel)

When using Patchlevel for event sourcing, here are the typing patterns:

Events

use Patchlevel\EventSourcing\Attribute\Event;
use Patchlevel\EventSourcing\Attribute\Normalize;
use Patchlevel\EventSourcing\Serializer\Normalizer\DateTimeImmutableNormalizer;

/**
 * @phpstan-immutable
 */
#[Event(name: 'product.created', aliases: ['product.added'])]
final class ProductCreated
{
    /**
     * @param non-empty-string $aggregateId
     * @param non-empty-string $name
     * @param non-empty-string $sku
     * @param positive-int $priceInCents
     * @param non-empty-string $currency
     */
    public function __construct(
        public readonly string $aggregateId,
        public readonly string $name,
        public readonly string $sku,
        public readonly int $priceInCents,
        public readonly string $currency,
        #[Normalize(new DateTimeImmutableNormalizer())]
        public readonly DateTimeImmutable $occurredAt,
    ) {}
}

Aggregates

use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Id;

/**
 * @phpstan-consistent-constructor
 */
#[Aggregate(name: 'product')]
final class ProductAggregate extends BasicAggregateRoot
{
    #[Id]
    private ProductId $id;
    
    /**
     * @var non-empty-string
     */
    private string $name;
    
    /**
     * @var non-empty-string
     */
    private string $sku;
    
    private Money $price;
    
    /**
     * @param non-empty-string $name
     * @param non-empty-string $sku
     * @param positive-int $priceInCents
     * @param non-empty-string $currency
     */
    public static function create(
        string $name,
        string $sku,
        int $priceInCents,
        string $currency
    ): self {
        $id = ProductId::generate();
        $self = new self();
        
        $self->recordThat(new ProductCreated(
            $id->toString(),
            $name,
            $sku,
            $priceInCents,
            $currency,
            new DateTimeImmutable()
        ));
        
        return $self;
    }
    
    #[Apply]
    public function applyProductCreated(ProductCreated $event): void
    {
        $this->id = ProductId::fromString($event->aggregateId);
        $this->name = $event->name;
        $this->sku = $event->sku;
        $this->price = Money::fromCents($event->priceInCents, $event->currency);
    }
    
    /**
     * @return list<object>
     */
    public function pullDomainEvents(): array
    {
        return $this->releaseEvents();
    }
}

Projections

use Patchlevel\EventSourcing\Attribute\Projector;
use Patchlevel\EventSourcing\Attribute\Subscribe;
use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil;

#[Projector(name: 'product_catalog')]
final class ProductCatalogProjection
{
    use SubscriberUtil;
    
    /**
     * @param array<non-empty-string, ProductReadModel> $products
     */
    public function __construct(
        private array $products = []
    ) {}
    
    #[Subscribe(ProductCreated::class)]
    public function onProductCreated(ProductCreated $event): void
    {
        $this->products[$event->sku] = new ProductReadModel(
            $event->aggregateId,
            $event->name,
            $event->sku,
            Money::fromCents($event->priceInCents, $event->currency),
            'available',
            $event->occurredAt
        );
    }
    
    /**
     * @param non-empty-string $sku
     */
    public function findBySku(string $sku): ?ProductReadModel
    {
        return $this->products[$sku] ?? null;
    }
}

Project-Specific Type Patterns

Domain-Specific Types

/**
 * @phpstan-type ProductConfig = array{
 *     minPrice: positive-int,
 *     maxPrice: positive-int,
 *     allowedCategories: list<non-empty-string>,
 *     taxRates: array<non-empty-string, float>,
 *     defaultCurrency: non-empty-string
 * }
 */
class ProductConfiguration
{
    /**
     * @param ProductConfig $config
     */
    public static function fromArray(array $config): self
    {
        // Validation and construction
    }
}

/**
 * @phpstan-type OrderLineItem = array{
 *     productId: non-empty-string,
 *     quantity: positive-int,
 *     unitPrice: positive-int,
 *     discount?: non-negative-int,
 *     tax?: non-negative-int,
 *     metadata?: array<string, mixed>
 * }
 */
interface OrderService
{
    /**
     * @param non-empty-string $orderId
     * @param list<OrderLineItem> $items
     */
    public function addItems(string $orderId, array $items): void;
    
    /**
     * @param non-empty-string $orderId
     * @return list<OrderLineItem>
     */
    public function getOrderItems(string $orderId): array;
}

Service Layer Types

/**
 * @phpstan-type CreateProductDTO = array{
 *     name: non-empty-string,
 *     sku: non-empty-string,
 *     price: positive-int,
 *     currency: non-empty-string,
 *     category: non-empty-string,
 *     description?: string,
 *     attributes?: array<string, mixed>
 * }
 *
 * @phpstan-type ProductResponseDTO = array{
 *     id: non-empty-string,
 *     name: non-empty-string,
 *     sku: non-empty-string,
 *     price: positive-int,
 *     currency: non-empty-string,
 *     status: 'available'|'out_of_stock'|'discontinued',
 *     createdAt: DateTimeImmutable,
 *     inventory: array{
 *         quantity: non-negative-int,
 *         reserved: non-negative-int,
 *         available: non-negative-int
 *     }
 * }
 */
final class ProductApplicationService
{
    /**
     * @param CreateProductDTO $data
     * @return ProductResponseDTO
     * @throws ValidationException
     * @throws DuplicateSkuException
     */
    public function createProduct(array $data): array
    {
        // Implementation with full type safety
    }
}

Command and Query Types

/**
 * @phpstan-immutable
 */
final class CreateOrderCommand
{
    /**
     * @param non-empty-string $customerId
     * @param list<array{productId: non-empty-string, quantity: positive-int}> $items
     * @param array<string, mixed> $metadata
     */
    public function __construct(
        public readonly string $customerId,
        public readonly array $items,
        public readonly array $metadata = []
    ) {}
}

/**
 * @template T
 */
interface QueryHandler
{
    /**
     * @return T
     */
    public function handle(Query $query): mixed;
}

/**
 * @phpstan-type OrderSummary = array{
 *     orderId: non-empty-string,
 *     customerId: non-empty-string,
 *     total: non-negative-int,
 *     currency: non-empty-string,
 *     status: 'pending'|'paid'|'shipped'|'delivered'|'cancelled',
 *     items: list<OrderLineItem>
 * }
 *
 * @implements QueryHandler<OrderSummary>
 */
final class GetOrderByIdHandler implements QueryHandler
{
    /**
     * @return OrderSummary
     * @throws OrderNotFoundException
     */
    public function handle(Query $query): mixed
    {
        // Type-safe implementation
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment