- Introduction
- Basic Types
- Advanced Types
- More Advanced Types
- Generics (Templates)
- Practical Patterns
- Working with Third-Party Code (Stubs)
- PHPDoc Annotations
- Integration with Modern PHP
- Tooling and Interoperability
- Best Practices
- Quick Reference
- PHPStan Configuration
- CI/CD Integration
- Common Pitfalls
- DDD-Specific Typing Patterns
- Event Sourcing Types (Patchlevel)
- Project-Specific Type Patterns
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
- Catch bugs before runtime - Find null pointer exceptions, undefined methods, and type mismatches during development
- Self-documenting code - Types serve as always-up-to-date inline documentation
- Confident refactoring - Know immediately when changes break type contracts
- Better IDE support - Modern IDEs use these types for intelligent autocompletion
- Enforce business rules - Express constraints like "positive integers only" or "non-empty strings"
- 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
PHPStan extends PHP's type system with more specific types that help catch bugs and express intent clearly.
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 |
/**
* 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 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 |
/**
* 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
];
}
}
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 |
/**
* 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
}
}
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 |
/**
* 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');
}
}
Union types represent "either/or" relationships where a value can be one of several types.
- Optional values (
T|null
) - Multiple valid input formats (
string|array
) - Success/failure returns (
Result|Error
) - Flexible APIs that accept various types
/**
* 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 represent "both/and" relationships where a value must satisfy multiple type constraints.
- Multiple interface requirements
- Adding constraints to existing types
- Combining capabilities
- Ensuring objects have required methods
/**
* 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 types allow you to specify exact values, creating precise type constraints.
/**
* 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));
}
}
/**
* 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
}
}
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.
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
{
// ...
}
}
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 is the process of refining types from general to specific through runtime checks.
/**
* 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;
}
}
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
.
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}
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'>
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;
}
}
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);
}
}
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
}
}
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
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 |
/**
* @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>
Sometimes, a function's return type depends on the value of an argument. You can express this relationship using conditional return types.
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
/**
* @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.1 introduced native enumerations. PHPStan fully understands them, allowing you to type them precisely.
// 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);
}
}
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)
);
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 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
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 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 allow you to write reusable code that works with different types while maintaining type safety.
Generics solve the problem of writing the same code for different types. Instead of creating StringCollection
, IntCollection
, UserCollection
, you create one generic Collection<T>
.
- Type Safety - Catch type errors at analysis time
- Code Reuse - Write once, use with any type
- Better IDE Support - Autocompletion knows exact types
- Self-Documenting - Types explain themselves
/**
* 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'));
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);
/**
* 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 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
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);
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>
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
{
// ...
}
}
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();
Real-world examples of how to use PHPStan types effectively.
/**
* 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>
/**
* 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>
/**
* 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
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/
.
- A library has no typehints or PHPDocs.
- A library has incorrect PHPDocs (e.g.,
@return array
when it's actuallylist<User>
). - A library uses magic methods like
__get
or__call
that you want to make explicit.
- Create a file with a
.stub
extension (e.g.,project.stubs.php
). - Replicate the class/method structure of the library code, but only include the signatures and the correct PHPDoc. Omit the method bodies.
- 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
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 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. |
PHPStan is continuously updated to support the latest PHP features.
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.
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.
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.
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.
- Be Specific: Prefer
non-empty-string
overstring
. Preferlist<User>
overarray
. - Use Array Shapes: For structured arrays (like configs or API responses), always define an
array{...}
shape. - Embrace Generics: Don't repeat yourself. Use generics for collections, repositories, and factories.
- Centralize Complex Types: Use
@phpstan-type
and@phpstan-import-type
to keep complex type definitions DRY. - 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.
- 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.
- Combine with Native Types: Use native PHP typehints wherever possible and augment them with more specific PHPDoc types.
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 allows fine-tuning analysis for your specific project needs. The configuration file phpstan.neon
is the central place for all settings.
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
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
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
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
services:
-
class: App\PHPStan\Rules\Domain\NoDomainDependencyOnInfrastructureRule
tags:
- phpstan.rules.rule
-
class: App\PHPStan\Rules\NoDebugStatementsRule
tags:
- phpstan.rules.rule
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
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
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
#!/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
# 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
// ❌ 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
}
// ❌ 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
}
// ❌ 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
}
}
// ❌ 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
}
// ❌ 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
}
}
// ❌ 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;
}
parameters:
resultCachePath: var/cache/phpstan
# Analyze only changed files
vendor/bin/phpstan analyse src/Controller/UserController.php
# Use --debug to find slow spots
vendor/bin/phpstan analyse --debug
parameters:
# Increase memory for large codebases
memoryLimitFile: .phpstan-memory-limit
# Disable memory-intensive features if needed
checkMissingIterableValueType: false
parameters:
parallel:
maximumNumberOfProcesses: 4 # Adjust based on CPU cores
minimumNumberOfJobsPerProcess: 8
jobSize: 32
# 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
- "Call to an undefined method" - Usually missing @method annotation or wrong type inference
- "Access to an undefined property" - Missing @property annotation or typo
- "Parameter #1 expects X, Y given" - Type mismatch, might need type assertion
- "Method X() should return Y but returns Z" - Wrong return type annotation
// 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);
// 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
}
// 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
Domain-Driven Design requires careful typing to maintain domain integrity and express business rules clearly.
/**
* @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,
];
}
}
/**
* @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;
}
}
/**
* @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 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
}
}
When using Patchlevel for event sourcing, here are the typing patterns:
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,
) {}
}
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();
}
}
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;
}
}
/**
* @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;
}
/**
* @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
}
}
/**
* @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
}
}