AGENTS.md
# PHP Best Practices - Complete Guide
**Version:** 2.1.0
**Focus:** PHP 8.0 - 8.5, PSR Standards, Modern PHP Features
**Rules:** 51 (9 type + 16 modern + 6 PSR + 5 SOLID + 5 error + 5 perf + 5 security)
**License:** MIT
---
## Step 1: Detect PHP Version
**Always check the project's PHP version before giving advice.** Features vary across 8.0 - 8.5.
```bash
# Check composer.json
grep '"php"' composer.json # e.g. "^8.2"
# Check runtime
php -v # e.g. PHP 8.3.12
```
**Only suggest features available in the detected version:**
| Version | Key Features Added |
|---------|-------------------|
| 8.0+ | Union types, match, nullsafe, named args, constructor promotion, attributes |
| 8.1+ | Enums, readonly props, intersection types, first-class callables, never |
| 8.2+ | Readonly classes, DNF types |
| 8.3+ | Typed class constants, `#[\Override]`, `json_validate()` |
| 8.4+ | Property hooks, asymmetric visibility, `#[\Deprecated]`, `new` without parens |
| 8.5+ | Pipe operator `\|>` |
## Overview
Comprehensive PHP 8.x best practices covering type system, modern features, PSR standards, SOLID principles, error handling, performance, and security. Each rule includes bad and good examples with detailed explanations.
## Categories
1. **Type System (CRITICAL)** - Strict types, return types, union types, null handling
2. **Modern PHP Features (CRITICAL)** - Constructor promotion, enums, readonly, match, property hooks, pipe operator
3. **PSR Standards (HIGH)** - PSR-4 autoloading, PSR-12 coding style, naming conventions
4. **SOLID Principles (HIGH)** - SRP, OCP, LSP, ISP, DIP
5. **Error Handling (HIGH)** - Custom exceptions, proper try-catch, error recovery
6. **Performance (MEDIUM)** - Generators, lazy loading, optimization techniques
7. **Security (CRITICAL)** - Input validation, output escaping, password hashing, SQL injection prevention
---
## 1. Type System
### 1.1 Strict Types Declaration
**Impact:** CRITICAL
Always enable strict type checking at the beginning of every PHP file with `declare(strict_types=1)`.
**Why:** Prevents silent type coercion bugs, catches type errors immediately, enables better static analysis.
**Bad:**
```php
<?php
// No strict types
function add(int $a, int $b): int {
return $a + $b;
}
add("5", "10"); // Returns 15 - strings coerced silently
```
**Good:**
```php
<?php
declare(strict_types=1);
function add(int $a, int $b): int {
return $a + $b;
}
add(5, 10); // OK: Returns 15
add("5", "10"); // Error: TypeError
```
---
### 1.2 Return Type Declarations
**Impact:** CRITICAL
Always declare return types for all methods and functions.
**Why:** Self-documenting, enforces contracts, enables IDE autocompletion, catches return type mismatches.
**Bad:**
```php
<?php
class UserRepository {
public function find($id) {
return $this->db->query("SELECT * FROM users WHERE id = ?", [$id]);
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class UserRepository {
public function find(int $id): ?User
{
$data = $this->db->query("SELECT * FROM users WHERE id = ?", [$id]);
return $data ? new User($data) : null;
}
}
```
---
### 1.3 Parameter Type Declarations
**Impact:** CRITICAL
Always declare parameter types for all function and method parameters.
**Bad:**
```php
<?php
function createOrder($user, $items) {
// What is $user? What format should $items be?
}
```
**Good:**
```php
<?php
declare(strict_types=1);
function createOrder(User $user, array $items): Order {
$order = new Order($user);
foreach ($items as $item) {
$order->addItem($item);
}
return $order;
}
```
---
### 1.4 Property Type Declarations
**Impact:** CRITICAL
Always declare types for class properties (PHP 7.4+).
**Bad:**
```php
<?php
class Product {
private $id;
private $name;
private $price;
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class Product {
private int $id;
private string $name;
private float $price;
private array $categories = [];
}
```
---
### 1.5 Union Types
**Impact:** HIGH
Use union types when a value can legitimately be one of multiple types (PHP 8.0+).
**Bad:**
```php
<?php
class DataLoader {
/**
* @param string|array $source
*/
public function load($source) {
// Relies on docblock, no type enforcement
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class DataLoader {
public function load(string|array $source): array
{
if (is_string($source)) {
return $this->loadFromFile($source);
}
return $this->loadFromArray($source);
}
}
```
---
### 1.6 Nullable Types
**Impact:** CRITICAL
Use nullable types explicitly when null is a valid value.
**Bad:**
```php
<?php
class UserService {
public function findByEmail(string $email) {
// Unclear if null is valid return
return $this->repository->find($email) ?: null;
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class UserService {
public function findByEmail(string $email): ?User
{
return $this->repository->find($email);
}
}
```
---
## 2. Modern PHP Features
### 2.1 Constructor Property Promotion
**Impact:** CRITICAL
Use constructor property promotion to reduce boilerplate (PHP 8.0+).
**Bad:**
```php
<?php
class User {
private string $id;
private string $name;
private string $email;
public function __construct(string $id, string $name, string $email) {
$this->id = $id;
$this->name = $name;
$this->email = $email;
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class User {
public function __construct(
private string $id,
private string $name,
private string $email,
) {}
}
```
---
### 2.2 Type-Safe Enums
**Impact:** CRITICAL
Use enums instead of class constants for finite sets of values (PHP 8.1+).
**Bad:**
```php
<?php
class OrderStatus {
public const PENDING = 'pending';
public const SHIPPED = 'shipped';
}
function updateStatus(string $status): void {
// 'invalid' would be accepted
}
```
**Good:**
```php
<?php
declare(strict_types=1);
enum OrderStatus: string {
case Pending = 'pending';
case Shipped = 'shipped';
case Delivered = 'delivered';
public function label(): string {
return match($this) {
self::Pending => 'Awaiting Processing',
self::Shipped => 'On the Way',
self::Delivered => 'Delivered',
};
}
}
function updateStatus(OrderStatus $status): void {
// Only valid enum values accepted
}
```
---
### 2.3 Readonly Properties
**Impact:** CRITICAL
Use readonly properties for immutable data (PHP 8.1+).
**Bad:**
```php
<?php
class Invoice {
private string $invoiceNumber;
// Setter allows modification after creation
public function setInvoiceNumber(string $number): void {
$this->invoiceNumber = $number;
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class Invoice {
public function __construct(
public readonly string $invoiceNumber,
public readonly DateTimeImmutable $issuedAt,
public readonly float $amount,
) {}
// No setters - properties are immutable
}
```
---
### 2.4 Match Expression
**Impact:** HIGH
Use match expressions instead of switch for cleaner, type-safe code (PHP 8.0+).
**Bad:**
```php
<?php
function getStatusMessage(int $code): string {
switch ($code) {
case 200:
$message = 'OK';
break;
case 404:
$message = 'Not Found';
break;
default:
$message = 'Unknown';
}
return $message;
}
```
**Good:**
```php
<?php
declare(strict_types=1);
function getStatusMessage(int $code): string {
return match ($code) {
200 => 'OK',
404 => 'Not Found',
default => 'Unknown',
};
}
```
---
### 2.5 Nullsafe Operator
**Impact:** HIGH
Use the nullsafe operator for cleaner null checking chains (PHP 8.0+).
**Bad:**
```php
<?php
function getCountry(?Order $order): ?string {
if ($order !== null) {
$customer = $order->getCustomer();
if ($customer !== null) {
$address = $customer->getAddress();
if ($address !== null) {
return $address->getCountry();
}
}
}
return null;
}
```
**Good:**
```php
<?php
declare(strict_types=1);
function getCountry(?Order $order): ?string {
return $order?->getCustomer()?->getAddress()?->getCountry();
}
```
---
### 2.6 Arrow Functions
**Impact:** MEDIUM
Use arrow functions for short, single-expression closures (PHP 7.4+).
**Bad:**
```php
<?php
$doubled = array_map(function ($n) {
return $n * 2;
}, $numbers);
$multiplier = 3;
$multiplied = array_map(function ($n) use ($multiplier) {
return $n * $multiplier;
}, $numbers);
```
**Good:**
```php
<?php
declare(strict_types=1);
$doubled = array_map(fn($n) => $n * 2, $numbers);
$multiplier = 3;
$multiplied = array_map(fn($n) => $n * $multiplier, $numbers);
```
---
### 2.7 Typed Class Constants (8.3+)
**Impact:** HIGH
Add type declarations to class constants for type safety.
**Bad:**
```php
<?php
class Config {
public const TIMEOUT = 30; // int? string? no enforcement
public const NAME = 'my-app';
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class Config {
public const int TIMEOUT = 30;
public const string NAME = 'my-app';
public const array ALLOWED = ['read', 'write'];
}
```
---
### 2.8 Override Attribute (8.3+)
**Impact:** HIGH
Use `#[\Override]` on methods that override a parent to catch typos and refactoring errors.
**Bad:**
```php
<?php
class UserRepo extends BaseRepo {
public function findByld(int $id): ?User { /* typo: 'l' not 'I' */ }
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class UserRepo extends BaseRepo {
#[\Override]
public function findById(int $id): ?User { /* typo caught when class is loaded */ }
}
```
---
### 2.9 Property Hooks (8.4+)
**Impact:** HIGH
Use property hooks to define get/set logic directly on properties.
**Bad:**
```php
<?php
class User {
private string $name;
public function getName(): string { return $this->name; }
public function setName(string $v): void { $this->name = ucfirst($v); }
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class User {
public string $name { set => ucfirst(strtolower($value)); }
public string $fullName { get => $this->firstName . ' ' . $this->lastName; }
}
```
---
### 2.10 Asymmetric Visibility (8.4+)
**Impact:** HIGH
Use `public private(set)` for properties that are publicly readable but privately writable.
**Bad:**
```php
<?php
class Order {
private string $status;
public function getStatus(): string { return $this->status; }
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class Order {
public private(set) string $status = 'pending';
public function markPaid(): void {
$this->status = 'paid'; // OK - internal set
}
}
echo $order->status; // OK - public read
// $order->status = 'x'; // Error - private set
```
---
### 2.11 Pipe Operator (8.5+)
**Impact:** HIGH
Use the pipe operator for readable left-to-right function chaining.
**Bad:**
```php
<?php
$result = htmlspecialchars(strtolower(trim($input)));
```
**Good:**
```php
<?php
declare(strict_types=1);
$result = $input
|> trim(...)
|> strtolower(...)
|> htmlspecialchars(...);
```
---
## 3. PSR Standards
### 3.1 PSR-4 Autoloading
**Impact:** CRITICAL
Follow PSR-4 autoloading standard for class file organization.
**Structure:**
```
src/
Domain/
User/
User.php -> App\Domain\User\User
UserRepository.php
Application/
Services/
UserService.php -> App\Application\Services\UserService
```
**composer.json:**
```json
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
```
---
### 3.2 PSR-12 Coding Style
**Impact:** HIGH
Follow PSR-12 extended coding style for consistent, readable code.
**Key Rules:**
- Opening braces on their own line for classes, methods, and functions
- One blank line after namespace and use blocks
- Spaces after control structure keywords
- One blank line between methods
- Type declarations with no space before colon
**Good:**
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
class UserService
{
public function __construct(
private UserRepository $repository,
) {}
public function find(int $id): ?User
{
if ($id < 1) {
return null;
}
return $this->repository->find($id);
}
}
```
---
### 3.3 Class Naming Conventions
**Impact:** HIGH
Use PascalCase with descriptive, intention-revealing names.
**Patterns:**
- Entities: `User`, `Order`, `Product`
- Services: `UserService`, `OrderService`
- Repositories: `UserRepository`, `OrderRepository`
- Controllers: `UserController`, `OrderController`
- Commands: `CreateUserCommand`, `ProcessPaymentCommand`
- Events: `UserCreated`, `OrderShipped` (past tense)
- Exceptions: `UserNotFoundException`, `InvalidPaymentMethodException`
- Interfaces: `Cacheable`, `UserRepository`, `PaymentGateway`
---
## 4. SOLID Principles
### 4.1 Single Responsibility Principle
**Impact:** CRITICAL
A class should have only one reason to change.
**Bad:**
```php
<?php
class User {
// Multiple responsibilities
public function save(): void { /* DB */ }
public function sendEmail(): void { /* Email */ }
public function toJson(): string { /* Serialization */ }
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class User {
// Just user data
}
class UserRepository {
public function save(User $user): void { /* DB */ }
}
class UserMailer {
public function sendWelcome(User $user): void { /* Email */ }
}
class UserSerializer {
public function toJson(User $user): string { /* Serialization */ }
}
```
---
### 4.2 Open/Closed Principle
**Impact:** HIGH
Classes should be open for extension but closed for modification.
**Bad:**
```php
<?php
class PaymentProcessor {
public function process(string $type, float $amount): void {
if ($type === 'credit_card') { /* ... */ }
if ($type === 'paypal') { /* ... */ }
// Adding new type requires modifying this class
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
interface PaymentMethod {
public function process(Money $amount): PaymentResult;
}
class CreditCardPayment implements PaymentMethod {
public function process(Money $amount): PaymentResult { /* ... */ }
}
class PayPalPayment implements PaymentMethod {
public function process(Money $amount): PaymentResult { /* ... */ }
}
// Add new payment methods without modifying existing code
class CryptoPayment implements PaymentMethod {
public function process(Money $amount): PaymentResult { /* ... */ }
}
```
---
### 4.3 Dependency Inversion Principle
**Impact:** CRITICAL
Depend on abstractions, not concretions.
**Bad:**
```php
<?php
class OrderService {
private MySqlDatabase $db;
public function __construct() {
$this->db = new MySqlDatabase();
}
}
```
**Good:**
```php
<?php
declare(strict_types=1);
interface OrderRepository {
public function save(Order $order): void;
public function find(OrderId $id): ?Order;
}
class OrderService {
public function __construct(
private OrderRepository $repository,
private PaymentGateway $payment,
private Logger $logger,
) {}
}
class DoctrineOrderRepository implements OrderRepository {
// Implementation
}
```
---
## 5. Error Handling
### 5.1 Custom Exceptions
**Impact:** HIGH
Create specific exception classes instead of using generic `\Exception`.
**Bad:**
```php
<?php
throw new \Exception('Email already exists'); // Caller can't distinguish error types
```
**Good:**
```php
<?php
declare(strict_types=1);
class DuplicateEmailException extends \RuntimeException
{
public function __construct(private readonly string $email)
{
parent::__construct("Email already registered: {$email}");
}
}
// Caller handles specifically
try {
$service->register($data);
} catch (DuplicateEmailException $e) {
return response()->json(['error' => 'Email taken'], 409);
} catch (ValidationException $e) {
return response()->json(['errors' => $e->getErrors()], 422);
}
```
---
### 5.2 Catch Specific Exceptions
**Impact:** HIGH
Never catch generic `\Exception` or `\Throwable` except at top-level error boundaries.
**Bad:**
```php
<?php
try {
$user = $repo->find($id);
$mailer->send($user);
} catch (\Exception $e) {
return null; // Swallows ALL errors - hides bugs
}
```
**Good:**
```php
<?php
declare(strict_types=1);
class NotificationService
{
public function notifyUser(int $id): void
{
try {
$user = $this->repo->find($id);
$this->mailer->send($user);
} catch (UserNotFoundException $e) {
return;
} catch (MailerException $e) {
$this->logger->error('Email failed', ['error' => $e->getMessage()]);
// Continue - email is non-critical
}
}
}
```
---
### 5.3 Never Suppress Errors
**Impact:** CRITICAL
Never use the `@` operator. Handle errors explicitly.
**Bad:**
```php
<?php
$data = @file_get_contents($path); // Hides all errors
$conn = @mysqli_connect('localhost', 'user', 'pass');
```
**Good:**
```php
<?php
declare(strict_types=1);
if (!is_readable($path)) {
throw new FileNotFoundException("Not readable: {$path}");
}
$data = file_get_contents($path);
```
---
## 6. Performance
### 6.1 Generators for Large Datasets
**Impact:** MEDIUM
Use generators to process large datasets without loading everything into memory.
**Bad:**
```php
<?php
function getAllUsers(): array {
return $stmt->fetchAll(); // 1M users = huge memory spike
}
```
**Good:**
```php
<?php
declare(strict_types=1);
function getAllUsers(PDO $pdo, int $chunk = 1000): \Generator {
$offset = 0;
do {
$stmt = $pdo->prepare('SELECT * FROM users LIMIT :l OFFSET :o');
$stmt->bindValue(':l', $chunk, PDO::PARAM_INT);
$stmt->bindValue(':o', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
yield User::fromArray($row);
}
$offset += $chunk;
} while (count($rows) === $chunk);
}
```
---
### 6.2 Native String Functions over Regex
**Impact:** MEDIUM
Use PHP 8.0+ string functions instead of regex for simple checks.
**Bad:**
```php
<?php
if (preg_match('/^https/', $url)) { /* starts with */ }
if (preg_match('/\.pdf$/', $file)) { /* ends with */ }
```
**Good:**
```php
<?php
declare(strict_types=1);
if (str_starts_with($url, 'https')) { /* 2-10x faster */ }
if (str_ends_with($file, '.pdf')) { /* clearer intent */ }
if (str_contains($role, 'admin')) { /* no escaping needed */ }
```
---
## 7. Security
### 7.1 Input Validation
**Impact:** CRITICAL
Always validate and sanitize user input.
**Good:**
```php
<?php
declare(strict_types=1);
function createUser(array $data): User {
$email = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
throw new InvalidArgumentException('Invalid email');
}
$name = trim($data['name'] ?? '');
if (strlen($name) < 2 || strlen($name) > 100) {
throw new InvalidArgumentException('Name must be 2-100 characters');
}
return new User($email, $name);
}
```
---
### 7.2 Prepared Statements
**Impact:** CRITICAL
Always use prepared statements for SQL queries to prevent SQL injection.
**Bad:**
```php
<?php
$sql = "SELECT * FROM users WHERE email = '{$email}'";
$result = $db->query($sql); // SQL injection vulnerable
```
**Good:**
```php
<?php
declare(strict_types=1);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$result = $stmt->fetch();
```
---
### 7.3 Password Hashing
**Impact:** CRITICAL
Use `password_hash()` and `password_verify()` for password security.
**Bad:**
```php
<?php
$hash = md5($password); // Insecure
$hash = sha1($password); // Insecure
```
**Good:**
```php
<?php
declare(strict_types=1);
// Hashing
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Verification
if (password_verify($inputPassword, $storedHash)) {
// Password correct
if (password_needs_rehash($storedHash, PASSWORD_ARGON2ID)) {
// Rehash if algorithm changed
$newHash = password_hash($inputPassword, PASSWORD_ARGON2ID);
}
}
```
---
## References
- [PHP Manual](https://www.php.net/manual/en/)
- [PHP 8.3 Release](https://www.php.net/releases/8.3/en.php)
- [PSR Standards](https://www.php-fig.org/psr/)
- [PHPStan - Static Analysis](https://phpstan.org/)
- [Psalm - Static Analysis](https://psalm.dev/)
- [PHP The Right Way](https://phptherightway.com/)
---
**Last Updated:** March 2026
**Version:** 2.1.0
**License:** MIT
metadata.json
{
"version": "2.1.0",
"organization": "PHP Community",
"date": "March 2026",
"phpVersion": "8.0 - 8.5",
"rulesTotal": 51,
"abstract": "Comprehensive PHP 8.x best practices guide designed for AI agents and LLMs. Contains 51 rules across 7 categories (type system, modern features, PSR standards, SOLID principles, error handling, performance, security), prioritized by impact from critical to medium. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific rationale to guide automated refactoring and code generation. Always detect the project's PHP version before suggesting features.",
"references": [
"https://www.php.net/manual/en/",
"https://www.php.net/releases/8.3/en.php",
"https://www.php.net/releases/8.4/en.php",
"https://www.php-fig.org/psr/",
"https://www.php-fig.org/psr/psr-4/",
"https://www.php-fig.org/psr/psr-12/",
"https://github.com/php/php-src",
"https://phpstan.org/",
"https://psalm.dev/",
"https://github.com/rectorphp/rector",
"https://cs.symfony.com/",
"https://phptherightway.com/"
],
"categories": [
{
"name": "Type System",
"prefix": "type",
"impact": "CRITICAL",
"description": "Strict types, property types, return types, union/intersection types, never/void"
},
{
"name": "Modern PHP Features",
"prefix": "modern",
"impact": "CRITICAL",
"description": "Enums, readonly, constructor promotion, match, property hooks (8.4), pipe operator (8.5)"
},
{
"name": "PSR Standards",
"prefix": "psr",
"impact": "HIGH",
"description": "PSR-4 autoloading, PSR-12 coding style, file structure, naming conventions"
},
{
"name": "SOLID Principles",
"prefix": "solid",
"impact": "HIGH",
"description": "SRP, OCP, LSP, ISP, DIP — object-oriented design principles"
},
{
"name": "Error Handling",
"prefix": "error",
"impact": "HIGH",
"description": "Custom exceptions, exception hierarchy, specific catches, finally cleanup, no suppression"
},
{
"name": "Performance",
"prefix": "perf",
"impact": "MEDIUM",
"description": "Lazy loading, generators, native array/string functions, avoid globals"
},
{
"name": "Security",
"prefix": "sec",
"impact": "CRITICAL",
"description": "Input validation, output escaping, password hashing, prepared statements, file uploads"
}
],
"keyFeatures": [
"Version detection: always check composer.json before suggesting features",
"Strict types declaration on every file",
"Modern PHP 8.0-8.5 syntax (enums, readonly, property hooks, pipe operator)",
"PSR-4 autoloading and PSR-12 coding style",
"SOLID principles with PHP examples",
"Union and intersection types for precise type safety",
"Per-rule PHP version annotations (8.0+, 8.1+, 8.2+, 8.3+, 8.4+, 8.5+)"
]
}
README.md
# PHP Best Practices
Modern PHP 8.x patterns, PSR standards, and SOLID principles for clean, maintainable code.
## Overview
**Important:** Always detect the project's PHP version (`composer.json` or `php -v`) before giving advice. Only suggest features available in the detected version.
This skill provides guidance for:
- PHP 8.0 - 8.5 modern features (version-annotated)
- Type system best practices
- PSR standards compliance
- SOLID principles
## Categories (51 rules)
### 1. Type System (Critical) — 9 rules
Strict types, return types, union/intersection types, nullable handling, void/never.
### 2. Modern PHP Features (Critical) — 16 rules
8.0: constructor promotion, match, named args. 8.1: enums, readonly. 8.2: readonly classes. 8.3: typed constants, #[\Override]. 8.4: property hooks, asymmetric visibility. 8.5: pipe operator.
### 3. PSR Standards (High) — 6 rules
PSR-4 autoloading, PSR-12 coding style, naming conventions, file structure, namespaces.
### 4. SOLID Principles (High) — 5 rules
Single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion.
### 5. Error Handling (High) — 5 rules
Custom exceptions, exception hierarchy, specific catches, finally cleanup, never suppress errors.
### 6. Performance (Medium) — 5 rules
Generators, lazy loading, native array/string functions, avoiding globals.
### 7. Security (Critical) — 5 rules
Input validation, output escaping, password hashing, prepared statements, file upload security.
## Usage
Ask Claude to:
- "Review my PHP code"
- "Check PHP types"
- "Audit PHP for SOLID"
- "Check PHP best practices"
## Key Guidelines
### Always Use
- `declare(strict_types=1)` at file start
- Constructor property promotion
- Readonly properties for immutable data
- Enums instead of class constants
- Match expressions over switch
- Named arguments for clarity
- Type declarations everywhere
### Avoid
- Mixed type when specific type possible
- Hard-coded dependencies
- Fat interfaces
- Suppressing errors with @
- Global variables
- God classes
## References
- [PHP Manual](https://www.php.net/manual/)
- [PHP-FIG PSR Standards](https://www.php-fig.org/psr/)
- [PHP The Right Way](https://phptherightway.com/)
rules/_sections.md
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
---
## 1. Type System (type)
**Impact:** CRITICAL
**Description:** Strict type enforcement is the foundation of reliable PHP code. Type declarations prevent bugs, enable static analysis, and provide self-documenting contracts. Essential for modern PHP 8.x development.
## 2. Modern PHP Features (modern)
**Impact:** CRITICAL
**Description:** PHP 8.x features that reduce boilerplate and improve code clarity. Each rule is annotated with its minimum PHP version. Always check the project's PHP version before suggesting features. Covers: constructor promotion (8.0), enums (8.1), readonly (8.1/8.2), typed constants and #[\Override] (8.3), property hooks and asymmetric visibility (8.4), and pipe operator (8.5).
## 3. PSR Standards (psr)
**Impact:** HIGH
**Description:** PHP-FIG standards (PSR-4 autoloading, PSR-12 coding style, naming conventions) ensure code interoperability and maintainability. Following PSR standards is expected in professional PHP development.
## 4. SOLID Principles (solid)
**Impact:** HIGH
**Description:** SOLID principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) create maintainable, testable, and flexible code architectures.
## 5. Error Handling (error)
**Impact:** HIGH
**Description:** Proper exception handling, custom exceptions, exception hierarchies, and resource cleanup strategies prevent silent failures and enable graceful error management.
## 6. Performance (perf)
**Impact:** MEDIUM
**Description:** Performance optimizations including lazy loading, generators, native array/string functions, and avoiding globals improve application scalability and resource usage.
## 7. Security (sec)
**Impact:** CRITICAL
**Description:** Security practices including input validation, output escaping, password hashing, prepared statements, and file upload validation protect against OWASP Top 10 vulnerabilities.
rules/_template.md
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Brief description of impact (e.g., "Reduces bugs by 50%")
tags: category, subtopic, php-version
---
## Rule Title Here
**Impact: MEDIUM (optional impact description)**
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the benefits and rationale.
## Bad Example
```php
<?php
declare(strict_types=1);
// Bad code example here
// Show common anti-patterns
class BadExample
{
// Demonstrate the problem
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Good code example here
// Show best practices
class GoodExample
{
// Demonstrate the solution
}
```
## Why
- **Benefit 1**: Explanation of first major benefit
- **Benefit 2**: Explanation of second benefit
- **Benefit 3**: Explanation of third benefit
- **PHP Version**: Specify PHP version requirement if applicable
Reference: [PHP Documentation](https://www.php.net/manual/en/) | [PSR Standards](https://www.php-fig.org/psr/)
rules/error-custom-exceptions.md
---
title: Custom Exceptions
impact: HIGH
impactDescription: Specific exceptions enable precise error handling and better debugging
tags: error-handling, exceptions, custom-exceptions, php8
---
# Custom Exceptions
Create specific exception classes for different error scenarios instead of using generic exceptions.
## Bad Example
```php
<?php
declare(strict_types=1);
class UserService
{
public function register(array $data): User
{
if (empty($data['email'])) {
throw new \Exception('Email is required');
}
if ($this->repository->findByEmail($data['email'])) {
throw new \Exception('Email already exists');
}
if (!$this->gateway->charge($data['amount'])) {
throw new \Exception('Payment failed');
}
// All errors are generic \Exception - caller can't distinguish them
return $this->repository->create($data);
}
}
// Caller has no way to handle specific errors
try {
$service->register($data);
} catch (\Exception $e) {
// Is this validation? Duplicate? Payment? No way to know without string matching
echo $e->getMessage();
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Domain-specific exceptions
class ValidationException extends \RuntimeException
{
/** @param array<string, string> $errors */
public function __construct(
private readonly array $errors,
string $message = 'Validation failed',
) {
parent::__construct($message);
}
/** @return array<string, string> */
public function getErrors(): array
{
return $this->errors;
}
}
class DuplicateEmailException extends \RuntimeException
{
public function __construct(
private readonly string $email,
) {
parent::__construct("Email already registered: {$email}");
}
public function getEmail(): string
{
return $this->email;
}
}
class PaymentFailedException extends \RuntimeException
{
public function __construct(
private readonly string $reason,
private readonly ?string $transactionId = null,
) {
parent::__construct("Payment failed: {$reason}");
}
}
class UserService
{
public function register(array $data): User
{
if (empty($data['email'])) {
throw new ValidationException(['email' => 'Email is required']);
}
if ($this->repository->findByEmail($data['email'])) {
throw new DuplicateEmailException($data['email']);
}
if (!$this->gateway->charge($data['amount'])) {
throw new PaymentFailedException('Card declined');
}
return $this->repository->create($data);
}
}
// Caller can handle each error type differently
try {
$service->register($data);
} catch (ValidationException $e) {
return response()->json(['errors' => $e->getErrors()], 422);
} catch (DuplicateEmailException $e) {
return response()->json(['error' => 'Email already taken'], 409);
} catch (PaymentFailedException $e) {
return response()->json(['error' => 'Payment failed'], 402);
}
```
## Why
- **Precise Handling**: Callers can catch and handle specific error types
- **Context Preservation**: Custom exceptions carry domain-specific data (email, errors array)
- **Self-Documenting**: Exception class names describe what went wrong
- **Type Safety**: IDE and static analysis can verify catch blocks
- **No String Matching**: No need to parse exception messages to determine error type
rules/error-exception-hierarchy.md
---
title: Exception Hierarchy
impact: HIGH
impactDescription: Proper inheritance enables layered catch blocks and consistent error handling
tags: error-handling, exceptions, hierarchy, php8
---
# Exception Hierarchy
Organize exceptions into a meaningful hierarchy so callers can catch at different levels of specificity.
## Bad Example
```php
<?php
declare(strict_types=1);
// Flat, unrelated exceptions - no hierarchy
class UserNotFoundException extends \Exception {}
class OrderNotFoundException extends \Exception {}
class ProductNotFoundException extends \Exception {}
class InvalidEmailException extends \Exception {}
class InvalidPriceException extends \Exception {}
class DatabaseConnectionException extends \Exception {}
class ApiTimeoutException extends \Exception {}
// Caller must catch each one individually
try {
$order = $service->processOrder($data);
} catch (UserNotFoundException $e) {
// handle
} catch (OrderNotFoundException $e) {
// handle (same logic as above)
} catch (ProductNotFoundException $e) {
// handle (same logic again)
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Base exception for the application
class AppException extends \RuntimeException {}
// Not-found family
class NotFoundException extends AppException
{
public function __construct(
private readonly string $entity,
private readonly string|int $identifier,
) {
parent::__construct("{$entity} not found: {$identifier}");
}
public function getEntity(): string
{
return $this->entity;
}
}
class UserNotFoundException extends NotFoundException
{
public function __construct(string|int $id)
{
parent::__construct('User', $id);
}
}
class OrderNotFoundException extends NotFoundException
{
public function __construct(string|int $id)
{
parent::__construct('Order', $id);
}
}
// Validation family
class ValidationException extends AppException
{
/** @param array<string, string> $errors */
public function __construct(
private readonly array $errors = [],
) {
parent::__construct('Validation failed');
}
/** @return array<string, string> */
public function getErrors(): array
{
return $this->errors;
}
}
// Infrastructure family
class InfrastructureException extends AppException {}
class DatabaseException extends InfrastructureException {}
class ExternalApiException extends InfrastructureException {}
// Callers can catch at any level
try {
$order = $service->processOrder($data);
} catch (NotFoundException $e) {
// Catches UserNotFound, OrderNotFound, ProductNotFound
return response()->json(['error' => $e->getMessage()], 404);
} catch (ValidationException $e) {
return response()->json(['errors' => $e->getErrors()], 422);
} catch (InfrastructureException $e) {
// Catches Database, ExternalApi - log and show generic error
$logger->error($e->getMessage());
return response()->json(['error' => 'Service unavailable'], 503);
}
```
## Why
- **Layered Catching**: Catch broad categories or specific exceptions as needed
- **DRY Error Handling**: One catch block for all "not found" cases
- **Consistent Structure**: Shared base provides common interface
- **Extensible**: Add new exceptions without changing existing catch blocks
- **Use RuntimeException**: Extend `\RuntimeException` for errors that can't be recovered from programmatically
rules/error-finally-cleanup.md
---
title: Finally for Cleanup
impact: MEDIUM
impactDescription: Guarantees resource cleanup regardless of success or failure
tags: error-handling, finally, cleanup, resources, php8
---
# Finally for Cleanup
Use `finally` blocks to guarantee cleanup code runs whether an exception occurs or not.
## Bad Example
```php
<?php
declare(strict_types=1);
function processFile(string $path): array
{
$handle = fopen($path, 'r');
try {
$data = parseContents($handle);
fclose($handle); // Skipped if parseContents throws
return $data;
} catch (ParseException $e) {
fclose($handle); // Duplicated cleanup
throw $e;
}
// If an unexpected exception occurs, handle is never closed
}
// Lock without guaranteed release
function updateInventory(int $productId, int $quantity): void
{
$lock = Cache::lock("product:{$productId}", 10);
$lock->get();
$product = Product::find($productId);
$product->stock -= $quantity;
$product->save();
$lock->release(); // Never called if save() throws
}
```
## Good Example
```php
<?php
declare(strict_types=1);
function processFile(string $path): array
{
$handle = fopen($path, 'r');
try {
return parseContents($handle);
} finally {
fclose($handle); // Always runs, even if exception thrown
}
}
// Lock with guaranteed release
function updateInventory(int $productId, int $quantity): void
{
$lock = Cache::lock("product:{$productId}", 10);
$lock->get();
try {
$product = Product::find($productId);
$product->stock -= $quantity;
$product->save();
} finally {
$lock->release(); // Always released
}
}
// Database transaction with guaranteed cleanup
function transferFunds(Account $from, Account $to, float $amount): void
{
$pdo = getConnection();
$pdo->beginTransaction();
try {
$from->debit($amount);
$to->credit($amount);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
// Temporary state restoration
function withLocale(string $locale, callable $callback): mixed
{
$original = setlocale(LC_ALL, '0');
try {
setlocale(LC_ALL, $locale);
return $callback();
} finally {
setlocale(LC_ALL, $original); // Always restored
}
}
```
## Why
- **Guaranteed Execution**: `finally` runs whether try succeeds, catch fires, or exception propagates
- **No Duplication**: Write cleanup once instead of in both try and catch
- **Resource Safety**: File handles, locks, connections always released
- **State Restoration**: Temporary changes always reverted
rules/error-never-suppress.md
---
title: Never Suppress Errors
impact: CRITICAL
impactDescription: Suppressed errors hide bugs and make debugging nearly impossible
tags: error-handling, error-suppression, at-operator, php8
---
# Never Suppress Errors
Never use the `@` error suppression operator. Handle errors explicitly instead.
## Bad Example
```php
<?php
declare(strict_types=1);
// @ hides all errors - bugs become invisible
$data = @file_get_contents('/path/to/file');
$value = @json_decode($json);
$result = @unserialize($data);
$conn = @mysqli_connect('localhost', 'user', 'pass');
// Silently returns false/null with no indication of what went wrong
if ($data === false) {
// Was it file not found? Permission denied? Disk error? No way to know
}
// Empty catch blocks are equally bad
try {
$service->process($data);
} catch (\Exception $e) {
// Swallowed - same as @
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Check before acting
if (!is_readable($path)) {
throw new FileNotFoundException("File not readable: {$path}");
}
$data = file_get_contents($path);
if ($data === false) {
throw new FileReadException("Failed to read: {$path}");
}
// Use json_validate (8.3+) or check decode errors
$decoded = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new JsonParseException(json_last_error_msg());
}
// PHP 8.3+: validate before decoding
if (!json_validate($json)) {
throw new JsonParseException('Invalid JSON input');
}
// Use exceptions instead of error returns
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
} catch (\PDOException $e) {
$logger->error('Database connection failed', [
'error' => $e->getMessage(),
]);
throw new DatabaseConnectionException('Cannot connect to database', previous: $e);
}
// Log if you intentionally skip an error
try {
$mailer->send($notification);
} catch (MailerException $e) {
$logger->warning('Non-critical email failed', [
'error' => $e->getMessage(),
]);
// Intentionally continuing - email is non-critical
}
```
## Why
- **Bugs Stay Visible**: Errors surface immediately where they occur
- **Performance**: `@` is slow - PHP still generates the error internally
- **Debugging**: Stack traces and error messages are preserved
- **Explicit Intent**: If you skip an error, a comment and log explain why
- **Static Analysis**: Tools like PHPStan flag `@` usage as a code smell
rules/error-try-catch-specific.md
---
title: Catch Specific Exceptions
impact: HIGH
impactDescription: Catching specific exceptions prevents swallowing unexpected errors
tags: error-handling, try-catch, exceptions, php8
---
# Catch Specific Exceptions
Always catch the most specific exception type possible. Never catch generic `\Exception` or `\Throwable` unless at the top-level error boundary.
## Bad Example
```php
<?php
declare(strict_types=1);
// Catches everything - hides bugs
try {
$user = $repository->find($id);
$mailer->sendWelcome($user);
$logger->info('User welcomed');
} catch (\Exception $e) {
// Was it a DB error? Mail error? A typo causing TypeError?
// All swallowed silently
return null;
}
// Even worse - catching Throwable swallows fatal errors
try {
$result = $service->process($data);
} catch (\Throwable $e) {
// This catches Error (type errors, OOM) - dangerous
return 'Something went wrong';
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Catch specific exceptions with appropriate handling
try {
$user = $repository->find($id);
$mailer->sendWelcome($user);
} catch (UserNotFoundException $e) {
$logger->warning('User not found', ['id' => $id]);
return null;
} catch (MailerException $e) {
// Email failure shouldn't block the flow - log and continue
$logger->error('Welcome email failed', [
'user_id' => $id,
'error' => $e->getMessage(),
]);
}
// Multi-catch for same handling (PHP 8.0+)
try {
$data = $api->fetch($endpoint);
} catch (ConnectionException | TimeoutException $e) {
$logger->error('API unreachable', ['error' => $e->getMessage()]);
throw new ServiceUnavailableException('External service down', previous: $e);
}
// Top-level boundary is the only place for broad catches
// e.g., in error handler, middleware, or command bus
try {
$response = $kernel->handle($request);
} catch (\Throwable $e) {
$logger->critical('Unhandled exception', [
'exception' => $e::class,
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$response = new Response('Internal Server Error', 500);
}
```
## Why
- **No Hidden Bugs**: Unexpected exceptions bubble up instead of being silently swallowed
- **Appropriate Responses**: Different errors get different handling (404 vs 500 vs retry)
- **Better Debugging**: When something breaks, you see the actual error
- **Multi-Catch**: PHP 8.0+ `catch (A | B $e)` groups exceptions with same handling
- **Preserve Context**: Use `previous: $e` when re-throwing to keep the full chain
rules/modern-arrow-functions.md
---
title: Arrow Functions
impact: MEDIUM
impactDescription: Concise closures with automatic variable capture
tags: modern-features, arrow-functions, closures, php74
---
# Arrow Functions
Use arrow functions for short, single-expression closures (PHP 7.4+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Verbose closures for simple operations
$numbers = [1, 2, 3, 4, 5];
$doubled = array_map(function ($n) {
return $n * 2;
}, $numbers);
$evens = array_filter($numbers, function ($n) {
return $n % 2 === 0;
});
// Must explicitly use `use` to capture outer scope
$multiplier = 3;
$multiplied = array_map(function ($n) use ($multiplier) {
return $n * $multiplier;
}, $numbers);
// Nested verbose closures
$users = [/* ... */];
$activeEmails = array_map(function ($user) {
return $user->getEmail();
}, array_filter($users, function ($user) {
return $user->isActive();
}));
```
## Good Example
```php
<?php
declare(strict_types=1);
$numbers = [1, 2, 3, 4, 5];
// Concise arrow functions
$doubled = array_map(fn($n) => $n * 2, $numbers);
$evens = array_filter($numbers, fn($n) => $n % 2 === 0);
// Automatic capture of outer scope - no `use` needed
$multiplier = 3;
$multiplied = array_map(fn($n) => $n * $multiplier, $numbers);
// Chained operations are more readable
$users = [/* ... */];
$activeEmails = array_map(
fn($user) => $user->getEmail(),
array_filter($users, fn($user) => $user->isActive())
);
// With type hints
$prices = [10.5, 20.0, 15.75];
$withTax = array_map(
fn(float $price): float => $price * 1.1,
$prices
);
// Collection operations
class UserCollection
{
/** @var User[] */
private array $users;
public function getActiveUsers(): array
{
return array_filter($this->users, fn($u) => $u->isActive());
}
public function getEmails(): array
{
return array_map(fn($u) => $u->getEmail(), $this->users);
}
public function findByRole(string $role): array
{
return array_filter($this->users, fn($u) => $u->getRole() === $role);
}
public function getTotalBalance(): float
{
return array_sum(array_map(fn($u) => $u->getBalance(), $this->users));
}
}
// Sorting with arrow functions
$products = [/* ... */];
usort($products, fn($a, $b) => $a->getPrice() <=> $b->getPrice());
// Callbacks and event handlers
$button->onClick(fn() => $controller->handleClick());
$form->onSubmit(fn($data) => $controller->processForm($data));
// Validation rules
$rules = [
'email' => fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL) !== false,
'age' => fn($v) => is_numeric($v) && $v >= 18,
'name' => fn($v) => strlen($v) >= 2 && strlen($v) <= 100,
];
// Higher-order functions
function createMultiplier(int $factor): Closure
{
return fn(int $n): int => $n * $factor;
}
$double = createMultiplier(2);
$triple = createMultiplier(3);
echo $double(5); // 10
echo $triple(5); // 15
```
## Why
- **Concise**: Single expression without braces or return keyword
- **Auto-Capture**: Variables from outer scope captured automatically
- **Readable**: Better for functional programming patterns
- **Type Support**: Full support for parameter and return types
- **Immutable Capture**: Captured variables are by-value (safe)
- **Perfect For**: Callbacks, array functions, short lambdas
rules/modern-asymmetric-visibility.md
---
title: Asymmetric Visibility
impact: HIGH
impactDescription: Enables immutable-like public API without readonly limitations
tags: modern-features, asymmetric-visibility, access-control, encapsulation, php84
phpVersion: "8.4+"
---
# Asymmetric Visibility
Use asymmetric visibility to allow public reading but restrict writing (PHP 8.4+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Using readonly - cannot modify even internally
readonly class Order
{
public function __construct(
public string $id,
public string $status, // Cannot update status after creation!
public float $total,
) {}
}
// Using private with getters - verbose
class VerboseOrder
{
public function __construct(
private string $id,
private string $status,
private float $total,
) {}
public function getId(): string { return $this->id; }
public function getStatus(): string { return $this->status; }
public function getTotal(): float { return $this->total; }
public function markPaid(): void
{
$this->status = 'paid';
}
}
echo $order->getStatus(); // Verbose
```
## Good Example
```php
<?php
declare(strict_types=1);
class Order
{
public function __construct(
// Publicly readable, only settable inside the class
public private(set) string $id,
public private(set) string $status,
public private(set) float $total,
) {}
public function markPaid(): void
{
$this->status = 'paid'; // OK - internal set
}
public function applyDiscount(float $percent): void
{
$this->total *= (1 - $percent / 100); // OK - internal set
}
}
$order = new Order('ORD-001', 'pending', 99.99);
echo $order->status; // OK - public read: "pending"
// $order->status = 'paid'; // Error! Cannot set from outside
$order->markPaid();
echo $order->status; // "paid"
// Also works with protected(set)
class BaseModel
{
public protected(set) string $table;
public protected(set) array $fillable = [];
}
class User extends BaseModel
{
public function __construct()
{
$this->table = 'users'; // OK - child class can set
$this->fillable = ['name', 'email']; // OK
}
}
$user = new User();
echo $user->table; // OK - public read
// $user->table = 'foo'; // Error! Cannot set from outside
```
## Why
- **Clean Public API**: `$order->status` instead of `$order->getStatus()`
- **Internal Mutability**: Unlike readonly, the class can still update its own properties
- **No Getter Boilerplate**: Eliminates trivial getter methods
- **Granular Control**: Choose `private(set)` or `protected(set)` for write access
- **Pairs with Hooks**: Combine with property hooks for validated writes
rules/modern-attributes.md
---
title: PHP Attributes
impact: HIGH
impactDescription: Type-safe metadata with IDE support, replaces annotations
tags: modern-features, attributes, metadata, php8
---
# PHP Attributes
Use native attributes for metadata instead of docblock annotations (PHP 8.0+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Using docblock annotations - parsed as strings, no type safety
class UserController
{
/**
* @Route("/users/{id}", methods={"GET"})
* @Cache(maxage=3600)
* @Security("is_granted('VIEW', user)")
*/
public function show(int $id)
{
// Annotations are just comments - no IDE support for validation
}
}
// Validation using docblocks
class CreateUserRequest
{
/**
* @Assert\NotBlank()
* @Assert\Email()
*/
public string $email;
/**
* @Assert\NotBlank()
* @Assert\Length(min=8)
*/
public string $password;
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Define custom attributes
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
public function __construct(
public string $path,
public array $methods = ['GET'],
public ?string $name = null,
) {}
}
#[Attribute(Attribute::TARGET_METHOD)]
class Cache
{
public function __construct(
public int $maxAge = 0,
public bool $public = true,
) {}
}
#[Attribute(Attribute::TARGET_PROPERTY)]
class Validate
{
public function __construct(
public array $rules = [],
) {}
}
#[Attribute(Attribute::TARGET_CLASS)]
class Entity
{
public function __construct(
public string $table,
) {}
}
#[Attribute(Attribute::TARGET_PROPERTY)]
class Column
{
public function __construct(
public string $name,
public string $type = 'string',
public bool $nullable = false,
) {}
}
// Using attributes on a controller
class UserController
{
#[Route('/users/{id}', methods: ['GET'], name: 'user.show')]
#[Cache(maxAge: 3600)]
public function show(int $id): Response
{
return new Response($this->userService->find($id));
}
#[Route('/users', methods: ['POST'], name: 'user.create')]
public function create(CreateUserRequest $request): Response
{
return new Response($this->userService->create($request));
}
}
// Using attributes for validation
class CreateUserRequest
{
#[Validate(rules: ['required', 'email', 'unique:users'])]
public string $email;
#[Validate(rules: ['required', 'min:8', 'confirmed'])]
public string $password;
#[Validate(rules: ['required', 'string', 'max:255'])]
public string $name;
}
// Entity with ORM attributes
#[Entity(table: 'users')]
class User
{
#[Column(name: 'id', type: 'integer')]
public int $id;
#[Column(name: 'email', type: 'string')]
public string $email;
#[Column(name: 'created_at', type: 'datetime', nullable: true)]
public ?DateTimeImmutable $createdAt;
}
// Reading attributes via reflection
function getRoutes(object $controller): array
{
$routes = [];
$reflection = new ReflectionClass($controller);
foreach ($reflection->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
$routes[] = [
'path' => $route->path,
'methods' => $route->methods,
'handler' => [$controller, $method->getName()],
];
}
}
return $routes;
}
```
## Why
- **Type Safety**: Attributes are real classes with typed constructors
- **IDE Support**: Full autocompletion, refactoring, and navigation
- **Validation**: Invalid attribute usage caught by static analysis tools
- **Native Feature**: Built into PHP, no external parser needed
- **Performance**: Faster than parsing docblocks at runtime
- **Named Arguments**: Clear parameter names in attribute usage
rules/modern-constructor-promotion.md
---
title: Constructor Property Promotion
impact: CRITICAL
impactDescription: Reduces boilerplate by 60%, cleaner class definitions
tags: modern-features, constructor-promotion, php8, boilerplate-reduction
---
# Constructor Property Promotion
Constructor property promotion (PHP 8.0+) reduces boilerplate code significantly. It combines parameter declaration, property declaration, and assignment into a single statement, making classes cleaner and easier to maintain.
## Bad Example
```php
<?php
// Verbose - property declared twice, assigned manually
class User
{
private string $id;
private string $name;
private string $email;
private bool $active;
private ?DateTimeImmutable $createdAt;
public function __construct(
string $id,
string $name,
string $email,
bool $active = true,
?DateTimeImmutable $createdAt = null
) {
$this->id = $id;
$this->name = $name;
$this->email = $email;
$this->active = $active;
$this->createdAt = $createdAt ?? new DateTimeImmutable();
}
}
// Mixed styles - inconsistent
class Product
{
private string $sku;
public function __construct(
private string $name,
string $sku,
private float $price,
) {
$this->sku = $sku;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Constructor property promotion - clean and concise
class User
{
public function __construct(
private string $id,
private string $name,
private string $email,
private bool $active = true,
private ?DateTimeImmutable $createdAt = null,
) {
$this->createdAt ??= new DateTimeImmutable();
}
}
// With readonly for immutable properties (8.1+)
class ImmutableUser
{
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $email,
private bool $active = true,
) {}
public function activate(): void
{
$this->active = true;
}
public function isActive(): bool
{
return $this->active;
}
}
```
## With Validation
```php
<?php
class Email
{
public function __construct(
public readonly string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(
sprintf('Invalid email address: %s', $value)
);
}
}
}
class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency = 'USD',
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
if (strlen($currency) !== 3) {
throw new InvalidArgumentException('Currency must be 3 characters');
}
}
}
```
## Mixed Promoted and Non-Promoted
```php
<?php
// When you need computed properties
class Order
{
private string $orderNumber;
public function __construct(
public readonly string $id,
public readonly array $items,
public readonly DateTimeImmutable $createdAt,
) {
// Computed property - can't be promoted
$this->orderNumber = sprintf(
'ORD-%s-%s',
$createdAt->format('Ymd'),
strtoupper(substr($id, 0, 8))
);
}
public function getOrderNumber(): string
{
return $this->orderNumber;
}
}
```
## Visibility Combinations
```php
<?php
class Entity
{
public function __construct(
public readonly string $id, // Public, immutable
protected string $name, // Protected, mutable
private string $internalState, // Private, mutable
private readonly array $metadata, // Private, immutable
) {}
}
```
## Why
- **Less Boilerplate**: Reduces code by ~60% for simple DTOs
- **Single Source of Truth**: No separate property declaration and assignment
- **Readability**: Easier to scan and maintain
- **Trailing Comma**: Clean diffs when adding/removing parameters
- **Combines with Readonly**: `public readonly string $id` for immutable promoted properties (8.1+)
rules/modern-enums-methods.md
---
title: Enum Methods
impact: HIGH
impactDescription: Encapsulate behavior within enums, reduce external helpers
tags: modern-features, enums, methods, encapsulation, php81
---
# Enum Methods
Add methods to enums to encapsulate related behavior and logic.
## Bad Example
```php
<?php
declare(strict_types=1);
enum UserRole: string
{
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
}
// Logic scattered outside the enum
class PermissionChecker
{
public function canEdit(UserRole $role): bool
{
return $role === UserRole::Admin || $role === UserRole::Editor;
}
public function canDelete(UserRole $role): bool
{
return $role === UserRole::Admin;
}
public function canView(UserRole $role): bool
{
return true; // All roles can view
}
public function getLabel(UserRole $role): string
{
return match ($role) {
UserRole::Admin => 'Administrator',
UserRole::Editor => 'Content Editor',
UserRole::Viewer => 'Read-only Viewer',
};
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
enum UserRole: string
{
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
// Permission methods directly on the enum
public function canEdit(): bool
{
return match ($this) {
self::Admin, self::Editor => true,
self::Viewer => false,
};
}
public function canDelete(): bool
{
return $this === self::Admin;
}
public function canView(): bool
{
return true;
}
public function canManageUsers(): bool
{
return $this === self::Admin;
}
// Human-readable label
public function label(): string
{
return match ($this) {
self::Admin => 'Administrator',
self::Editor => 'Content Editor',
self::Viewer => 'Read-only Viewer',
};
}
// Get permissions array
public function permissions(): array
{
return match ($this) {
self::Admin => ['view', 'edit', 'delete', 'manage_users'],
self::Editor => ['view', 'edit'],
self::Viewer => ['view'],
};
}
// Static factory methods
public static function default(): self
{
return self::Viewer;
}
public static function fromPermissionLevel(int $level): self
{
return match (true) {
$level >= 100 => self::Admin,
$level >= 50 => self::Editor,
default => self::Viewer,
};
}
}
// More complex enum with interface implementation
interface Describable
{
public function description(): string;
}
enum OrderStatus: string implements Describable
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function description(): string
{
return match ($this) {
self::Pending => 'Order is awaiting processing',
self::Processing => 'Order is being prepared',
self::Shipped => 'Order has been shipped',
self::Delivered => 'Order has been delivered',
self::Cancelled => 'Order was cancelled',
};
}
public function isFinal(): bool
{
return match ($this) {
self::Delivered, self::Cancelled => true,
default => false,
};
}
public function canTransitionTo(self $newStatus): bool
{
return match ($this) {
self::Pending => in_array($newStatus, [self::Processing, self::Cancelled]),
self::Processing => in_array($newStatus, [self::Shipped, self::Cancelled]),
self::Shipped => $newStatus === self::Delivered,
self::Delivered, self::Cancelled => false,
};
}
public function color(): string
{
return match ($this) {
self::Pending => '#FFA500',
self::Processing => '#0000FF',
self::Shipped => '#800080',
self::Delivered => '#008000',
self::Cancelled => '#FF0000',
};
}
}
// Usage
$role = UserRole::Editor;
if ($role->canEdit()) {
// Edit content
}
$status = OrderStatus::Processing;
if ($status->canTransitionTo(OrderStatus::Shipped)) {
// Allow status change
}
```
## Why
- **Encapsulation**: Behavior lives with the data it operates on
- **Single Source of Truth**: All enum-related logic in one place
- **Type Safety**: Methods have access to `$this` for the current case
- **Exhaustive Matching**: Static analysis ensures all cases are handled
- **Interface Support**: Enums can implement interfaces
- **Clean Architecture**: No need for separate helper classes
rules/modern-enums.md
---
title: Type-Safe Enums
impact: CRITICAL
impactDescription: Provides type safety for constants, prevents invalid values
tags: modern-features, enums, type-safety, php81
---
# Type-Safe Enums
Enums (PHP 8.1+) provide type-safe constants with methods. They prevent invalid values, enable IDE autocompletion, and encapsulate related behavior. Always prefer enums over class constants for finite sets of values.
## Bad Example
```php
<?php
// Class constants - no type safety
class OrderStatus
{
public const PENDING = 'pending';
public const PROCESSING = 'processing';
public const SHIPPED = 'shipped';
public const DELIVERED = 'delivered';
public const CANCELLED = 'cancelled';
}
// Anyone can pass invalid value
function updateStatus(string $status): void
{
// 'invalid_status' would be accepted
}
updateStatus('typo'); // No error!
// Constants scattered or duplicated
class Order
{
public const STATUS_PENDING = 1;
public const STATUS_ACTIVE = 2;
}
class Payment
{
public const STATUS_PENDING = 1; // Duplicated
public const STATUS_COMPLETED = 2;
}
```
## Good Example
### Basic Enum (Unit Enum)
```php
<?php
declare(strict_types=1);
// Unit enum - no backing value
enum Direction
{
case North;
case South;
case East;
case West;
public function opposite(): self
{
return match($this) {
self::North => self::South,
self::South => self::North,
self::East => self::West,
self::West => self::East,
};
}
}
$direction = Direction::North;
$opposite = $direction->opposite(); // Direction::South
```
### Backed Enum (String or Int)
```php
<?php
// String-backed enum - for database/API values
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'Awaiting Processing',
self::Processing => 'Being Prepared',
self::Shipped => 'On the Way',
self::Delivered => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function color(): string
{
return match($this) {
self::Pending => 'yellow',
self::Processing => 'blue',
self::Shipped => 'purple',
self::Delivered => 'green',
self::Cancelled => 'red',
};
}
public function canTransitionTo(self $newStatus): bool
{
return match($this) {
self::Pending => in_array($newStatus, [self::Processing, self::Cancelled]),
self::Processing => in_array($newStatus, [self::Shipped, self::Cancelled]),
self::Shipped => $newStatus === self::Delivered,
self::Delivered, self::Cancelled => false,
};
}
}
// Usage
$status = OrderStatus::Pending;
$status->value; // 'pending'
$status->name; // 'Pending'
$status->label(); // 'Awaiting Processing'
// From database/API value
$status = OrderStatus::from('pending'); // OrderStatus::Pending
$status = OrderStatus::tryFrom('invalid'); // null (no exception)
```
### Int-Backed Enum
```php
<?php
// Int-backed enum - for legacy databases
enum Priority: int
{
case Low = 1;
case Medium = 2;
case High = 3;
case Critical = 4;
public function isUrgent(): bool
{
return $this->value >= self::High->value;
}
}
// Comparison
$priority = Priority::High;
if ($priority->value > Priority::Medium->value) {
// Handle high priority
}
```
### Enum with Interface
```php
<?php
interface Labelable
{
public function label(): string;
}
enum PaymentMethod: string implements Labelable
{
case CreditCard = 'credit_card';
case BankTransfer = 'bank_transfer';
case PayPal = 'paypal';
public function label(): string
{
return match($this) {
self::CreditCard => 'Credit Card',
self::BankTransfer => 'Bank Transfer',
self::PayPal => 'PayPal',
};
}
public function processingFee(): float
{
return match($this) {
self::CreditCard => 0.029,
self::BankTransfer => 0.01,
self::PayPal => 0.034,
};
}
}
```
### Enum with Traits
```php
<?php
trait EnumHelpers
{
/** Only works with backed enums (string/int) */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public static function names(): array
{
return array_column(self::cases(), 'name');
}
public static function options(): array
{
return array_combine(
array_column(self::cases(), 'value'),
array_map(fn($case) => $case->label(), self::cases())
);
}
}
enum Role: string
{
use EnumHelpers;
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
public function label(): string
{
return match($this) {
self::Admin => 'Administrator',
self::Editor => 'Content Editor',
self::Viewer => 'Read Only',
};
}
public function permissions(): array
{
return match($this) {
self::Admin => ['create', 'read', 'update', 'delete', 'manage'],
self::Editor => ['create', 'read', 'update'],
self::Viewer => ['read'],
};
}
}
// Usage
Role::values(); // ['admin', 'editor', 'viewer']
Role::options(); // ['admin' => 'Administrator', ...]
```
### Type-Safe Function Parameters
```php
<?php
// Function accepts only valid enum values
function updateOrderStatus(Order $order, OrderStatus $newStatus): void
{
if (!$order->status->canTransitionTo($newStatus)) {
throw new InvalidStatusTransitionException(
$order->status,
$newStatus
);
}
$order->status = $newStatus;
}
// Type safety - invalid values rejected
updateOrderStatus($order, OrderStatus::Shipped); //
updateOrderStatus($order, 'shipped'); // TypeError
```
### In Eloquent/Database
```php
<?php
// Model with enum casting
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class,
'priority' => Priority::class,
];
}
// Query with enum
Order::where('status', OrderStatus::Pending)->get();
// Validation rule
'status' => ['required', new Enum(OrderStatus::class)],
```
## Why
- **Type Safety**: Invalid values caught immediately (TypeError)
- **IDE Support**: Autocompletion and refactoring support
- **Encapsulation**: Related behavior lives with the data (methods)
- **Self-Documenting**: Code clearly shows all valid values
- **Match Expressions**: Natural pairing with exhaustive match
- **Database Integration**: Backed enums map to DB values
- **Safe Conversion**: `from()`/`tryFrom()` for converting from raw values
rules/modern-first-class-callables.md
---
title: First-Class Callable Syntax
impact: MEDIUM
impactDescription: Concise callable references with IDE support
tags: modern-features, first-class-callables, closures, php81
---
# First-Class Callable Syntax
Use first-class callable syntax to create closures from callables (PHP 8.1+).
## Bad Example
```php
<?php
declare(strict_types=1);
class StringProcessor
{
public function toUpperCase(string $str): string
{
return strtoupper($str);
}
public function toLowerCase(string $str): string
{
return strtolower($str);
}
}
// Old way - verbose Closure::fromCallable
$processor = new StringProcessor();
$upper = Closure::fromCallable([$processor, 'toUpperCase']);
$lower = Closure::fromCallable([$processor, 'toLowerCase']);
// String-based callable - no IDE support, error-prone
$callback = [$processor, 'toUpperCase'];
array_map($callback, $strings); // Works but fragile
// Static methods
$formatter = Closure::fromCallable([DateFormatter::class, 'format']);
// Functions
$trimmer = Closure::fromCallable('trim');
```
## Good Example
```php
<?php
declare(strict_types=1);
class StringProcessor
{
public function toUpperCase(string $str): string
{
return strtoupper($str);
}
public function toLowerCase(string $str): string
{
return strtolower($str);
}
public function getProcessors(): array
{
// First-class callable syntax - concise and type-safe
return [
'upper' => $this->toUpperCase(...),
'lower' => $this->toLowerCase(...),
];
}
}
// Instance methods
$processor = new StringProcessor();
$upper = $processor->toUpperCase(...);
$lower = $processor->toLowerCase(...);
$strings = ['hello', 'world'];
$uppercased = array_map($upper, $strings); // ['HELLO', 'WORLD']
// Static methods
class DateFormatter
{
public static function format(DateTimeInterface $date): string
{
return $date->format('Y-m-d');
}
public static function formatTime(DateTimeInterface $date): string
{
return $date->format('H:i:s');
}
}
$formatDate = DateFormatter::format(...);
$formatTime = DateFormatter::formatTime(...);
$dates = [new DateTime(), new DateTime('+1 day')];
$formatted = array_map($formatDate, $dates);
// Built-in functions
$trim = trim(...);
$strlen = strlen(...);
$strtoupper = strtoupper(...);
$cleaned = array_map($trim, $dirtyStrings);
$lengths = array_map($strlen, $strings);
// Constructor as callable
class User
{
public function __construct(
public string $name,
public string $email,
) {}
}
// Can't use new directly, but can wrap
$createUser = fn(array $data) => new User($data['name'], $data['email']);
// Practical use cases
class EventDispatcher
{
/** @var array<string, Closure[]> */
private array $listeners = [];
public function subscribe(string $event, Closure $listener): void
{
$this->listeners[$event][] = $listener;
}
}
class UserController
{
public function __construct(
private EventDispatcher $dispatcher,
private UserService $service,
) {
// Subscribe methods as first-class callables
$this->dispatcher->subscribe('user.created', $this->onUserCreated(...));
$this->dispatcher->subscribe('user.deleted', $this->onUserDeleted(...));
}
private function onUserCreated(User $user): void
{
// Handle event
}
private function onUserDeleted(int $userId): void
{
// Handle event
}
}
// Pipeline pattern
class Pipeline
{
/** @var Closure[] */
private array $stages = [];
public function pipe(Closure $stage): self
{
$this->stages[] = $stage;
return $this;
}
public function process(mixed $payload): mixed
{
return array_reduce(
$this->stages,
fn($carry, $stage) => $stage($carry),
$payload
);
}
}
$pipeline = new Pipeline();
$pipeline
->pipe(trim(...))
->pipe(strtolower(...))
->pipe($processor->toUpperCase(...));
$result = $pipeline->process(' Hello World '); // 'HELLO WORLD'
```
## Why
- **Concise Syntax**: `$obj->method(...)` instead of `Closure::fromCallable()`
- **IDE Support**: Full autocompletion, refactoring, and navigation
- **Type Safety**: Closures maintain the callable's type signature
- **Refactoring Safe**: Renaming methods updates references automatically
- **Consistency**: Same syntax for instance, static, and global functions
- **Functional PHP**: Enables cleaner functional programming patterns
rules/modern-match-expression.md
---
title: Match Expression
impact: HIGH
impactDescription: Type-safe, concise alternative to switch with no fall-through
tags: modern-features, match, control-flow, php8
---
# Match Expression
Use match expressions instead of switch statements for cleaner, safer code (PHP 8.0+).
## Bad Example
```php
<?php
declare(strict_types=1);
function getStatusMessage(int $code): string
{
// Switch is verbose and error-prone
switch ($code) {
case 200:
$message = 'OK';
break;
case 201:
$message = 'Created';
break;
case 400:
$message = 'Bad Request';
break;
case 404:
$message = 'Not Found';
break;
case 500:
$message = 'Internal Server Error';
break;
default:
$message = 'Unknown';
// Easy to forget break - falls through!
}
return $message;
}
// Type coercion issues
$value = '1';
switch ($value) {
case 1: // Matches due to loose comparison!
echo 'One';
break;
}
```
## Good Example
```php
<?php
declare(strict_types=1);
function getStatusMessage(int $code): string
{
// Match is an expression - returns a value
return match ($code) {
200 => 'OK',
201 => 'Created',
400 => 'Bad Request',
404 => 'Not Found',
500 => 'Internal Server Error',
default => 'Unknown',
};
}
// Multiple values per arm
function getHttpStatusCategory(int $code): string
{
return match (true) {
$code >= 100 && $code < 200 => 'Informational',
$code >= 200 && $code < 300 => 'Success',
$code >= 300 && $code < 400 => 'Redirection',
$code >= 400 && $code < 500 => 'Client Error',
$code >= 500 && $code < 600 => 'Server Error',
default => 'Unknown',
};
}
// Multiple conditions in one arm
function getDiscount(string $customerType): float
{
return match ($customerType) {
'premium', 'vip', 'gold' => 0.20,
'silver', 'regular' => 0.10,
'new' => 0.05,
default => 0.0,
};
}
// With enums - exhaustive matching
enum PaymentMethod
{
case CreditCard;
case PayPal;
case BankTransfer;
case Crypto;
}
function getPaymentFee(PaymentMethod $method): float
{
return match ($method) {
PaymentMethod::CreditCard => 2.9,
PaymentMethod::PayPal => 3.5,
PaymentMethod::BankTransfer => 0.5,
PaymentMethod::Crypto => 1.0,
// No default needed - all cases covered
// Adding new enum case = UnhandledMatchError at runtime
};
}
// Match as expression in complex scenarios
class OrderProcessor
{
public function calculateShipping(Order $order): Money
{
$baseRate = match ($order->shippingMethod) {
ShippingMethod::Standard => new Money(599, 'USD'),
ShippingMethod::Express => new Money(1299, 'USD'),
ShippingMethod::Overnight => new Money(2499, 'USD'),
ShippingMethod::Pickup => new Money(0, 'USD'),
};
return $order->isHeavy()
? $baseRate->multiply(1.5)
: $baseRate;
}
}
// Strict comparison - no type coercion
$value = '1';
$result = match ($value) {
1 => 'integer one', // Won't match - strict comparison
'1' => 'string one', // Matches
default => 'other',
};
```
## Why
- **Expression**: Returns a value directly, no break statements needed
- **Strict Comparison**: Uses === preventing type coercion bugs
- **Exhaustive**: Error if value doesn't match any arm (without default)
- **Concise**: Much less boilerplate than switch
- **No Fall-through**: Impossible to accidentally fall through cases
- **Enum Support**: Perfect pairing with enums for exhaustive matching
rules/modern-named-arguments.md
---
title: Named Arguments
impact: MEDIUM
impactDescription: Self-documenting calls, skip optional parameters
tags: modern-features, named-arguments, readability, php8
---
# Named Arguments
Use named arguments for clarity and flexibility (PHP 8.0+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Hard to understand what each argument means
$user = new User(
1,
'John',
'Doe',
'john@example.com',
null,
true,
false,
'America/New_York'
);
// What do these booleans mean?
$result = $validator->validate($data, true, false, true);
// Must pass all preceding optional arguments
$query = $repository->findAll(null, null, null, 100);
// Confusing function calls
setcookie('session', $value, 0, '/', '', true, true);
```
## Good Example
```php
<?php
declare(strict_types=1);
// Clear what each argument represents
$user = new User(
id: 1,
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
phone: null,
isActive: true,
isAdmin: false,
timezone: 'America/New_York',
);
// Self-documenting boolean parameters
$result = $validator->validate(
data: $data,
strict: true,
allowEmpty: false,
throwOnError: true,
);
// Skip optional parameters - only pass what you need
$query = $repository->findAll(limit: 100);
// Much clearer
setcookie(
name: 'session',
value: $value,
secure: true,
httponly: true,
);
// Mix positional and named (positional must come first)
function createNotification(
string $message,
string $title = 'Notice',
string $type = 'info',
bool $persistent = false,
?int $timeout = null,
): Notification {
return new Notification($message, $title, $type, $persistent, $timeout);
}
// Can skip defaults and only specify what differs
$notification = createNotification(
'Your order has shipped!',
type: 'success',
persistent: true,
);
// Perfect for configuration objects
class DatabaseConfig
{
public function __construct(
public string $host = 'localhost',
public int $port = 3306,
public string $database = '',
public string $username = '',
public string $password = '',
public string $charset = 'utf8mb4',
public bool $persistent = false,
public int $timeout = 30,
) {}
}
$config = new DatabaseConfig(
host: 'db.example.com',
database: 'myapp',
username: 'admin',
password: 'secret',
timeout: 60,
);
// Variadic functions with named arguments
function logMessage(
string $message,
string $level = 'info',
array ...$context
): void {
// Implementation
}
logMessage(
message: 'User logged in',
level: 'info',
user: ['id' => 1, 'name' => 'John'],
ip: ['address' => '192.168.1.1'],
);
```
## Why
- **Self-Documenting**: Argument purpose is clear at call site
- **Skip Defaults**: Only pass arguments that differ from defaults
- **Order Independence**: Arguments can be in any order when named
- **Boolean Clarity**: Named booleans are much clearer than positional
- **Refactoring Safe**: Reordering parameters won't break named calls
- **IDE Support**: Full autocompletion for parameter names
rules/modern-nullsafe-operator.md
---
title: Nullsafe Operator
impact: HIGH
impactDescription: Eliminates nested null checks, safer navigation
tags: modern-features, nullsafe, null-handling, php8
---
# Nullsafe Operator
Use the nullsafe operator for cleaner null checking chains (PHP 8.0+).
## Bad Example
```php
<?php
declare(strict_types=1);
class OrderService
{
public function getCustomerCountry(?Order $order): ?string
{
// Nested null checks - pyramid of doom
if ($order !== null) {
$customer = $order->getCustomer();
if ($customer !== null) {
$address = $customer->getAddress();
if ($address !== null) {
return $address->getCountry();
}
}
}
return null;
}
public function getShippingCity(?Order $order): ?string
{
// Ternary chains - hard to read
return $order !== null
? ($order->getShipping() !== null
? ($order->getShipping()->getAddress() !== null
? $order->getShipping()->getAddress()->getCity()
: null)
: null)
: null;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class OrderService
{
public function getCustomerCountry(?Order $order): ?string
{
// Clean nullsafe chain - stops at first null
return $order?->getCustomer()?->getAddress()?->getCountry();
}
public function getShippingCity(?Order $order): ?string
{
return $order?->getShipping()?->getAddress()?->getCity();
}
public function getOrderTotal(?Order $order): float
{
// Combine with null coalescing for default value
return $order?->getTotal()?->getAmount() ?? 0.0;
}
}
// Works with method calls
$user?->getProfile()?->updateLastLogin();
// Works with property access
$length = $order?->items?->count();
// Combine with null coalescing for array access
$firstItem = $order?->getItems()[0] ?? null; // ?? handles null from nullsafe
// Practical examples
class NotificationService
{
public function notify(?User $user, string $message): void
{
// Only sends if user and preferences exist
$user?->getPreferences()?->getNotificationChannel()?->send($message);
}
}
class ReportGenerator
{
public function getManagerEmail(?Employee $employee): ?string
{
return $employee
?->getDepartment()
?->getManager()
?->getEmail();
}
public function getManagerName(?Employee $employee): string
{
// Chain with null coalescing for default
return $employee
?->getDepartment()
?->getManager()
?->getName() ?? 'No Manager Assigned';
}
}
// Complex example with multiple nullsafe chains
class InvoiceService
{
public function formatBillingInfo(?Invoice $invoice): string
{
$company = $invoice?->getCustomer()?->getCompany()?->getName()
?? 'Individual';
$address = $invoice?->getBillingAddress()?->format()
?? 'No address provided';
$contact = $invoice?->getCustomer()?->getPrimaryContact()?->getEmail()
?? $invoice?->getCustomer()?->getEmail()
?? 'No contact';
return "$company\n$address\n$contact";
}
}
```
## Why
- **Concise**: Eliminates verbose null checking boilerplate
- **Readable**: Clear intent - "access if not null"
- **Safe**: Short-circuits at first null, returning null
- **Chainable**: Perfect for deep object graph navigation
- **Combinable**: Works great with null coalescing (??) for defaults
- **Less Bugs**: Reduces chance of null pointer errors
rules/modern-override-attribute.md
---
title: Override Attribute
impact: HIGH
impactDescription: Catches silent bugs from misspelled or removed parent methods
tags: modern-features, override, attributes, inheritance, php83
phpVersion: "8.3+"
---
# Override Attribute
Use `#[\Override]` on methods that override a parent method (PHP 8.3+).
## Bad Example
```php
<?php
declare(strict_types=1);
class BaseRepository
{
public function findById(int $id): ?object
{
// Base implementation
return null;
}
public function save(object $entity): void
{
// Base implementation
}
}
class UserRepository extends BaseRepository
{
// Typo in method name - creates new method instead of overriding!
public function findByld(int $id): ?User // 'l' instead of 'I'
{
return User::find($id);
}
// Parent renames save() to persist() - this silently becomes dead code
public function save(object $entity): void
{
// This method is never called if parent renames it
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class BaseRepository
{
public function findById(int $id): ?object
{
return null;
}
public function save(object $entity): void
{
// Base implementation
}
}
class UserRepository extends BaseRepository
{
// Typo caught when class is loaded!
// #[\Override]
// public function findByld(int $id): ?User // Fatal error: method does not override
#[\Override]
public function findById(int $id): ?User
{
return User::find($id);
}
#[\Override]
public function save(object $entity): void
{
// If parent renames save(), this throws a fatal error
$entity->save();
}
}
// Works with interfaces too
interface Renderable
{
public function render(): string;
}
class Button implements Renderable
{
#[\Override]
public function render(): string
{
return '<button>Click</button>';
}
}
// Works with abstract classes
abstract class Controller
{
abstract protected function authorize(): bool;
}
class UserController extends Controller
{
#[\Override]
protected function authorize(): bool
{
return auth()->check();
}
}
```
## Why
- **Catches Typos**: Misspelled method names are caught when the class is loaded
- **Refactoring Safety**: Renaming or removing a parent method triggers an error
- **Clear Intent**: Signals that a method is meant to override a parent
- **Interface Changes**: Detects when an interface removes a method
- **Low Overhead**: Validated at class loading, no per-call cost
rules/modern-pipe-operator.md
---
title: Pipe Operator
impact: HIGH
impactDescription: Enables readable left-to-right function chaining for data transformation
tags: modern-features, pipe-operator, functional, chaining, php85
phpVersion: "8.5+"
---
# Pipe Operator
Use the pipe operator (`|>`) for readable function chaining (PHP 8.5+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Deeply nested function calls - read inside-out
$result = htmlspecialchars(
strtolower(
trim(
$input
)
)
);
// Temporary variables - cluttered
$step1 = trim($input);
$step2 = strtolower($step1);
$step3 = htmlspecialchars($step2);
$result = $step3;
// Array processing - hard to follow
$slugs = array_unique(
array_filter(
array_map(
fn(string $tag) => strtolower(trim($tag)),
explode(',', $tags)
),
fn(string $tag) => $tag !== ''
)
);
```
## Good Example
```php
<?php
declare(strict_types=1);
// Pipe operator - read left-to-right, top-to-bottom
$result = $input
|> trim(...)
|> strtolower(...)
|> htmlspecialchars(...);
// Chain with first-class callables
$length = "Hello World"
|> trim(...)
|> strlen(...);
// Use arrow functions for multi-argument functions
$slugs = $tags
|> (fn(string $s) => explode(',', $s))
|> (fn(array $a) => array_map(fn(string $t) => strtolower(trim($t)), $a))
|> (fn(array $a) => array_filter($a, fn(string $t) => $t !== ''))
|> array_unique(...);
// Practical example: building a slug
$slug = $title
|> trim(...)
|> strtolower(...)
|> (fn(string $s) => preg_replace('/[^a-z0-9]+/', '-', $s))
|> (fn(string $s) => trim($s, '-'));
// Practical example: processing user input
$sanitized = $request->input('comment')
|> trim(...)
|> strip_tags(...)
|> htmlspecialchars(...);
```
## Important
The pipe operator passes the left-hand value as the **sole argument** to the right-hand callable. `$x |> foo(...)` is equivalent to `foo($x)`. For multi-argument functions, wrap in an arrow function:
```php
// str_replace has 3 args - wrap in arrow function
$clean = $input |> (fn(string $s) => str_replace(' ', '-', $s));
```
## Why
- **Readable Flow**: Data transformations read left-to-right, top-to-bottom
- **No Nesting**: Eliminates deeply nested function calls
- **No Temp Variables**: Avoids cluttering scope with intermediate values
- **Functional Style**: Encourages composable, single-purpose functions
- **Sole Argument**: `$x |> fn(...)` is equivalent to `fn($x)` — wrap in closures for multi-argument functions
- **Pairs with First-Class Callables**: `trim(...)` syntax works naturally with `|>`
rules/modern-property-hooks.md
---
title: Property Hooks
impact: HIGH
impactDescription: Replaces verbose getter/setter boilerplate with clean property-level logic
tags: modern-features, property-hooks, getters, setters, php84
phpVersion: "8.4+"
---
# Property Hooks
Use property hooks to define get/set logic directly on properties (PHP 8.4+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Verbose getter/setter boilerplate
class User
{
private string $firstName;
private string $lastName;
private string $email;
public function __construct(string $firstName, string $lastName, string $email)
{
$this->setFirstName($firstName);
$this->setLastName($lastName);
$this->setEmail($email);
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $value): void
{
$this->firstName = ucfirst(strtolower($value));
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $value): void
{
if (strlen($value) < 2) {
throw new \InvalidArgumentException('Too short');
}
$this->lastName = $value;
}
public function getFullName(): string
{
return $this->firstName . ' ' . $this->lastName;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $value): void
{
$this->email = strtolower($value);
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class User
{
// Short hook syntax for simple transforms
public string $firstName {
set => ucfirst(strtolower($value));
}
// Full hook syntax for validation
public string $lastName {
set {
if (strlen($value) < 2) {
throw new \InvalidArgumentException('Too short');
}
$this->lastName = $value;
}
}
// Virtual property (get-only, no stored value)
public string $fullName {
get => $this->firstName . ' ' . $this->lastName;
}
// Transform on set
public string $email {
set => strtolower($value);
}
public function __construct(
string $firstName,
string $lastName,
string $email,
) {
$this->firstName = $firstName;
$this->lastName = $lastName;
$this->email = $email;
}
}
$user = new User('PETER', 'Peterson', 'PETER@EXAMPLE.COM');
echo $user->firstName; // "Peter"
echo $user->fullName; // "Peter Peterson"
echo $user->email; // "peter@example.com"
// Works with constructor promotion too
class Product
{
public function __construct(
public string $name { set => trim($value); },
public float $price { set => max(0, $value); },
) {}
}
```
## Why
- **Less Boilerplate**: Eliminates separate getter/setter methods
- **Co-located Logic**: Validation and transformation live with the property
- **Virtual Properties**: Computed values without storing data
- **Direct Access**: Use `$obj->prop` instead of `$obj->getProp()`
- **Constructor Compatible**: Works with constructor property promotion
rules/modern-readonly-classes.md
---
title: Readonly Classes
impact: HIGH
impactDescription: Enforces complete immutability for value objects and DTOs
tags: modern-features, readonly, immutability, value-objects, php82
---
# Readonly Classes
Use readonly classes when all properties should be immutable (PHP 8.2+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Marking each property as readonly individually
class EmailAddress
{
public function __construct(
public readonly string $local,
public readonly string $domain,
) {}
}
// Easy to forget readonly on new properties
class PersonName
{
public function __construct(
public readonly string $firstName,
public readonly string $lastName,
public string $middleName = '', // Oops! Forgot readonly
) {}
}
// Verbose for classes with many properties
class ShippingAddress
{
public function __construct(
public readonly string $street,
public readonly string $city,
public readonly string $state,
public readonly string $zipCode,
public readonly string $country,
) {}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Readonly class - all properties are automatically readonly
readonly class EmailAddress
{
public function __construct(
public string $local,
public string $domain,
) {
if (!filter_var("$local@$domain", FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
}
public function toString(): string
{
return "{$this->local}@{$this->domain}";
}
public function equals(self $other): bool
{
return $this->local === $other->local
&& $this->domain === $other->domain;
}
}
readonly class PersonName
{
public function __construct(
public string $firstName,
public string $lastName,
public string $middleName = '',
) {}
public function getFullName(): string
{
return trim("{$this->firstName} {$this->middleName} {$this->lastName}");
}
}
readonly class ShippingAddress
{
public function __construct(
public string $street,
public string $city,
public string $state,
public string $zipCode,
public string $country,
) {}
public function format(): string
{
return implode("\n", [
$this->street,
"{$this->city}, {$this->state} {$this->zipCode}",
$this->country,
]);
}
}
// DTOs are perfect candidates for readonly classes
readonly class CreateUserRequest
{
public function __construct(
public string $email,
public string $password,
public string $name,
public ?string $phone = null,
) {}
}
// Event objects should be immutable
readonly class UserCreatedEvent
{
public function __construct(
public int $userId,
public string $email,
public DateTimeImmutable $occurredAt,
) {}
}
```
## Why
- **Less Verbose**: No need to repeat readonly on each property
- **Enforced Immutability**: New properties are automatically readonly
- **Value Objects**: Ideal for implementing value object pattern
- **DTOs**: Perfect for data transfer objects
- **Events**: Event objects should be immutable by design
- **Clear Intent**: Class declaration signals complete immutability
rules/modern-readonly-properties.md
---
title: Readonly Properties
impact: CRITICAL
impactDescription: Ensures immutability, prevents accidental modification
tags: modern-features, readonly, immutability, php81
---
# Readonly Properties
Use readonly properties for immutable data that should only be set once (PHP 8.1+).
## Bad Example
```php
<?php
declare(strict_types=1);
class Invoice
{
private string $invoiceNumber;
private DateTimeImmutable $issuedAt;
private float $amount;
public function __construct(
string $invoiceNumber,
DateTimeImmutable $issuedAt,
float $amount
) {
$this->invoiceNumber = $invoiceNumber;
$this->issuedAt = $issuedAt;
$this->amount = $amount;
}
// No protection against accidental modification
public function setInvoiceNumber(string $number): void
{
// This shouldn't be allowed after creation!
$this->invoiceNumber = $number;
}
public function getInvoiceNumber(): string
{
return $this->invoiceNumber;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class Invoice
{
public function __construct(
public readonly string $invoiceNumber,
public readonly DateTimeImmutable $issuedAt,
public readonly float $amount,
public readonly string $currency = 'USD',
) {}
// No setters needed - properties are immutable
// Direct public access is safe because they can't be modified
}
// Usage
$invoice = new Invoice(
invoiceNumber: 'INV-2024-001',
issuedAt: new DateTimeImmutable(),
amount: 99.99,
);
echo $invoice->invoiceNumber; // Works - reading is allowed
// $invoice->invoiceNumber = 'INV-2024-002'; // Error! Cannot modify readonly
// Readonly with private visibility when you need getters
class User
{
public function __construct(
private readonly int $id,
private readonly string $email,
private readonly string $passwordHash,
) {}
public function getId(): int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
// No getter for passwordHash - it stays private
}
// Value objects are perfect for readonly
class Coordinates
{
public function __construct(
public readonly float $latitude,
public readonly float $longitude,
) {
if ($latitude < -90 || $latitude > 90) {
throw new InvalidArgumentException('Invalid latitude');
}
if ($longitude < -180 || $longitude > 180) {
throw new InvalidArgumentException('Invalid longitude');
}
}
public function distanceTo(Coordinates $other): float
{
// Haversine formula
return 0.0; // Simplified
}
}
```
## Why
- **Immutability**: Properties cannot be modified after initialization
- **Thread Safety**: Immutable objects are inherently thread-safe
- **Value Objects**: Perfect for implementing value object pattern
- **No Defensive Copies**: Safe to expose without getters
- **Clear Intent**: Signals that property should never change
- **Bug Prevention**: Prevents accidental modification of critical data
rules/modern-typed-constants.md
---
title: Typed Class Constants
impact: HIGH
impactDescription: Prevents type errors in class constants and improves static analysis
tags: modern-features, typed-constants, type-safety, php83
phpVersion: "8.3+"
---
# Typed Class Constants
Use typed class constants to enforce type safety on constant values (PHP 8.3+).
## Bad Example
```php
<?php
declare(strict_types=1);
// Untyped constants - no type checking
class PaymentGateway
{
public const TIMEOUT = 30;
public const CURRENCY = 'USD';
public const RETRY_LIMIT = 3;
public const ENABLED = true;
}
// Child class can accidentally change the type
class StripeGateway extends PaymentGateway
{
public const TIMEOUT = '30'; // Changed from int to string - no error!
public const ENABLED = 1; // Changed from bool to int - no error!
}
interface HasVersion
{
public const VERSION = '1.0.0';
}
class App implements HasVersion
{
public const VERSION = 100; // Type changed silently - no error!
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Typed constants enforce type safety
class PaymentGateway
{
public const int TIMEOUT = 30;
public const string CURRENCY = 'USD';
public const int RETRY_LIMIT = 3;
public const bool ENABLED = true;
// Array constants with typed declaration
public const array SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP'];
}
// Child class cannot change the type
class StripeGateway extends PaymentGateway
{
public const int TIMEOUT = 60; // OK - same type
// public const string TIMEOUT = '60'; // TypeError!
}
// Interfaces with typed constants
interface HasVersion
{
public const string VERSION = '1.0.0';
}
class App implements HasVersion
{
public const string VERSION = '2.0.0'; // OK - same type
// public const int VERSION = 2; // TypeError!
}
// Enums with typed constants
enum Status: string
{
public const string DEFAULT = 'pending';
case Pending = 'pending';
case Active = 'active';
}
```
## Why
- **Type Safety**: Constants are validated when the class is loaded
- **Inheritance Protection**: Child classes cannot change constant types
- **Interface Contracts**: Typed constants in interfaces enforce implementation types
- **Static Analysis**: Tools like PHPStan and Psalm can validate constant usage
- **Self-Documenting**: Type declaration makes intent clear
rules/perf-array-functions.md
---
title: Native Array Functions
impact: MEDIUM
impactDescription: Built-in array functions are optimized in C and faster than manual loops
tags: performance, arrays, native-functions, php8
---
# Native Array Functions
Use PHP's built-in array functions instead of manual loops. They are implemented in C and significantly faster.
## Bad Example
```php
<?php
declare(strict_types=1);
// Manual filtering
$activeUsers = [];
foreach ($users as $user) {
if ($user->isActive()) {
$activeUsers[] = $user;
}
}
// Manual mapping
$emails = [];
foreach ($users as $user) {
$emails[] = $user->getEmail();
}
// Manual checking
$hasAdmin = false;
foreach ($users as $user) {
if ($user->isAdmin()) {
$hasAdmin = true;
break;
}
}
// Manual unique
$seen = [];
$unique = [];
foreach ($items as $item) {
if (!in_array($item->id, $seen, true)) {
$seen[] = $item->id;
$unique[] = $item;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Filter with arrow function
$activeUsers = array_filter($users, fn(User $u) => $u->isActive());
// Map with arrow function
$emails = array_map(fn(User $u) => $u->getEmail(), $users);
// Check existence
$hasAdmin = (bool) array_filter($users, fn(User $u) => $u->isAdmin());
// Combine filter + map
$activeEmails = array_map(
fn(User $u) => $u->getEmail(),
array_filter($users, fn(User $u) => $u->isActive()),
);
// Key-value building with array_column
$userNames = array_column($userData, 'name', 'id');
// Reduce for aggregation
$totalRevenue = array_reduce(
$orders,
fn(float $sum, Order $o) => $sum + $o->getTotal(),
0.0,
);
// Array unpacking (spread)
$merged = [...$defaults, ...$overrides];
// Sorting with spaceship operator
usort($products, fn(Product $a, Product $b) => $a->price <=> $b->price);
```
## Why
- **Performance**: Native functions are implemented in C, faster than PHP loops
- **Concise**: One line instead of 5-6 lines of loop boilerplate
- **Functional Style**: Composable, chainable operations
- **Arrow Functions**: `fn() =>` pairs naturally with array functions
- **Immutable**: Most array functions return new arrays, leaving originals unchanged
rules/perf-avoid-globals.md
---
title: Avoid Global Variables
impact: MEDIUM
impactDescription: Globals create hidden dependencies and make code untestable
tags: performance, globals, dependency-injection, php8
---
# Avoid Global Variables
Never use global variables or the `global` keyword. Use dependency injection instead.
## Bad Example
```php
<?php
declare(strict_types=1);
// Global state - hidden dependency
$db = new PDO('mysql:host=localhost;dbname=app', 'root', '');
$config = ['debug' => true, 'cache_ttl' => 3600];
class UserService
{
public function find(int $id): ?User
{
global $db; // Hidden dependency
$stmt = $db->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch();
}
public function isDebug(): bool
{
global $config; // Another hidden dependency
return $config['debug'];
}
}
// Problems:
// - Can't test without setting up globals
// - Can't trace where $db comes from
// - Any code can modify $db or $config at any time
```
## Good Example
```php
<?php
declare(strict_types=1);
class UserService
{
public function __construct(
private readonly PDO $db,
private readonly Config $config,
) {}
public function find(int $id): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
return $data ? User::fromArray($data) : null;
}
public function isDebug(): bool
{
return $this->config->get('debug', false);
}
}
// Dependencies are explicit, injectable, and testable
$service = new UserService($pdo, $config);
// Easy to test with mocks
$service = new UserService($mockPdo, new Config(['debug' => false]));
```
## Why
- **Explicit Dependencies**: Constructor shows exactly what a class needs
- **Testability**: Dependencies can be mocked or stubbed
- **No Side Effects**: No code can silently change shared state
- **Thread Safety**: No shared mutable state
- **Refactoring**: IDE can track all usages through typed properties
rules/perf-generators.md
---
title: Generators for Large Datasets
impact: MEDIUM
impactDescription: Generators process items one at a time, using constant memory regardless of dataset size
tags: performance, generators, memory, yield, php8
---
# Generators for Large Datasets
Use generators (`yield`) to process large datasets without loading everything into memory.
## Bad Example
```php
<?php
declare(strict_types=1);
// Loads entire file into memory - crashes on large files
function readLines(string $path): array
{
return file($path); // 1GB file = 1GB+ memory
}
// Loads all records into memory
function getAllUsers(PDO $pdo): array
{
$stmt = $pdo->query('SELECT * FROM users');
return $stmt->fetchAll(); // 1M users = huge memory spike
}
// Building large array in memory
function generateRange(int $start, int $end): array
{
$result = [];
for ($i = $start; $i <= $end; $i++) {
$result[] = $i;
}
return $result; // 10M items = 10M element array
}
foreach (getAllUsers() as $user) {
processUser($user); // All users loaded before loop starts
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Reads file line by line - constant memory
function readLines(string $path): \Generator
{
$handle = fopen($path, 'r');
try {
while (($line = fgets($handle)) !== false) {
yield trim($line);
}
} finally {
fclose($handle);
}
}
// Fetches records in chunks - constant memory
function getAllUsers(PDO $pdo, int $chunkSize = 1000): \Generator
{
$offset = 0;
do {
$stmt = $pdo->prepare('SELECT * FROM users LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $chunkSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
yield User::fromArray($row);
}
$offset += $chunkSize;
} while (count($rows) === $chunkSize);
}
// Lazy range - no array allocation
function lazyRange(int $start, int $end): \Generator
{
for ($i = $start; $i <= $end; $i++) {
yield $i;
}
}
// Process one user at a time - constant memory
foreach (getAllUsers($pdo) as $user) {
processUser($user);
}
// Chain generators with yield from
function activeUsers(PDO $pdo): \Generator
{
foreach (getAllUsers($pdo) as $user) {
if ($user->isActive()) {
yield $user;
}
}
}
```
## Why
- **Constant Memory**: Process 1M records using the same memory as 1 record
- **Lazy Evaluation**: Items computed on demand, not all upfront
- **Composable**: Generators can be chained with `yield from`
- **File Processing**: Read multi-GB files without memory issues
- **Database Batching**: Fetch in chunks while presenting a simple iterator interface
rules/perf-lazy-loading.md
---
title: Lazy Loading
impact: MEDIUM
impactDescription: Defer expensive operations until actually needed to save memory and time
tags: performance, lazy-loading, optimization, php8
---
# Lazy Loading
Load resources and perform expensive operations only when they are actually needed, not at construction time.
## Bad Example
```php
<?php
declare(strict_types=1);
class ReportService
{
private array $allUsers;
private array $allOrders;
private PDFGenerator $pdf;
public function __construct(
private readonly UserRepository $userRepo,
private readonly OrderRepository $orderRepo,
) {
// Loads everything upfront even if not all methods are called
$this->allUsers = $this->userRepo->findAll();
$this->allOrders = $this->orderRepo->findAll();
$this->pdf = new PDFGenerator(); // Expensive initialization
}
public function getUserCount(): int
{
return count($this->allUsers);
}
public function generateReport(): string
{
return $this->pdf->generate($this->allOrders);
}
}
// Just calling getUserCount() loads ALL orders and initializes PDF engine
$report = new ReportService($userRepo, $orderRepo);
echo $report->getUserCount();
```
## Good Example
```php
<?php
declare(strict_types=1);
class ReportService
{
private ?array $users = null;
private ?PDFGenerator $pdf = null;
public function __construct(
private readonly UserRepository $userRepo,
private readonly OrderRepository $orderRepo,
) {}
public function getUserCount(): int
{
return $this->userRepo->count(); // Query only what's needed
}
public function generateReport(): string
{
$orders = $this->orderRepo->findAll(); // Load when needed
return $this->getPdf()->generate($orders);
}
private function getPdf(): PDFGenerator
{
// Lazy initialization - created only on first use
return $this->pdf ??= new PDFGenerator();
}
/** @return array<User> */
private function getUsers(): array
{
return $this->users ??= $this->userRepo->findAll();
}
}
// getUserCount() only runs a COUNT query - no data loaded
$report = new ReportService($userRepo, $orderRepo);
echo $report->getUserCount();
```
## Why
- **Faster Startup**: Constructor does minimal work
- **Less Memory**: Only loads data that's actually used
- **Null Coalescing Assignment**: `??=` provides clean lazy init pattern
- **Query Optimization**: Use COUNT queries instead of loading all records
- **Pay for What You Use**: Expensive resources created only when needed
rules/perf-string-functions.md
---
title: Native String Functions
impact: MEDIUM
impactDescription: Use built-in string functions over regex for simple operations
tags: performance, strings, native-functions, php8
---
# Native String Functions
Use PHP's built-in string functions instead of regex for simple operations. String functions are faster and more readable.
## Bad Example
```php
<?php
declare(strict_types=1);
// Regex for simple checks - overkill
if (preg_match('/^https/', $url)) {
// starts with https
}
if (preg_match('/\.pdf$/', $filename)) {
// ends with .pdf
}
if (preg_match('/admin/', $role)) {
// contains admin
}
// Regex for simple replacements
$clean = preg_replace('/\s+/', ' ', $text);
$slug = preg_replace('/[^a-z0-9]/', '-', strtolower($title));
```
## Good Example
```php
<?php
declare(strict_types=1);
// str_starts_with (PHP 8.0+)
if (str_starts_with($url, 'https')) {
// starts with https
}
// str_ends_with (PHP 8.0+)
if (str_ends_with($filename, '.pdf')) {
// ends with .pdf
}
// str_contains (PHP 8.0+)
if (str_contains($role, 'admin')) {
// contains admin
}
// String functions for simple operations
$trimmed = trim($input);
$lower = strtolower($name);
$upper = strtoupper($code);
$replaced = str_replace('old', 'new', $text);
$parts = explode(',', $csv);
$joined = implode(', ', $items);
// substr for extraction
$extension = substr($filename, strrpos($filename, '.') + 1);
// Use regex only for complex patterns
$isEmail = filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
$matches = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts);
```
## Why
- **Performance**: String functions are 2-10x faster than regex for simple operations
- **Readability**: `str_contains($s, 'admin')` is clearer than `preg_match('/admin/', $s)`
- **No Escaping**: No need to escape regex special characters
- **Type Safety**: String functions have strict parameter types
- **PHP 8.0+**: `str_starts_with`, `str_ends_with`, `str_contains` replace common regex patterns
rules/psr-12-coding-style.md
---
title: PSR-12 Coding Style
impact: HIGH
impactDescription: Consistent formatting, improved readability and collaboration
tags: psr, coding-style, formatting, php-fig
---
# PSR-12 Coding Style
Follow PSR-12 extended coding style for consistent, readable code.
## Bad Example
```php
<?php
namespace App\Services;
use App\Models\User;use App\Repositories\UserRepository;
class UserService{
private $repository;
public function __construct(UserRepository $repo){
$this->repository=$repo;
}
public function find($id){
if($id<1){return null;}
return $this->repository->find($id);
}
public function create($data)
{
if(!isset($data['email'])||!isset($data['name'])){throw new \Exception('Missing data');}
return $this->repository->create($data);
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Repositories\UserRepository;
use InvalidArgumentException;
class UserService
{
public function __construct(
private UserRepository $repository,
) {}
public function find(int $id): ?User
{
if ($id < 1) {
return null;
}
return $this->repository->find($id);
}
public function create(array $data): User
{
if (!isset($data['email']) || !isset($data['name'])) {
throw new InvalidArgumentException('Missing required data');
}
return $this->repository->create($data);
}
public function update(
int $id,
array $data,
bool $validate = true,
): User {
$user = $this->find($id);
if ($user === null) {
throw new UserNotFoundException($id);
}
if ($validate) {
$this->validate($data);
}
return $this->repository->update($user, $data);
}
}
```
### Key PSR-12 Rules
```php
<?php
declare(strict_types=1);
namespace App\Example;
use App\Contracts\ServiceInterface;
use App\Exceptions\CustomException;
use Psr\Log\LoggerInterface;
// One blank line after namespace and use blocks
class ExampleService implements ServiceInterface
{
// Opening brace on its own line for classes
private const MAX_RETRIES = 3;
public function __construct(
private LoggerInterface $logger,
private int $timeout = 30,
) {
// Constructor body
}
// One blank line between methods
public function process(
string $input,
array $options = [],
): string {
// Multi-line: closing paren, return type, and brace on same line
$result = '';
// Space after control structure keywords
if ($input === '') {
return $result;
}
// Operators surrounded by spaces
$length = strlen($input) + 1;
// Foreach with proper spacing
foreach ($options as $key => $value) {
$result .= "{$key}: {$value}\n";
}
// Switch statement formatting
switch ($input[0]) {
case 'a':
$result = 'starts with a';
break;
case 'b':
$result = 'starts with b';
break;
default:
$result = 'other';
break;
}
// Try-catch formatting
try {
$this->validate($input);
} catch (CustomException $e) {
$this->logger->error($e->getMessage());
throw $e;
} finally {
$this->cleanup();
}
return $result;
}
// Closure formatting
public function withCallback(callable $callback): array
{
$items = [1, 2, 3];
// Short closure
$doubled = array_map(fn($n) => $n * 2, $items);
// Multi-line closure
$processed = array_filter(
$items,
function (int $item) use ($callback): bool {
return $callback($item) > 0;
}
);
return $processed;
}
}
```
### Interface and Trait
```php
<?php
declare(strict_types=1);
namespace App\Contracts;
interface ServiceInterface
{
public function process(string $input, array $options = []): string;
public function withCallback(callable $callback): array;
}
trait LoggableTrait
{
protected function log(string $message): void
{
// Trait method implementation
}
}
```
## Why
- **Consistency**: All code looks the same regardless of author
- **Readability**: Standardized formatting is easier to read
- **Tooling**: PHP CS Fixer and IDE formatters can enforce automatically
- **Collaboration**: Reduces friction in code reviews
- **Industry Standard**: Most PHP projects and frameworks follow PSR-12
- **Professionalism**: Demonstrates attention to code quality
rules/psr-4-autoloading.md
---
title: PSR-4 Autoloading
impact: CRITICAL
impactDescription: Standard autoloading, predictable class location
tags: psr, autoloading, organization, php-fig
---
# PSR-4 Autoloading
Follow PSR-4 autoloading standard for class file organization.
## Bad Example
```php
<?php
// File: includes/classes/user_model.php
// Wrong: File name doesn't match class name
// Wrong: Using underscores instead of directories
class User_Model
{
// ...
}
// File: lib/MyApp/Services/userService.php
// Wrong: File name case doesn't match class name
namespace MyApp\Services;
class UserService
{
// ...
}
// Manual includes - fragile and error-prone
require_once 'includes/classes/user_model.php';
require_once 'includes/classes/order_model.php';
require_once 'lib/helpers.php';
```
## Good Example
```php
<?php
declare(strict_types=1);
// File: src/Domain/User/User.php
// Namespace matches directory structure
namespace App\Domain\User;
class User
{
public function __construct(
private UserId $id,
private Email $email,
) {}
}
// File: src/Domain/User/UserId.php
namespace App\Domain\User;
readonly class UserId
{
public function __construct(
public string $value,
) {}
}
// File: src/Application/Services/UserService.php
namespace App\Application\Services;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
class UserService
{
public function __construct(
private UserRepository $repository,
) {}
}
```
### Composer Configuration
```json
{
"autoload": {
"psr-4": {
"App\\": "src/",
"Tests\\": "tests/"
}
}
}
```
### Directory Structure
```
project/
├── composer.json
├── src/
│ ├── Application/
│ │ ├── Commands/
│ │ │ └── CreateUserCommand.php
│ │ └── Services/
│ │ └── UserService.php
│ ├── Domain/
│ │ ├── User/
│ │ │ ├── User.php
│ │ │ ├── UserId.php
│ │ │ ├── Email.php
│ │ │ └── UserRepository.php
│ │ └── Order/
│ │ ├── Order.php
│ │ └── OrderRepository.php
│ └── Infrastructure/
│ ├── Persistence/
│ │ └── DoctrineUserRepository.php
│ └── Http/
│ └── Controllers/
│ └── UserController.php
└── tests/
├── Unit/
│ └── Domain/
│ └── User/
│ └── UserTest.php
└── Integration/
└── UserServiceTest.php
```
## Why
- **Automatic Loading**: No manual require/include statements needed
- **Predictable Structure**: Class location is deterministic from namespace
- **IDE Support**: Enables full autocompletion and navigation
- **Composer Integration**: Standard Composer autoloader works out of the box
- **Interoperability**: Works with any PSR-4 compliant framework
- **Maintainability**: Clear organization makes codebases easier to navigate
rules/psr-file-structure.md
---
title: File Structure
impact: MEDIUM
impactDescription: Predictable file organization, improves readability
tags: psr, file-structure, organization, php-fig
---
# File Structure
Organize PHP files with proper ordering of elements and logical grouping.
## Bad Example
```php
<?php
class UserService {
use LoggableTrait;
private $repo;
const MAX = 100;
public function find($id) {}
private $logger;
public const MIN = 1;
public function __construct($repo, $logger) {
$this->repo = $repo;
$this->logger = $logger;
}
}
namespace App\Services;
use App\Repositories\UserRepository;
```
## Good Example
```php
<?php
/**
* This file is part of the MyApp package.
*
* (c) Company Name <email@example.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace App\Services;
use App\Contracts\UserServiceInterface;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use App\Events\UserCreated;
use DateTimeImmutable;
use InvalidArgumentException;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
/**
* Handles user-related business operations.
*/
final class UserService implements UserServiceInterface
{
use LoggableTrait;
use CacheableTrait;
// Constants - public first, then protected, then private
public const DEFAULT_PAGE_SIZE = 20;
public const MAX_PAGE_SIZE = 100;
protected const CACHE_TTL = 3600;
private const LOG_CHANNEL = 'user';
// Properties - ordered by visibility
public readonly string $version;
protected EventDispatcherInterface $dispatcher;
private UserRepository $repository;
private LoggerInterface $logger;
private array $cache = [];
// Constructor
public function __construct(
UserRepository $repository,
LoggerInterface $logger,
EventDispatcherInterface $dispatcher,
) {
$this->repository = $repository;
$this->logger = $logger;
$this->dispatcher = $dispatcher;
$this->version = '1.0.0';
}
// Public methods
public function find(int $id): ?User
{
return $this->repository->find($id);
}
public function findOrFail(int $id): User
{
$user = $this->find($id);
if ($user === null) {
throw new UserNotFoundException($id);
}
return $user;
}
public function create(array $data): User
{
$this->validateCreateData($data);
$user = $this->repository->create($data);
$this->dispatcher->dispatch(
new UserCreated($user->getId(), new DateTimeImmutable())
);
return $user;
}
public function update(User $user, array $data): User
{
$this->validateUpdateData($data);
return $this->repository->update($user, $data);
}
public function delete(User $user): void
{
$this->repository->delete($user);
}
// Protected methods
protected function getCacheKey(int $id): string
{
return sprintf('user:%d', $id);
}
// Private methods
private function validateCreateData(array $data): void
{
if (empty($data['email'])) {
throw new InvalidArgumentException('Email is required');
}
if (empty($data['name'])) {
throw new InvalidArgumentException('Name is required');
}
}
private function validateUpdateData(array $data): void
{
// Validation logic
}
}
```
### Standard File Structure Order
```
1. Opening PHP tag (<?php)
2. File-level docblock (optional - license, copyright)
3. declare(strict_types=1)
4. Blank line
5. namespace declaration
6. Blank line
7. use statements (grouped and sorted)
- PHP native classes
- External packages
- Internal project classes
8. Blank line
9. Class/Interface/Trait/Enum docblock
10. Class/Interface/Trait/Enum declaration
a. Traits (use statements)
b. Constants (public → protected → private)
c. Properties (public → protected → private)
d. Constructor
e. Public methods
f. Protected methods
g. Private methods
```
### Use Statement Organization
```php
<?php
declare(strict_types=1);
namespace App\Services;
// PHP native classes
use DateTimeImmutable;
use InvalidArgumentException;
use RuntimeException;
// External packages (alphabetized by vendor)
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
// Internal project classes (alphabetized by namespace)
use App\Contracts\ServiceInterface;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use App\Events\UserCreated;
use App\Exceptions\UserNotFoundException;
```
## Why
- **Predictability**: Developers know where to find things
- **Readability**: Logical ordering improves code comprehension
- **Maintenance**: Consistent structure makes updates easier
- **Code Review**: Standard format reduces review friction
- **Tooling**: IDE and static analysis tools work better
- **PSR Compliance**: Follows PHP-FIG recommendations
rules/psr-namespace-usage.md
---
title: Namespace Usage
impact: HIGH
impactDescription: Proper code organization, avoids naming conflicts
tags: psr, namespaces, organization, php-fig
---
# Namespace Usage
Use namespaces properly to organize code and avoid naming conflicts.
## Bad Example
```php
<?php
// No namespace - global scope pollution
class User
{
}
class UserService
{
}
// Wrong: Using namespace as prefix only
namespace MyApp;
class MyApp_User // Redundant prefix
{
}
// Wrong: Too deep/specific namespaces
namespace App\Domain\User\Entity\Model\Base;
class User
{
}
// Wrong: Using fully qualified names everywhere
class UserService
{
public function find(int $id): \App\Domain\User\User
{
return $this->repository->find($id);
}
public function create(\App\Domain\User\CreateUserDto $dto): \App\Domain\User\User
{
// Cluttered and hard to read
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
namespace App\Domain\User;
// Import classes at the top
use App\Domain\Shared\ValueObject;
use DateTimeImmutable;
class User
{
public function __construct(
private UserId $id,
private Email $email,
private DateTimeImmutable $createdAt,
) {}
}
// Service in separate namespace
// File: src/Application/Services/UserService.php
namespace App\Application\Services;
use App\Domain\User\User;
use App\Domain\User\UserId;
use App\Domain\User\UserRepository;
use App\Application\Dto\CreateUserDto;
class UserService
{
public function __construct(
private UserRepository $repository,
) {}
public function find(UserId $id): ?User
{
return $this->repository->find($id);
}
public function create(CreateUserDto $dto): User
{
// Clean and readable
}
}
```
### Namespace Organization Patterns
```php
<?php
// Domain Layer
namespace App\Domain\User; // User aggregate
namespace App\Domain\Order; // Order aggregate
namespace App\Domain\Shared; // Shared value objects
// Application Layer
namespace App\Application\Services; // Application services
namespace App\Application\Commands; // Command objects
namespace App\Application\Queries; // Query objects
namespace App\Application\Dto; // Data transfer objects
// Infrastructure Layer
namespace App\Infrastructure\Persistence; // Database implementations
namespace App\Infrastructure\Http\Controllers; // HTTP controllers
namespace App\Infrastructure\Http\Middleware; // HTTP middleware
namespace App\Infrastructure\Queue; // Queue workers
namespace App\Infrastructure\Cache; // Cache implementations
// Tests
namespace Tests\Unit\Domain\User;
namespace Tests\Integration\Application;
namespace Tests\Functional\Http;
```
### Handling Name Conflicts
```php
<?php
declare(strict_types=1);
namespace App\Services;
// Aliasing to avoid conflicts
use App\Domain\User\User;
use App\External\Payment\User as PaymentUser;
use DateTimeImmutable as DateTime;
// Or use more descriptive aliases
use App\Domain\User\User as DomainUser;
use App\Http\Resources\User as UserResource;
use App\Database\Models\User as UserModel;
class UserSyncService
{
public function sync(DomainUser $domainUser): UserModel
{
// Clear which User class is being used
}
}
```
### Grouping Related Imports
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
// Group 1: PHP built-in
use DateTimeImmutable;
use InvalidArgumentException;
// Group 2: Framework classes
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
// Group 3: External packages
use League\Fractal\Manager;
use Psr\Log\LoggerInterface;
// Group 4: Application classes
use App\Application\Services\UserService;
use App\Domain\User\UserId;
use App\Http\Requests\CreateUserRequest;
use App\Http\Resources\UserResource;
class UserController extends Controller
{
// ...
}
```
### Function and Constant Imports
```php
<?php
declare(strict_types=1);
namespace App\Services;
// Import specific functions
use function array_map;
use function array_filter;
use function sprintf;
// Import constants
use const PHP_EOL;
use const SORT_REGULAR;
// Or use namespace functions/constants directly with prefix
class DataProcessor
{
public function process(array $data): string
{
// Imported function
$filtered = array_filter($data, fn($v) => $v !== null);
// With namespace prefix (also valid)
\sort($filtered, SORT_REGULAR); // sort() modifies in place, returns bool
return \implode(PHP_EOL, $filtered);
}
}
```
## Why
- **Organization**: Namespaces group related code logically
- **Autoloading**: PSR-4 maps namespaces to directories
- **Conflict Prevention**: Same class name can exist in different namespaces
- **Readability**: Import statements make class origins clear
- **IDE Support**: Enables better autocompletion and navigation
- **Maintainability**: Clear boundaries between modules
rules/psr-naming-classes.md
---
title: Class Naming Conventions
impact: HIGH
impactDescription: Clear communication through consistent naming patterns
tags: psr, naming, conventions, readability
---
# Class Naming Conventions
Use PascalCase for class names with descriptive, intention-revealing names.
## Bad Example
```php
<?php
declare(strict_types=1);
// Wrong: Lowercase, underscores
class user_service
{
}
// Wrong: Abbreviations
class UsrMgr
{
}
// Wrong: Generic names
class Manager
{
}
class Data
{
}
class Helper
{
}
// Wrong: Verb as class name (sounds like method)
class GetUser
{
}
// Wrong: Inconsistent suffixes
class UserProcess
{
}
class OrderHandler
{
}
class ProductManager
{
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Domain entities - nouns
class User
{
}
class Order
{
}
class Product
{
}
// Services - NounService pattern
class UserService
{
}
class OrderService
{
}
class PaymentService
{
}
// Repositories - NounRepository pattern
class UserRepository
{
}
class OrderRepository
{
}
// Controllers - NounController pattern
class UserController
{
}
class OrderController
{
}
// Factories - NounFactory pattern
class UserFactory
{
}
class OrderFactory
{
}
// Commands/Actions - VerbNounCommand pattern
class CreateUserCommand
{
}
class ProcessPaymentCommand
{
}
class SendEmailCommand
{
}
// Handlers - NounHandler pattern
class CreateUserHandler
{
}
class PaymentFailedHandler
{
}
// Events - PastTenseNoun pattern
class UserCreated
{
}
class OrderShipped
{
}
class PaymentFailed
{
}
// Exceptions - DescriptiveException pattern
class UserNotFoundException
{
}
class InvalidPaymentMethodException
{
}
class InsufficientFundsException
{
}
// Value Objects - descriptive nouns
class EmailAddress
{
}
class Money
{
}
class DateRange
{
}
// Interfaces - Adjective-able or Noun pattern
interface Cacheable
{
}
interface Serializable
{
}
interface UserRepository
{
}
interface PaymentGateway
{
}
// Abstract classes - AbstractNoun or BaseNoun
abstract class AbstractRepository
{
}
abstract class BaseController
{
}
```
### Naming Patterns by Type
```php
<?php
declare(strict_types=1);
namespace App\Domain\Order;
// Entity - simple noun
class Order
{
public function __construct(
private OrderId $id,
private CustomerId $customerId,
private OrderStatus $status,
) {}
}
// Value Object - descriptive noun
readonly class OrderId
{
public function __construct(
public string $value,
) {}
}
// Enum - singular noun
enum OrderStatus: string
{
case Pending = 'pending';
case Completed = 'completed';
}
// Repository Interface - NounRepository
interface OrderRepository
{
public function find(OrderId $id): ?Order;
public function save(Order $order): void;
}
// Service - NounService
class OrderService
{
public function __construct(
private OrderRepository $repository,
) {}
}
// Event - past tense
readonly class OrderCreated
{
public function __construct(
public OrderId $orderId,
public DateTimeImmutable $occurredAt,
) {}
}
// Command - imperative
readonly class CreateOrder
{
public function __construct(
public CustomerId $customerId,
public array $items,
) {}
}
```
## Why
- **Clarity**: PascalCase visually distinguishes classes from variables/functions
- **Consistency**: Standard naming patterns make code predictable
- **Discoverability**: Suffixes (Service, Repository) indicate purpose
- **Communication**: Names reveal intent to other developers
- **IDE Support**: Consistent naming improves autocompletion
- **PSR Compliance**: Follows PHP-FIG recommendations
rules/psr-naming-methods.md
---
title: Method Naming Conventions
impact: MEDIUM
impactDescription: Clear intent through descriptive method names
tags: psr, naming, methods, conventions
---
# Method Naming Conventions
Use camelCase for method names with clear, action-oriented verbs.
## Bad Example
```php
<?php
declare(strict_types=1);
class UserService
{
// Wrong: Underscores
public function get_user_by_id(int $id): ?User
{
}
// Wrong: Starts with uppercase
public function FindActive(): array
{
}
// Wrong: No verb - unclear what it does
public function user(int $id): User
{
}
// Wrong: Abbreviations
public function getUsrByEml(string $email): ?User
{
}
// Wrong: Boolean method doesn't read as question
public function checkActive(): bool
{
}
// Wrong: Vague names
public function process(): void
{
}
public function handle(): void
{
}
public function doStuff(): void
{
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class UserService
{
// Finder methods - findBy pattern
public function find(int $id): ?User
{
return $this->repository->find($id);
}
public function findByEmail(string $email): ?User
{
return $this->repository->findByEmail($email);
}
public function findOrFail(int $id): User
{
return $this->repository->find($id)
?? throw new UserNotFoundException($id);
}
// Collection getters - get + plural
public function getActiveUsers(): array
{
return $this->repository->findActive();
}
public function getAllByRole(Role $role): array
{
return $this->repository->findByRole($role);
}
// Boolean methods - is/has/can/should prefix
public function isActive(User $user): bool
{
return $user->status === UserStatus::Active;
}
public function hasPermission(User $user, string $permission): bool
{
return in_array($permission, $user->getPermissions(), true);
}
public function canEdit(User $user, Resource $resource): bool
{
return $user->getId() === $resource->getOwnerId()
|| $this->hasPermission($user, 'edit_all');
}
public function shouldNotify(User $user): bool
{
return $user->preferences->notificationsEnabled;
}
// Action methods - verb + noun
public function create(CreateUserDto $dto): User
{
return $this->repository->create($dto);
}
public function update(User $user, UpdateUserDto $dto): User
{
return $this->repository->update($user, $dto);
}
public function delete(User $user): void
{
$this->repository->delete($user);
}
public function activate(User $user): void
{
$user->activate();
$this->repository->save($user);
}
public function deactivate(User $user): void
{
$user->deactivate();
$this->repository->save($user);
}
// Conversion methods - to + target type
public function toArray(User $user): array
{
return $user->toArray();
}
public function toDto(User $user): UserDto
{
return UserDto::fromEntity($user);
}
// Calculation methods - calculate/compute prefix
public function calculateAge(User $user): int
{
return $user->birthDate->diff(new DateTimeImmutable())->y;
}
public function computeDiscount(User $user): float
{
return match ($user->tier) {
UserTier::Gold => 0.20,
UserTier::Silver => 0.10,
default => 0.0,
};
}
}
```
### Common Method Prefixes
```php
<?php
declare(strict_types=1);
class ExampleService
{
// Getters - get + property/computed value
public function getId(): int { }
public function getName(): string { }
public function getFullName(): string { }
// Setters - set + property
public function setName(string $name): void { }
public function setStatus(Status $status): void { }
// Boolean checks - is/has/can/should/was/will
public function isValid(): bool { }
public function isEmpty(): bool { }
public function hasItems(): bool { }
public function hasBeenModified(): bool { }
public function canProcess(): bool { }
public function shouldRetry(): bool { }
public function wasSuccessful(): bool { }
public function willExpire(): bool { }
// Actions - verb + noun (specific)
public function sendEmail(Email $email): void { }
public function processPayment(Payment $payment): void { }
public function generateReport(ReportType $type): Report { }
public function validateInput(array $input): ValidationResult { }
public function parseResponse(string $response): array { }
public function formatDate(DateTimeInterface $date): string { }
// CRUD operations
public function create(array $data): Entity { }
public function read(int $id): ?Entity { }
public function update(Entity $entity, array $data): Entity { }
public function delete(Entity $entity): void { }
// Collection operations
public function add(Item $item): void { }
public function remove(Item $item): void { }
public function contains(Item $item): bool { }
public function count(): int { }
public function clear(): void { }
}
```
## Why
- **Readability**: camelCase is PHP convention for methods
- **Intent**: Verb prefixes reveal the action being performed
- **Boolean Clarity**: is/has/can prefixes make conditionals read naturally
- **Consistency**: Standard prefixes make APIs predictable
- **Self-Documenting**: Good names eliminate need for comments
- **IDE Support**: Consistent naming improves autocomplete suggestions
rules/sec-file-uploads.md
---
title: File Upload Security
impact: CRITICAL
impactDescription: Unrestricted file uploads can lead to remote code execution
tags: security, file-uploads, validation, php8
---
# File Upload Security
Always validate file type, size, and name. Store uploads outside the web root or in non-executable locations.
## Bad Example
```php
<?php
declare(strict_types=1);
// No validation - accepts anything
$file = $_FILES['upload'];
move_uploaded_file($file['tmp_name'], "uploads/{$file['name']}");
// Attacker uploads shell.php → remote code execution
// Only checking extension - easily bypassed
$ext = pathinfo($_FILES['upload']['name'], PATHINFO_EXTENSION);
if ($ext === 'jpg') {
move_uploaded_file($file['tmp_name'], "uploads/{$file['name']}");
}
// Attacker uploads shell.php.jpg or uses double extension
// Trusting MIME type from client - can be faked
if ($_FILES['upload']['type'] === 'image/jpeg') {
// Client sends whatever MIME type they want
}
// Storing in web-accessible directory with original name
move_uploaded_file($file['tmp_name'], "public/uploads/{$file['name']}");
```
## Good Example
```php
<?php
declare(strict_types=1);
function handleUpload(array $file): string
{
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new UploadException('Upload failed with error code: ' . $file['error']);
}
// Validate file size (server-side, don't rely on MAX_FILE_SIZE)
$maxSize = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $maxSize) {
throw new UploadException('File exceeds maximum size of 5MB');
}
// Validate MIME type using file content (not client-provided type)
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedMimes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
if (!isset($allowedMimes[$mimeType])) {
throw new UploadException('File type not allowed: ' . $mimeType);
}
// Generate random filename - never use original
$extension = $allowedMimes[$mimeType];
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
// Store outside web root
$storagePath = '/var/app/storage/uploads';
$destination = $storagePath . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new UploadException('Failed to store uploaded file');
}
return $filename;
}
// For images, verify they are valid images
function validateImage(string $path): void
{
$imageInfo = getimagesize($path);
if ($imageInfo === false) {
unlink($path);
throw new UploadException('File is not a valid image');
}
}
// Serve files through a controller (not direct URL access)
function downloadFile(string $filename): void
{
$path = '/var/app/storage/uploads/' . basename($filename);
if (!file_exists($path)) {
throw new NotFoundException('File not found');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
header('Content-Type: ' . $finfo->file($path));
header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
header('Content-Length: ' . filesize($path));
readfile($path);
}
```
## Why
- **Random Filenames**: Prevents path traversal and overwrites
- **MIME Validation**: `finfo` checks actual file content, not client-provided type
- **Outside Web Root**: PHP files in uploads can't be executed directly
- **Size Limits**: Prevents disk exhaustion attacks
- **basename()**: Strips directory components from filenames to prevent traversal
- **Serve via Controller**: Allows access control checks before serving files
rules/sec-input-validation.md
---
title: Input Validation
impact: CRITICAL
impactDescription: Unvalidated input is the root cause of most security vulnerabilities
tags: security, validation, input, sanitization, php8
---
# Input Validation
Always validate and sanitize all external input before using it. Never trust data from users, APIs, or any external source.
## Bad Example
```php
<?php
declare(strict_types=1);
// Using raw input directly
$name = $_POST['name'];
$email = $_POST['email'];
$age = $_POST['age'];
$user = new User($name, $email, $age);
$repository->save($user);
// Trusting query parameters
$page = $_GET['page'];
$sortBy = $_GET['sort']; // Could be "id; DROP TABLE users"
$results = $db->query("SELECT * FROM items ORDER BY {$sortBy} LIMIT {$page}");
```
## Good Example
```php
<?php
declare(strict_types=1);
// Validate types and constraints
function createUser(array $input): User
{
$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
throw new ValidationException(['email' => 'Invalid email address']);
}
$name = trim($input['name'] ?? '');
if ($name === '' || mb_strlen($name) > 100) {
throw new ValidationException(['name' => 'Name must be 1-100 characters']);
}
$age = filter_var($input['age'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 150],
]);
if ($age === false) {
throw new ValidationException(['age' => 'Age must be between 1 and 150']);
}
return new User($name, $email, $age);
}
// Whitelist for dynamic columns
function getResults(PDO $pdo, array $input): array
{
$allowedColumns = ['id', 'name', 'created_at', 'price'];
$sortBy = in_array($input['sort'] ?? '', $allowedColumns, true)
? $input['sort']
: 'id';
$page = max(1, (int) ($input['page'] ?? 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$stmt = $pdo->prepare(
"SELECT * FROM items ORDER BY {$sortBy} LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
```
## Why
- **Prevents Injection**: SQL injection, XSS, command injection all start with unvalidated input
- **Data Integrity**: Ensures only valid data enters the system
- **Whitelist Over Blacklist**: Whitelist allowed values instead of trying to block bad ones
- **Type Coercion**: `filter_var` with FILTER_VALIDATE_INT returns false for non-integers
- **Defense in Depth**: Validate at every boundary, not just the frontend
rules/sec-output-escaping.md
---
title: Output Escaping
impact: CRITICAL
impactDescription: Unescaped output enables XSS attacks and data corruption
tags: security, xss, output-escaping, htmlspecialchars, php8
---
# Output Escaping
Always escape output based on the context where it will be used to prevent XSS and injection attacks.
## Bad Example
```php
<?php
declare(strict_types=1);
// Raw user data in HTML - XSS vulnerable
echo "<h1>Welcome, {$user->name}</h1>";
echo "<p>{$user->bio}</p>";
// Raw data in HTML attributes
echo "<input value='{$searchTerm}'>";
// Raw data in JavaScript context
echo "<script>var name = '{$user->name}';</script>";
// Raw data in URL
echo "<a href='/search?q={$query}'>Search</a>";
```
## Good Example
```php
<?php
declare(strict_types=1);
// HTML context - htmlspecialchars with ENT_QUOTES
$safeName = htmlspecialchars($user->name, ENT_QUOTES, 'UTF-8');
echo "<h1>Welcome, {$safeName}</h1>";
// HTML attributes - same escaping
$safeSearch = htmlspecialchars($searchTerm, ENT_QUOTES, 'UTF-8');
echo "<input value=\"{$safeSearch}\">";
// JavaScript context - json_encode
$jsonName = json_encode($user->name, JSON_HEX_TAG | JSON_HEX_AMP);
echo "<script>var name = {$jsonName};</script>";
// URL context - urlencode
$safeQuery = urlencode($query);
echo "<a href=\"/search?q={$safeQuery}\">Search</a>";
// Helper function for consistent escaping
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo "<p>" . e($user->bio) . "</p>";
// In templating engines (Blade, Twig) - auto-escaping is default
// Blade: {{ $user->name }} - escaped
// Blade: {!! $trustedHtml !!} - raw (only for trusted content)
// Twig: {{ user.name }} - escaped
// Twig: {{ user.name|raw }} - raw (only for trusted content)
```
## Why
- **Prevents XSS**: Malicious scripts can't execute through escaped output
- **Context-Aware**: HTML, JavaScript, URL, and CSS each need different escaping
- **ENT_QUOTES**: Escapes both single and double quotes
- **UTF-8**: Always specify encoding to prevent multi-byte attacks
- **Template Engines**: Use frameworks with auto-escaping (Blade, Twig)
rules/sec-password-hashing.md
---
title: Password Hashing
impact: CRITICAL
impactDescription: Weak password storage enables mass credential compromise in data breaches
tags: security, passwords, hashing, bcrypt, argon2, php8
---
# Password Hashing
Always use `password_hash()` and `password_verify()` for password security. Never use MD5, SHA1, or plain text.
## Bad Example
```php
<?php
declare(strict_types=1);
// Plain text - worst possible
$password = $_POST['password'];
$db->insert('users', ['password' => $password]);
// MD5 - trivially crackable
$hash = md5($password);
// SHA1 - also trivially crackable
$hash = sha1($password);
// SHA256 without salt - still vulnerable to rainbow tables
$hash = hash('sha256', $password);
// Home-grown "salting" - reinventing the wheel poorly
$hash = hash('sha256', 'mysalt' . $password);
// Comparing hashes with == (timing attack vulnerable)
if ($storedHash == md5($inputPassword)) {
// login
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Hash with bcrypt (default) or Argon2id (recommended)
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Or: password_hash($password, PASSWORD_DEFAULT); // bcrypt
// Verify - timing-safe comparison built in
if (password_verify($inputPassword, $storedHash)) {
// Password correct
// Rehash if algorithm or cost changed
if (password_needs_rehash($storedHash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($inputPassword, PASSWORD_ARGON2ID);
$repository->updatePasswordHash($userId, $newHash);
}
// Regenerate session after login
session_regenerate_id(true);
}
// Custom Argon2id options for high-security applications
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64MB
'time_cost' => 4, // 4 iterations
'threads' => 3, // 3 threads
]);
// Password validation before hashing
function validatePassword(string $password): void
{
if (mb_strlen($password) < 8) {
throw new ValidationException(['password' => 'Minimum 8 characters']);
}
if (strlen($password) > 72) {
// bcrypt truncates at 72 bytes - strlen counts bytes
throw new ValidationException(['password' => 'Password too long']);
}
}
```
## Why
- **Argon2id**: Memory-hard algorithm resistant to GPU/ASIC attacks
- **Automatic Salting**: `password_hash` generates a unique salt per password
- **Timing-Safe**: `password_verify` prevents timing attacks
- **Future-Proof**: `password_needs_rehash` upgrades hashes when algorithm changes
- **bcrypt Limit**: bcrypt truncates passwords at 72 bytes - validate max length
- **PASSWORD_DEFAULT**: Currently bcrypt, will change to best available algorithm
rules/sec-sql-prepared.md
---
title: Prepared Statements
impact: CRITICAL
impactDescription: String-concatenated SQL enables SQL injection - the most dangerous web vulnerability
tags: security, sql-injection, prepared-statements, pdo, php8
---
# Prepared Statements
Always use prepared statements with parameter binding for SQL queries. Never concatenate user input into SQL strings.
## Bad Example
```php
<?php
declare(strict_types=1);
// String concatenation - SQL injection vulnerable
$email = $_POST['email'];
$result = $db->query("SELECT * FROM users WHERE email = '$email'");
// Input: ' OR '1'='1 → SELECT * FROM users WHERE email = '' OR '1'='1'
// Variable interpolation - equally vulnerable
$id = $_GET['id'];
$result = $db->query("SELECT * FROM orders WHERE id = $id");
// Input: 1; DROP TABLE orders → catastrophic
// sprintf - still vulnerable
$sql = sprintf("SELECT * FROM users WHERE name = '%s'", $name);
// Even with type casting - fragile and error-prone
$id = (int) $_GET['id']; // What if you forget the cast?
```
## Good Example
```php
<?php
declare(strict_types=1);
// PDO with named parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
// PDO with positional parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ? AND status = ?');
$stmt->execute([$id, $status]);
// Explicit type binding for non-string values
$stmt = $pdo->prepare('SELECT * FROM orders WHERE total > :min LIMIT :limit');
$stmt->bindValue(':min', $minAmount, PDO::PARAM_STR);
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$stmt->execute();
// INSERT with prepared statement
$stmt = $pdo->prepare(
'INSERT INTO users (name, email, created_at) VALUES (:name, :email, NOW())'
);
$stmt->execute([
'name' => $name,
'email' => $email,
]);
// PDO configuration for maximum safety
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // Use real prepared statements
]);
// Dynamic column names - whitelist, never bind
$allowed = ['name', 'email', 'created_at'];
$column = in_array($sortBy, $allowed, true) ? $sortBy : 'id';
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY {$column} ASC");
```
## Why
- **SQL Injection Prevention**: Parameters are never interpreted as SQL
- **Automatic Escaping**: Database driver handles escaping correctly
- **Performance**: Prepared statements can be reused for repeated queries
- **EMULATE_PREPARES = false**: Forces real server-side preparation
- **Column Names**: Cannot be parameterized - must use whitelist validation
rules/solid-dip.md
---
title: Dependency Inversion Principle
impact: CRITICAL
impactDescription: Depend on abstractions, improves testability and flexibility
tags: solid, dip, design-principles, abstractions, dependency-injection
---
# Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
## Bad Example
```php
<?php
declare(strict_types=1);
// High-level module directly depends on low-level implementations
class OrderService
{
private MySqlDatabase $database;
private SmtpMailer $mailer;
private StripePayment $payment;
private FileLogger $logger;
public function __construct()
{
// Creating concrete implementations - tight coupling
$this->database = new MySqlDatabase('localhost', 'shop', 'user', 'pass');
$this->mailer = new SmtpMailer('smtp.gmail.com', 587);
$this->payment = new StripePayment('sk_test_...');
$this->logger = new FileLogger('/var/log/orders.log');
}
public function createOrder(array $data): Order
{
$this->logger->log('Creating order...');
// Directly using concrete classes
$order = new Order($data);
$this->database->insert('orders', $order->toArray());
$this->payment->charge($order->getTotal());
$this->mailer->send($order->getCustomerEmail(), 'Order Confirmation', '...');
return $order;
}
}
// Problems:
// - Can't swap MySQL for PostgreSQL without changing OrderService
// - Can't test without real database, mailer, payment gateway
// - Can't use different mailer in production vs development
// - OrderService knows about infrastructure details
```
## Good Example
```php
<?php
declare(strict_types=1);
// Define abstractions (interfaces) in the domain/application layer
interface OrderRepository
{
public function save(Order $order): void;
public function find(OrderId $id): ?Order;
}
interface PaymentGateway
{
public function charge(Money $amount, PaymentMethod $method): PaymentResult;
}
interface NotificationService
{
public function notify(User $user, Notification $notification): void;
}
interface Logger
{
public function info(string $message, array $context = []): void;
public function error(string $message, array $context = []): void;
}
// High-level module depends on abstractions
class OrderService
{
public function __construct(
private OrderRepository $repository,
private PaymentGateway $payment,
private NotificationService $notifications,
private Logger $logger,
) {}
public function createOrder(CreateOrderCommand $command): Order
{
$this->logger->info('Creating order', ['customer' => $command->customerId]);
$order = Order::create(
customerId: $command->customerId,
items: $command->items,
);
$paymentResult = $this->payment->charge(
$order->getTotal(),
$command->paymentMethod,
);
if (!$paymentResult->isSuccessful()) {
throw new PaymentFailedException($paymentResult->getError());
}
$order->markAsPaid($paymentResult->getTransactionId());
$this->repository->save($order);
$this->notifications->notify(
$order->getCustomer(),
new OrderConfirmationNotification($order),
);
return $order;
}
}
// Low-level modules implement the abstractions
class DoctrineOrderRepository implements OrderRepository
{
public function __construct(
private EntityManagerInterface $em,
) {}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function find(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id);
}
}
class StripePaymentGateway implements PaymentGateway
{
public function __construct(
private StripeClient $stripe,
) {}
public function charge(Money $amount, PaymentMethod $method): PaymentResult
{
// Stripe implementation
}
}
class EmailNotificationService implements NotificationService
{
public function __construct(
private Mailer $mailer,
private TemplateRenderer $renderer,
) {}
public function notify(User $user, Notification $notification): void
{
$content = $this->renderer->render($notification->getTemplate(), [
'user' => $user,
'data' => $notification->getData(),
]);
$this->mailer->send($user->getEmail(), $notification->getSubject(), $content);
}
}
// Wire everything together in composition root (DI container)
class OrderServiceProvider
{
public function register(Container $container): void
{
$container->bind(OrderRepository::class, DoctrineOrderRepository::class);
$container->bind(PaymentGateway::class, StripePaymentGateway::class);
$container->bind(NotificationService::class, EmailNotificationService::class);
$container->bind(Logger::class, MonologLogger::class);
}
}
```
### Testing Benefits
```php
<?php
declare(strict_types=1);
class OrderServiceTest extends TestCase
{
public function testCreateOrderChargesPayment(): void
{
// Use test doubles - easy because we depend on abstractions
$repository = new InMemoryOrderRepository();
$payment = $this->createMock(PaymentGateway::class);
$notifications = new NullNotificationService();
$logger = new NullLogger();
$payment->expects($this->once())
->method('charge')
->willReturn(PaymentResult::success('txn_123'));
$service = new OrderService(
$repository,
$payment,
$notifications,
$logger,
);
$order = $service->createOrder(new CreateOrderCommand(
customerId: 1,
items: [new OrderItem('product-1', 2, Money::USD(1000))],
paymentMethod: new CreditCard('4242...'),
));
$this->assertTrue($order->isPaid());
}
}
// In-memory implementation for testing
class InMemoryOrderRepository implements OrderRepository
{
private array $orders = [];
public function save(Order $order): void
{
$this->orders[$order->getId()->value] = $order;
}
public function find(OrderId $id): ?Order
{
return $this->orders[$id->value] ?? null;
}
}
// Null implementation for testing
class NullNotificationService implements NotificationService
{
public function notify(User $user, Notification $notification): void
{
// Do nothing - for testing
}
}
```
## Why
- **Decoupling**: High-level policy doesn't depend on low-level details
- **Testability**: Easy to substitute test doubles for dependencies
- **Flexibility**: Swap implementations without changing business logic
- **Maintainability**: Changes to infrastructure don't affect domain code
- **Framework Independence**: Business logic isn't tied to specific frameworks
- **Parallel Development**: Teams can work on different layers independently
rules/solid-isp.md
---
title: Interface Segregation Principle
impact: HIGH
impactDescription: Focused interfaces, no forced implementations
tags: solid, isp, design-principles, interfaces
---
# Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
## Bad Example
```php
<?php
declare(strict_types=1);
// Fat interface - forces implementers to define methods they don't need
interface WorkerInterface
{
public function work(): void;
public function eat(): void;
public function sleep(): void;
public function attendMeeting(): void;
public function submitTimesheet(): void;
public function requestVacation(): void;
}
// Robot must implement methods that don't make sense for it
class Robot implements WorkerInterface
{
public function work(): void
{
echo "Working...";
}
public function eat(): void
{
// Robots don't eat - forced to implement anyway
throw new LogicException("Robots don't eat");
}
public function sleep(): void
{
// Robots don't sleep
throw new LogicException("Robots don't sleep");
}
public function attendMeeting(): void
{
throw new LogicException("Robots don't attend meetings");
}
public function submitTimesheet(): void
{
throw new LogicException("Robots don't submit timesheets");
}
public function requestVacation(): void
{
throw new LogicException("Robots don't take vacation");
}
}
// Another bloated interface
interface RepositoryInterface
{
public function find(int $id): ?object;
public function findAll(): array;
public function create(array $data): object;
public function update(int $id, array $data): object;
public function delete(int $id): void;
public function paginate(int $page, int $perPage): array;
public function search(string $query): array;
public function count(): int;
public function exists(int $id): bool;
public function createMany(array $records): array;
public function deleteMany(array $ids): void;
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Segregated interfaces - each has a focused purpose
interface Workable
{
public function work(): void;
}
interface Eatable
{
public function eat(): void;
}
interface Sleepable
{
public function sleep(): void;
}
interface MeetingAttendee
{
public function attendMeeting(): void;
}
interface TimesheetSubmitter
{
public function submitTimesheet(): void;
}
interface VacationRequester
{
public function requestVacation(): void;
}
// Human worker implements all relevant interfaces
class HumanWorker implements
Workable,
Eatable,
Sleepable,
MeetingAttendee,
TimesheetSubmitter,
VacationRequester
{
public function work(): void
{
echo "Working on tasks...";
}
public function eat(): void
{
echo "Taking lunch break...";
}
public function sleep(): void
{
echo "Resting after work...";
}
public function attendMeeting(): void
{
echo "Attending team meeting...";
}
public function submitTimesheet(): void
{
echo "Submitting weekly timesheet...";
}
public function requestVacation(): void
{
echo "Requesting PTO...";
}
}
// Robot only implements what makes sense
class Robot implements Workable
{
public function work(): void
{
echo "Performing automated tasks...";
}
}
// Contractor doesn't get vacation
class Contractor implements Workable, Eatable, TimesheetSubmitter
{
public function work(): void
{
echo "Working on contract...";
}
public function eat(): void
{
echo "Lunch break...";
}
public function submitTimesheet(): void
{
echo "Submitting contractor timesheet...";
}
}
```
### Repository Example with Segregated Interfaces
```php
<?php
declare(strict_types=1);
// Core read operations
interface ReadableRepository
{
public function find(int $id): ?object;
public function findAll(): array;
public function exists(int $id): bool;
}
// Core write operations
interface WritableRepository
{
public function create(array $data): object;
public function update(int $id, array $data): object;
public function delete(int $id): void;
}
// Optional pagination capability
interface PaginatableRepository
{
public function paginate(int $page, int $perPage): PaginatedResult;
}
// Optional search capability
interface SearchableRepository
{
public function search(string $query): array;
}
// Optional bulk operations
interface BulkWritableRepository
{
public function createMany(array $records): array;
public function deleteMany(array $ids): void;
}
// Full-featured repository for normal entities
class UserRepository implements
ReadableRepository,
WritableRepository,
PaginatableRepository,
SearchableRepository
{
public function find(int $id): ?User
{
// Implementation
}
public function findAll(): array
{
// Implementation
}
public function exists(int $id): bool
{
// Implementation
}
public function create(array $data): User
{
// Implementation
}
public function update(int $id, array $data): User
{
// Implementation
}
public function delete(int $id): void
{
// Implementation
}
public function paginate(int $page, int $perPage): PaginatedResult
{
// Implementation
}
public function search(string $query): array
{
// Implementation
}
}
// Read-only repository for audit logs
class AuditLogRepository implements ReadableRepository, PaginatableRepository
{
public function find(int $id): ?AuditLog
{
// Implementation
}
public function findAll(): array
{
// Implementation
}
public function exists(int $id): bool
{
// Implementation
}
public function paginate(int $page, int $perPage): PaginatedResult
{
// Implementation
}
// No write methods - audit logs are immutable
}
// Service only needs what it uses
class UserListService
{
public function __construct(
private ReadableRepository&PaginatableRepository $repository,
) {}
public function getPage(int $page): PaginatedResult
{
return $this->repository->paginate($page, 20);
}
}
```
## Why
- **No Dead Code**: Classes don't implement unused methods
- **Focused Contracts**: Each interface has clear, specific purpose
- **Flexibility**: Clients depend only on what they need
- **Easier Testing**: Mock only the interfaces actually used
- **Better Design**: Promotes composition over inheritance
- **Decoupling**: Changes to one interface don't affect unrelated clients
rules/solid-lsp.md
---
title: Liskov Substitution Principle
impact: HIGH
impactDescription: Subtypes are substitutable, maintains polymorphism
tags: solid, lsp, design-principles, substitutability
---
# Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering correctness.
## Bad Example
```php
<?php
declare(strict_types=1);
class Rectangle
{
protected int $width;
protected int $height;
public function setWidth(int $width): void
{
$this->width = $width;
}
public function setHeight(int $height): void
{
$this->height = $height;
}
public function getArea(): int
{
return $this->width * $this->height;
}
}
// Violates LSP - Square changes Rectangle behavior
class Square extends Rectangle
{
public function setWidth(int $width): void
{
// Breaks the contract - also sets height
$this->width = $width;
$this->height = $width;
}
public function setHeight(int $height): void
{
// Breaks the contract - also sets width
$this->width = $height;
$this->height = $height;
}
}
// This function expects Rectangle behavior
function resizeRectangle(Rectangle $rect): int
{
$rect->setWidth(5);
$rect->setHeight(10);
return $rect->getArea(); // Expects 50
}
$rectangle = new Rectangle();
echo resizeRectangle($rectangle); // 50 - correct
$square = new Square();
echo resizeRectangle($square); // 100 - unexpected! LSP violated
// Another violation example
class Bird
{
public function fly(): void
{
echo "Flying...";
}
}
class Penguin extends Bird
{
public function fly(): void
{
// Penguins can't fly - throws exception
throw new LogicException("Penguins can't fly!");
}
}
function makeBirdFly(Bird $bird): void
{
$bird->fly(); // Throws if penguin
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Use interface for common behavior
interface Shape
{
public function getArea(): int;
}
// Rectangle and Square are separate implementations
readonly class Rectangle implements Shape
{
public function __construct(
private int $width,
private int $height,
) {}
public function getArea(): int
{
return $this->width * $this->height;
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
}
readonly class Square implements Shape
{
public function __construct(
private int $side,
) {}
public function getArea(): int
{
return $this->side * $this->side;
}
public function getSide(): int
{
return $this->side;
}
}
// Both work correctly with the Shape interface
function calculateTotalArea(array $shapes): int
{
return array_sum(
array_map(fn(Shape $shape) => $shape->getArea(), $shapes)
);
}
$shapes = [
new Rectangle(5, 10), // 50
new Square(5), // 25
];
echo calculateTotalArea($shapes); // 75 - correct
// Bird example fixed with proper abstraction
interface Bird
{
public function eat(): void;
public function sleep(): void;
}
interface FlyingBird extends Bird
{
public function fly(): void;
}
interface SwimmingBird extends Bird
{
public function swim(): void;
}
class Sparrow implements FlyingBird
{
public function eat(): void
{
echo "Eating seeds...";
}
public function sleep(): void
{
echo "Sleeping in nest...";
}
public function fly(): void
{
echo "Flying through the air...";
}
}
class Penguin implements SwimmingBird
{
public function eat(): void
{
echo "Eating fish...";
}
public function sleep(): void
{
echo "Sleeping on ice...";
}
public function swim(): void
{
echo "Swimming in the ocean...";
}
}
// Only flying birds are passed to this function
function makeBirdsFly(FlyingBird ...$birds): void
{
foreach ($birds as $bird) {
$bird->fly();
}
}
```
### Contract Preservation Example
```php
<?php
declare(strict_types=1);
interface PaymentGateway
{
/**
* Process a payment.
*
* @throws InsufficientFundsException When balance is insufficient
* @throws PaymentDeclinedException When payment is declined
*/
public function charge(Money $amount, PaymentMethod $method): PaymentResult;
/**
* Refund must return the same currency as the original payment.
*/
public function refund(PaymentId $paymentId, Money $amount): RefundResult;
}
// Correct - follows the contract
class StripeGateway implements PaymentGateway
{
public function charge(Money $amount, PaymentMethod $method): PaymentResult
{
try {
$charge = $this->stripe->charges->create([
'amount' => $amount->cents,
'currency' => $amount->currency,
]);
return new PaymentResult(
id: new PaymentId($charge->id),
amount: $amount,
status: PaymentStatus::Completed,
);
} catch (StripeException $e) {
if ($e->getCode() === 'insufficient_funds') {
throw new InsufficientFundsException($e->getMessage());
}
throw new PaymentDeclinedException($e->getMessage());
}
}
public function refund(PaymentId $paymentId, Money $amount): RefundResult
{
// Returns same currency as documented
$refund = $this->stripe->refunds->create([
'charge' => $paymentId->value,
'amount' => $amount->cents,
]);
return new RefundResult(
id: new RefundId($refund->id),
amount: $amount,
);
}
}
// Also correct - different implementation, same contract
class PayPalGateway implements PaymentGateway
{
public function charge(Money $amount, PaymentMethod $method): PaymentResult
{
// Different implementation, same exceptions for same conditions
// Same return type with same semantics
}
public function refund(PaymentId $paymentId, Money $amount): RefundResult
{
// Same contract honored
}
}
```
## Why
- **Substitutability**: Subclasses work in place of parent classes
- **Reliability**: Code using base types works with any subtype
- **Polymorphism**: True polymorphism requires LSP compliance
- **Testing**: Base type tests apply to all subtypes
- **Design Quality**: Violations indicate wrong inheritance hierarchy
- **Maintenance**: Changing subtype implementation won't break callers
rules/solid-ocp.md
---
title: Open/Closed Principle
impact: HIGH
impactDescription: Extend without modifying, reduces regression risk
tags: solid, ocp, design-principles, extension
---
# Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification.
## Bad Example
```php
<?php
declare(strict_types=1);
// Must modify this class every time a new payment method is added
class PaymentProcessor
{
public function process(string $type, float $amount): PaymentResult
{
// Adding new payment type requires modifying this method
if ($type === 'credit_card') {
// Credit card processing logic
$fee = $amount * 0.029;
return new PaymentResult($amount + $fee, 'credit_card');
}
if ($type === 'paypal') {
// PayPal processing logic
$fee = $amount * 0.035;
return new PaymentResult($amount + $fee, 'paypal');
}
if ($type === 'bank_transfer') {
// Bank transfer logic
$fee = 1.00;
return new PaymentResult($amount + $fee, 'bank_transfer');
}
// Adding crypto? Must modify this class again!
if ($type === 'crypto') {
$fee = $amount * 0.01;
return new PaymentResult($amount + $fee, 'crypto');
}
throw new InvalidArgumentException("Unknown payment type: {$type}");
}
}
// Same problem with discount calculation
class DiscountCalculator
{
public function calculate(string $type, float $amount): float
{
return match ($type) {
'percentage' => $amount * 0.10,
'fixed' => 5.00,
'buy_one_get_one' => $amount / 2,
// Adding new discount type = modifying this class
default => 0.0,
};
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Define contract for payment methods
interface PaymentMethod
{
public function process(Money $amount): PaymentResult;
public function calculateFee(Money $amount): Money;
public function getName(): string;
}
// Each payment method is a separate class - closed for modification
class CreditCardPayment implements PaymentMethod
{
private const FEE_PERCENTAGE = 0.029;
public function __construct(
private PaymentGateway $gateway,
) {}
public function process(Money $amount): PaymentResult
{
$totalAmount = $amount->add($this->calculateFee($amount));
return $this->gateway->charge($totalAmount);
}
public function calculateFee(Money $amount): Money
{
return $amount->multiply(self::FEE_PERCENTAGE);
}
public function getName(): string
{
return 'credit_card';
}
}
class PayPalPayment implements PaymentMethod
{
private const FEE_PERCENTAGE = 0.035;
public function __construct(
private PayPalClient $client,
) {}
public function process(Money $amount): PaymentResult
{
$totalAmount = $amount->add($this->calculateFee($amount));
return $this->client->createPayment($totalAmount);
}
public function calculateFee(Money $amount): Money
{
return $amount->multiply(self::FEE_PERCENTAGE);
}
public function getName(): string
{
return 'paypal';
}
}
// New payment method - extend without modifying existing code
class CryptoPayment implements PaymentMethod
{
private const FEE_PERCENTAGE = 0.01;
public function __construct(
private CryptoGateway $gateway,
) {}
public function process(Money $amount): PaymentResult
{
$totalAmount = $amount->add($this->calculateFee($amount));
return $this->gateway->processPayment($totalAmount);
}
public function calculateFee(Money $amount): Money
{
return $amount->multiply(self::FEE_PERCENTAGE);
}
public function getName(): string
{
return 'crypto';
}
}
// Payment processor is closed for modification
class PaymentProcessor
{
/** @var array<string, PaymentMethod> */
private array $methods = [];
public function registerMethod(PaymentMethod $method): void
{
$this->methods[$method->getName()] = $method;
}
public function process(string $methodName, Money $amount): PaymentResult
{
if (!isset($this->methods[$methodName])) {
throw new UnsupportedPaymentMethodException($methodName);
}
return $this->methods[$methodName]->process($amount);
}
}
// Usage with dependency injection
class PaymentServiceProvider
{
public function register(Container $container): void
{
$container->singleton(PaymentProcessor::class, function ($c) {
$processor = new PaymentProcessor();
$processor->registerMethod($c->make(CreditCardPayment::class));
$processor->registerMethod($c->make(PayPalPayment::class));
$processor->registerMethod($c->make(CryptoPayment::class));
return $processor;
});
}
}
```
### Strategy Pattern Example
```php
<?php
declare(strict_types=1);
// Discount strategy interface
interface DiscountStrategy
{
public function calculate(Money $amount): Money;
public function getDescription(): string;
}
class PercentageDiscount implements DiscountStrategy
{
public function __construct(
private float $percentage,
) {}
public function calculate(Money $amount): Money
{
return $amount->multiply($this->percentage);
}
public function getDescription(): string
{
return sprintf('%d%% off', $this->percentage * 100);
}
}
class FixedAmountDiscount implements DiscountStrategy
{
public function __construct(
private Money $discountAmount,
) {}
public function calculate(Money $amount): Money
{
return $this->discountAmount->min($amount);
}
public function getDescription(): string
{
return sprintf('%s off', $this->discountAmount->format());
}
}
class BuyOneGetOneFreeDiscount implements DiscountStrategy
{
public function calculate(Money $amount): Money
{
return $amount->divide(2);
}
public function getDescription(): string
{
return 'Buy one get one free';
}
}
// Closed for modification, open for new discount strategies
class DiscountCalculator
{
public function apply(Money $amount, DiscountStrategy $strategy): Money
{
$discount = $strategy->calculate($amount);
return $amount->subtract($discount);
}
}
```
## Why
- **No Regression Risk**: Existing code isn't modified when adding features
- **Easy Extension**: New functionality via new classes, not changes
- **Better Testing**: Existing tests remain valid
- **Plugin Architecture**: Easy to add new behaviors at runtime
- **Team Parallelism**: Different team members add features independently
- **Framework Integration**: Works well with DI containers
rules/solid-srp.md
---
title: Single Responsibility Principle
impact: CRITICAL
impactDescription: One reason to change, easier testing and maintenance
tags: solid, srp, design-principles, single-responsibility
---
# Single Responsibility Principle (SRP)
A class should have only one reason to change - one responsibility.
## Bad Example
```php
<?php
declare(strict_types=1);
// This class has multiple responsibilities
class User
{
public function __construct(
private int $id,
private string $name,
private string $email,
) {}
// Responsibility 1: User data
public function getName(): string
{
return $this->name;
}
// Responsibility 2: Database operations
public function save(): void
{
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (?, ?)');
$stmt->execute([$this->name, $this->email]);
}
// Responsibility 3: Email sending
public function sendWelcomeEmail(): void
{
mail(
$this->email,
'Welcome!',
"Hello {$this->name}, welcome to our platform!"
);
}
// Responsibility 4: Formatting/Display
public function toJson(): string
{
return json_encode([
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
]);
}
// Responsibility 5: Validation
public function validate(): array
{
$errors = [];
if (empty($this->name)) {
$errors[] = 'Name is required';
}
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email';
}
return $errors;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Responsibility: User domain entity
class User
{
public function __construct(
private UserId $id,
private UserName $name,
private Email $email,
private UserStatus $status = UserStatus::Active,
) {}
public function getId(): UserId
{
return $this->id;
}
public function getName(): UserName
{
return $this->name;
}
public function getEmail(): Email
{
return $this->email;
}
public function isActive(): bool
{
return $this->status === UserStatus::Active;
}
public function activate(): void
{
$this->status = UserStatus::Active;
}
public function deactivate(): void
{
$this->status = UserStatus::Inactive;
}
}
// Responsibility: Database persistence
interface UserRepository
{
public function find(UserId $id): ?User;
public function save(User $user): void;
public function delete(User $user): void;
}
class DatabaseUserRepository implements UserRepository
{
public function __construct(
private PDO $pdo,
) {}
public function find(UserId $id): ?User
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id->value]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
return $data ? $this->hydrate($data) : null;
}
public function save(User $user): void
{
// Save implementation
}
public function delete(User $user): void
{
// Delete implementation
}
private function hydrate(array $data): User
{
// Hydration logic
}
}
// Responsibility: Email notifications
class UserEmailNotifier
{
public function __construct(
private MailerInterface $mailer,
private EmailTemplateRenderer $renderer,
) {}
public function sendWelcomeEmail(User $user): void
{
$content = $this->renderer->render('welcome', [
'name' => $user->getName()->value,
]);
$this->mailer->send(
to: $user->getEmail()->value,
subject: 'Welcome!',
body: $content,
);
}
public function sendPasswordResetEmail(User $user, string $token): void
{
// Password reset email logic
}
}
// Responsibility: Serialization
class UserSerializer
{
public function toArray(User $user): array
{
return [
'id' => $user->getId()->value,
'name' => $user->getName()->value,
'email' => $user->getEmail()->value,
];
}
public function toJson(User $user): string
{
return json_encode($this->toArray($user), JSON_THROW_ON_ERROR);
}
}
// Responsibility: Validation
class CreateUserValidator
{
public function validate(array $data): ValidationResult
{
$errors = [];
if (empty($data['name'])) {
$errors['name'] = 'Name is required';
}
if (empty($data['email'])) {
$errors['email'] = 'Email is required';
} elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Invalid email format';
}
return new ValidationResult($errors);
}
}
// Responsibility: Orchestrating user creation (Application Service)
class UserService
{
public function __construct(
private UserRepository $repository,
private CreateUserValidator $validator,
private UserEmailNotifier $notifier,
private UserFactory $factory,
) {}
public function create(array $data): User
{
$result = $this->validator->validate($data);
if (!$result->isValid()) {
throw new ValidationException($result->getErrors());
}
$user = $this->factory->create($data);
$this->repository->save($user);
$this->notifier->sendWelcomeEmail($user);
return $user;
}
}
```
## Identifying Violations
Signs a class has multiple responsibilities:
- Class name contains "And", "Manager", or "Helper"
- Large number of injected dependencies (5+)
- Methods that operate on unrelated data
- Fat controllers with validation, business logic, DB, email, and logging mixed together
### Refactoring Strategy
Extract each responsibility into its own class:
- **Validation** -> `UserValidator` or Form Request
- **Persistence** -> `UserRepository`
- **Notifications** -> `UserEmailNotifier`
- **Formatting** -> `UserSerializer` or API Resource
- **Orchestration** -> `UserService` (thin coordinator)
## Why
- **Focused Classes**: Each class does one thing well
- **Easier Testing**: Small, focused classes are easier to unit test
- **Better Reusability**: Single-purpose classes can be reused elsewhere
- **Simpler Maintenance**: Changes to one responsibility don't affect others
- **Clear Dependencies**: Easier to understand what a class needs
- **Team Scalability**: Different developers can work on different responsibilities
rules/type-intersection-types.md
---
title: Intersection Types
impact: MEDIUM
impactDescription: Enforces multiple interface implementations for composition
tags: type-system, intersection-types, interfaces, php81
---
# Intersection Types
Use intersection types when a value must implement multiple interfaces (PHP 8.1+).
## Bad Example
```php
<?php
declare(strict_types=1);
interface Cacheable
{
public function getCacheKey(): string;
}
interface Encodable
{
public function encode(): string;
}
class CacheService
{
/**
* @param Cacheable&Encodable $item
*/
public function store($item): void
{
// No type enforcement - relies on docblock
// Could receive object implementing only one interface
$key = $item->getCacheKey();
$data = $item->encode();
$this->cache->set($key, $data);
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
interface Cacheable
{
public function getCacheKey(): string;
public function getCacheTtl(): int;
}
interface Encodable
{
public function encode(): string;
public function decode(string $data): void;
}
class CacheService
{
// Object MUST implement both interfaces
public function store(Cacheable&Encodable $item): void
{
$key = $item->getCacheKey();
$data = $item->encode();
$ttl = $item->getCacheTtl();
$this->cache->set($key, $data, $ttl);
}
public function retrieve(
string $key,
Cacheable&Encodable $prototype
): Cacheable&Encodable {
$data = $this->cache->get($key);
$prototype->decode($data);
return $prototype;
}
}
// Implementation example
class User implements Cacheable, Encodable
{
public function __construct(
private int $id,
private string $name
) {}
public function getCacheKey(): string
{
return "user:{$this->id}";
}
public function getCacheTtl(): int
{
return 3600;
}
public function encode(): string
{
return json_encode(['id' => $this->id, 'name' => $this->name]);
}
public function decode(string $data): void
{
$decoded = json_decode($data, true);
$this->id = $decoded['id'];
$this->name = $decoded['name'];
}
}
```
## Why
- **Compound Requirements**: Enforces multiple interface implementations
- **Type Safety**: PHP enforces all required capabilities at class loading
- **Composition**: Enables type-safe composition over inheritance
- **Clear Contracts**: Explicitly states all required behaviors
- **Better Than Base Classes**: More flexible than requiring a specific base class
- **Static Analysis**: Full support in PHPStan and Psalm
rules/type-mixed-avoid.md
---
title: Avoid Mixed Type
impact: HIGH
impactDescription: Maintains type safety, use union types or generics instead
tags: type-system, mixed, union-types, generics, php8
---
# Avoid Mixed Type
Avoid using mixed type; prefer specific types or union types instead.
## Bad Example
```php
<?php
declare(strict_types=1);
class DataProcessor
{
// mixed accepts anything - no type safety
public function process(mixed $data): mixed
{
// What can we do with $data? Anything!
// What does this return? Who knows!
return $data;
}
// mixed hides the actual expected types
public function transform(mixed $input, mixed $options): mixed
{
// Lost all type information
}
}
class Cache
{
// Using mixed as a crutch
public function get(string $key): mixed
{
return $this->storage[$key] ?? null;
}
public function set(string $key, mixed $value): void
{
$this->storage[$key] = $value;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class DataProcessor
{
// Specific union type instead of mixed
public function process(array|object $data): array
{
if (is_object($data)) {
return get_object_vars($data);
}
return $data;
}
// Generic-like approach using templates (PHPStan/Psalm)
/**
* @template T
* @param T $input
* @return T
*/
public function transform(mixed $input): mixed
{
// When mixed is unavoidable, use generics for type tracking
return $input;
}
}
// Type-safe cache using generics
/**
* @template T
*/
class TypedCache
{
/** @var array<string, T> */
private array $storage = [];
/**
* @param T $value
*/
public function set(string $key, mixed $value): void
{
$this->storage[$key] = $value;
}
/**
* @return T|null
*/
public function get(string $key): mixed
{
return $this->storage[$key] ?? null;
}
}
// Specific cache implementations
class UserCache
{
/** @var array<string, User> */
private array $users = [];
public function get(string $id): ?User
{
return $this->users[$id] ?? null;
}
public function set(string $id, User $user): void
{
$this->users[$id] = $user;
}
}
// When mixed is truly needed, document why
class JsonEncoder
{
/**
* Encodes any JSON-serializable value.
* Mixed is appropriate here as JSON can encode any scalar, array, or object.
*
* @param scalar|array|object|null $value
*/
public function encode(mixed $value): string
{
return json_encode($value, JSON_THROW_ON_ERROR);
}
}
```
## Why
- **Type Safety Lost**: mixed disables all type checking
- **Hidden Bugs**: Type errors only appear at runtime
- **Poor Documentation**: Callers don't know what to pass
- **IDE Limitations**: No autocompletion or type hints
- **Maintenance Burden**: Future developers must guess types
- **Better Alternatives**: Union types, generics, or specific types are clearer
rules/type-nullable-types.md
---
title: Nullable Types
impact: CRITICAL
impactDescription: Makes null handling explicit, prevents null pointer bugs
tags: type-system, nullable, null-safety, php71
---
# Nullable Types
Use nullable types explicitly when null is a valid value.
## Bad Example
```php
<?php
declare(strict_types=1);
class UserService
{
// Unclear if null is valid
public function findByEmail(string $email)
{
$data = $this->repository->findByEmail($email);
if (!$data) {
return null; // Surprise! Returns null
}
return new User($data);
}
// Default null but no nullable type
public function setMiddleName(string $name = null): void
{
// This works but is confusing and deprecated pattern
$this->middleName = $name;
}
// Returning null from non-nullable return type
public function getActiveSubscription(): Subscription
{
// Bug: returns null but type says Subscription
return $this->subscriptions->getActive();
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class UserService
{
// Explicit nullable return type
public function findByEmail(string $email): ?User
{
$data = $this->repository->findByEmail($email);
return $data ? new User($data) : null;
}
// Explicit nullable parameter with modern syntax
public function setMiddleName(?string $name): void
{
$this->middleName = $name;
}
// Clear that subscription might not exist
public function getActiveSubscription(): ?Subscription
{
return $this->subscriptions->getActive();
}
// Alternative: throw exception when null is not acceptable
public function getActiveSubscriptionOrFail(): Subscription
{
$subscription = $this->subscriptions->getActive();
if ($subscription === null) {
throw new NoActiveSubscriptionException();
}
return $subscription;
}
}
// Proper null handling in calling code
$user = $userService->findByEmail($email);
if ($user !== null) {
$user->sendWelcomeEmail();
}
// Or with null coalescing
$displayName = $user?->getDisplayName() ?? 'Guest';
```
## Why
- **Explicit Intent**: Makes null as a valid value intentional and clear
- **Null Safety**: Forces callers to handle the null case
- **No Surprises**: Eliminates unexpected null returns
- **IDE Support**: IDEs warn about potential null pointer access
- **Static Analysis**: Tools catch null-related bugs before runtime
- **Modern Syntax**: Use `?Type` instead of `Type|null` for brevity
rules/type-parameter-types.md
---
title: Parameter Type Declarations
impact: CRITICAL
impactDescription: Prevents invalid arguments, improves code clarity
tags: type-system, parameter-types, type-safety, php7
---
# Parameter Type Declarations
Always declare types for all function and method parameters.
## Bad Example
```php
<?php
declare(strict_types=1);
class OrderService
{
// No parameter types - anything could be passed
public function createOrder($user, $items, $discount)
{
// What is $user? An ID? An object? An array?
// What format should $items be?
// Is $discount a percentage or fixed amount?
}
public function processPayment($amount, $method)
{
// Type assumptions lead to bugs
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class OrderService
{
public function createOrder(
User $user,
array $items,
float $discountPercentage = 0.0
): Order {
$order = new Order($user);
foreach ($items as $item) {
$order->addItem($item);
}
$order->applyDiscount($discountPercentage);
return $order;
}
public function processPayment(
Money $amount,
PaymentMethod $method
): PaymentResult {
return $this->paymentGateway->charge($amount, $method);
}
}
```
## Why
- **Clear Contracts**: Parameters define the method's expected input
- **Early Error Detection**: Type errors are caught immediately at call site
- **Self-Documenting**: Types eliminate guesswork about expected inputs
- **Refactoring Safety**: Changes to parameter types are caught by static analysis
- **IDE Intelligence**: Enables parameter hints and autocompletion
- **Defensive Programming**: Prevents invalid data from entering your methods
rules/type-property-types.md
---
title: Property Type Declarations
impact: CRITICAL
impactDescription: Ensures state integrity, prevents invalid property assignments
tags: type-system, property-types, type-safety, php74
---
# Property Type Declarations
Always declare types for class properties (PHP 7.4+).
## Bad Example
```php
<?php
declare(strict_types=1);
class Product
{
// No property types - state can be anything
private $id;
private $name;
private $price;
private $categories;
private $createdAt;
public function __construct($id, $name, $price)
{
$this->id = $id; // Could be int, string, null...
$this->name = $name; // Could be anything
$this->price = $price; // Hope it's numeric
$this->categories = [];
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class Product
{
private int $id;
private string $name;
private float $price;
private array $categories = [];
private ?string $description = null;
private DateTimeImmutable $createdAt;
private ?DateTimeImmutable $updatedAt = null;
public function __construct(
int $id,
string $name,
float $price,
?DateTimeImmutable $createdAt = null
) {
$this->id = $id;
$this->name = $name;
$this->price = $price;
$this->createdAt = $createdAt ?? new DateTimeImmutable();
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
}
```
## Why
- **State Integrity**: Properties always contain expected types
- **Null Safety**: Nullable types make null handling explicit
- **Initialization Enforcement**: Uninitialized typed properties throw errors
- **Documentation**: Property types serve as inline documentation
- **IDE Support**: Enables better autocompletion and refactoring
- **Static Analysis**: Tools can verify property usage throughout codebase
rules/type-return-types.md
---
title: Return Type Declarations
impact: CRITICAL
impactDescription: Enforces type contracts, enables static analysis
tags: type-system, return-types, type-safety, php7
---
# Return Type Declarations
Always declare return types for all methods and functions.
## Bad Example
```php
<?php
declare(strict_types=1);
class UserRepository
{
// No return type - unclear what this returns
public function find(int $id)
{
return $this->db->query("SELECT * FROM users WHERE id = ?", [$id]);
}
// Could return anything
public function getActiveUsers()
{
return $this->db->query("SELECT * FROM users WHERE active = 1");
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class UserRepository
{
public function find(int $id): ?User
{
$data = $this->db->query("SELECT * FROM users WHERE id = ?", [$id]);
return $data ? new User($data) : null;
}
/**
* @return User[]
*/
public function getActiveUsers(): array
{
$results = $this->db->query("SELECT * FROM users WHERE active = 1");
return array_map(fn(array $data) => new User($data), $results);
}
public function delete(int $id): bool
{
return $this->db->execute("DELETE FROM users WHERE id = ?", [$id]);
}
public function save(User $user): void
{
$this->db->execute("INSERT INTO users ...", $user->toArray());
}
}
```
## Why
- **Self-Documenting**: Return types serve as inline documentation
- **Type Safety**: Ensures methods return expected types
- **IDE Support**: Enables autocompletion and refactoring tools
- **Error Prevention**: Catches return type mismatches during development
- **Contract Enforcement**: Defines clear contracts between components
- **Static Analysis**: Enables tools like PHPStan and Psalm to find bugs
rules/type-strict-mode.md
---
title: Strict Types Declaration
impact: CRITICAL
impactDescription: Prevents type coercion bugs, enforces type safety
tags: type-system, strict-types, type-safety, php8
---
# Strict Types Declaration
`declare(strict_types=1)` enforces strict type checking for function arguments and return values. Without it, PHP silently coerces types, hiding bugs. Strict mode catches type errors early, improving code reliability.
## Bad Example
```php
<?php
// No strict types - silent coercion
function calculateTotal(int $price, int $quantity): int
{
return $price * $quantity;
}
// These hide problems:
calculateTotal("10", "5"); // Returns 50 - numeric strings coerced to int
calculateTotal(10.99, 2); // Returns 20 - float truncated to int (deprecated in 8.1)
// calculateTotal("abc", 2); // TypeError in PHP 8.0+ (non-numeric string)
// Missing from file
namespace App\Services;
class Calculator
{
// ...
}
```
## Good Example
```php
<?php
declare(strict_types=1);
// Strict types - TypeError on wrong types
function calculateTotal(int $price, int $quantity): int
{
return $price * $quantity;
}
calculateTotal(10, 5); // Returns 50
calculateTotal("10", "5"); // TypeError
calculateTotal(10.99, 2); // TypeError
```
## Declaration Rules
```php
<?php
// MUST be the first statement in the file
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
class UserService
{
// ...
}
```
```php
<?php
namespace App\Services; // Wrong - declare must come first
declare(strict_types=1);
class UserService
{
// ...
}
```
## Scope
```php
<?php
declare(strict_types=1);
// Strict mode applies to function CALLS in this file
function addNumbers(int $a, int $b): int
{
return $a + $b;
}
// This call is in strict mode
addNumbers(1, 2); //
addNumbers("1", "2"); // TypeError
// Strict mode also applies to internal PHP function calls
strlen("hello"); // Works
// strlen(12345); // TypeError - int given, string expected
```
## File-by-File Basis
```php
<?php
// file: src/Strict.php
declare(strict_types=1);
function strictFunction(int $n): int
{
return $n * 2;
}
```
```php
<?php
// file: src/NonStrict.php
// No declare - weak mode
require_once 'Strict.php';
// Calls from weak mode file still coerce
strictFunction("5"); // Returns 10 - coercion happens at call site
```
## Return Type Enforcement
```php
<?php
declare(strict_types=1);
// Return type strictly enforced
function getPrice(): float
{
return 99.99; // Must return float
}
function getCount(): int
{
return 42; // Must return int
}
// This would cause TypeError
function broken(): int
{
return "42"; // TypeError - can't return string as int
}
```
## With Nullable Types
```php
<?php
declare(strict_types=1);
function findUser(int $id): ?User
{
// Must return User or null, nothing else
return User::find($id);
}
function process(?string $data): void
{
// $data must be string or null
}
process("hello"); //
process(null); //
process(123); // TypeError
```
## With Union Types
```php
<?php
declare(strict_types=1);
function format(string|int $value): string
{
return (string) $value;
}
format("hello"); //
format(42); //
format(3.14); // TypeError - float not in union
```
## Best Practice Template
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\Contracts\RepositoryInterface;
use App\Models\User;
use App\Exceptions\UserNotFoundException;
final class UserService
{
public function __construct(
private readonly RepositoryInterface $repository,
) {}
public function findById(int $id): User
{
$user = $this->repository->find($id);
if ($user === null) {
throw new UserNotFoundException($id);
}
return $user;
}
public function create(array $data): User
{
return $this->repository->create($data);
}
/**
* @param array<int> $ids
* @return array<User>
*/
public function findMany(array $ids): array
{
return $this->repository->findMany($ids);
}
}
```
## IDE/Static Analysis
```php
<?php
declare(strict_types=1);
// PHPStan/Psalm will catch type errors even more strictly
// Combined with strict_types, you get maximum type safety
/** @var positive-int $count */
$count = getCount();
/** @var non-empty-string $name */
$name = getName();
```
## Why
- **Type Safety**: Catches type bugs at runtime immediately
- **No Surprises**: No silent type coercion
- **Static Analysis**: Works with PHPStan/Psalm for maximum safety
- **Self-Documenting**: Code intent is explicit and enforced
- **Industry Standard**: Required for reliable modern PHP
rules/type-union-types.md
---
title: Union Types
impact: HIGH
impactDescription: Provides type precision without using mixed
tags: type-system, union-types, type-safety, php8
---
# Union Types
Use union types when a value can legitimately be one of multiple types (PHP 8.0+).
## Bad Example
```php
<?php
declare(strict_types=1);
class ConfigLoader
{
/**
* @param string|array $source
* @return mixed
*/
public function load($source)
{
// No type enforcement, relies on docblock
if (is_string($source)) {
return $this->loadFromFile($source);
}
return $this->loadFromArray($source);
}
// Using mixed when specific types are known
public function getValue(string $key): mixed
{
return $this->config[$key] ?? null;
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class ConfigLoader
{
public function load(string|array $source): array
{
if (is_string($source)) {
return $this->loadFromFile($source);
}
return $this->loadFromArray($source);
}
public function getValue(string $key): string|int|bool|null
{
return $this->config[$key] ?? null;
}
}
// More examples of union types
class Response
{
public function setContent(string|Stringable $content): self
{
$this->content = (string) $content;
return $this;
}
}
class Logger
{
public function log(string|Stringable|array $message): void
{
if (is_array($message)) {
$message = json_encode($message, JSON_THROW_ON_ERROR);
}
$this->write((string) $message);
}
}
```
## Why
- **Type Precision**: Explicitly declares all acceptable types
- **Better Than Mixed**: More specific than mixed, enabling better analysis
- **Flexibility**: Allows legitimate polymorphic behavior with type safety
- **PHP 8 Feature**: Native language support replaces docblock annotations
- **IDE Support**: Full autocompletion and error detection
- **Self-Documenting**: Union types clearly communicate accepted values
rules/type-void-never.md
---
title: Void and Never Return Types
impact: HIGH
impactDescription: Clarifies method intent, enables better static analysis
tags: type-system, void, never, return-types, php71, php81
---
# Void and Never Return Types
Use void for methods that don't return a value, and never for methods that always throw or exit.
## Bad Example
```php
<?php
declare(strict_types=1);
class EventDispatcher
{
// No return type - unclear if something is returned
public function dispatch(Event $event)
{
foreach ($this->listeners as $listener) {
$listener->handle($event);
}
}
}
class ExceptionHandler
{
// Returns void but actually never returns
public function handleFatal(Throwable $e): void
{
$this->logger->critical($e->getMessage());
exit(1);
}
// Throws but return type doesn't indicate this
public function abort(int $code): void
{
throw new HttpException($code);
}
}
```
## Good Example
```php
<?php
declare(strict_types=1);
class EventDispatcher
{
// Explicit void - method performs action, returns nothing
public function dispatch(Event $event): void
{
foreach ($this->listeners as $listener) {
$listener->handle($event);
}
}
public function subscribe(string $event, callable $listener): void
{
$this->listeners[$event][] = $listener;
}
public function unsubscribe(string $event, callable $listener): void
{
$this->listeners[$event] = array_filter(
$this->listeners[$event] ?? [],
fn($l) => $l !== $listener
);
}
}
class ExceptionHandler
{
// Never type - method never returns normally (PHP 8.1+)
public function handleFatal(Throwable $e): never
{
$this->logger->critical($e->getMessage());
$this->renderErrorPage($e);
exit(1);
}
// Never type - always throws
public function abort(int $code, string $message = ''): never
{
throw new HttpException($code, $message);
}
// Never type - infinite loop
public function runDaemon(): never
{
while (true) {
$this->processQueue();
sleep(1);
}
}
}
// Static analysis understands code after never call is unreachable
function processOrFail(mixed $data): array
{
if (!is_array($data)) {
throw new \InvalidArgumentException('Expected array'); // never returns
}
// Static analysis knows $data is array here
return $data;
}
```
## Why
- **Clear Intent**: void shows method performs side effects without returning
- **Never Semantics**: never tells analyzers code after call is unreachable
- **Better Analysis**: Static analysis uses never for reachability analysis
- **Explicit Contract**: Distinguishes "returns nothing" from "never returns"
- **Documentation**: Self-documenting method behavior
- **Type Narrowing**: never enables better type inference after calls
SKILL.md
---
name: php-best-practices
description: PHP 8.x modern patterns, PSR standards, and SOLID principles. Use when reviewing PHP code, checking type safety, auditing code quality, or ensuring PHP best practices. Triggers on "review PHP", "check PHP code", "audit PHP", or "PHP best practices".
license: MIT
metadata:
author: php-community
version: "2.1.0"
phpVersion: "8.0 - 8.5"
---
# PHP Best Practices
Modern PHP 8.x patterns, PSR standards, type system best practices, and SOLID principles. Contains 51 rules for writing clean, maintainable PHP code.
## Step 1: Detect PHP Version
**Always check the project's PHP version before giving any advice.** Features vary significantly across 8.0 - 8.5. Never suggest syntax that doesn't exist in the project's version.
Check `composer.json` for the required PHP version:
```json
{ "require": { "php": "^8.1" } } // -> 8.1 rules and below
{ "require": { "php": "^8.3" } } // -> 8.3 rules and below
{ "require": { "php": ">=8.4" } } // -> 8.4 rules and below
```
Also check the runtime version:
```bash
php -v # e.g. PHP 8.3.12
```
### Feature Availability by Version
| Feature | Version | Rule Prefix |
|---------|---------|-------------|
| Union types, match, nullsafe, named args, constructor promotion, attributes | 8.0+ | `type-`, `modern-` |
| Enums, readonly properties, intersection types, first-class callables, never, fibers | 8.1+ | `modern-` |
| Readonly classes, DNF types, true/false/null standalone types | 8.2+ | `modern-` |
| Typed class constants, `#[\Override]`, `json_validate()` | 8.3+ | `modern-` |
| Property hooks, asymmetric visibility, `#[\Deprecated]`, `new` without parens | 8.4+ | `modern-` |
| Pipe operator `|>` | 8.5+ | `modern-` |
**Only suggest features available in the detected version.** If the user asks about upgrading or newer features, mention what becomes available at each version.
## When to Apply
Reference these guidelines when:
- Writing or reviewing PHP code
- Implementing classes and interfaces
- Using PHP 8.x modern features
- Ensuring type safety
- Following PSR standards
- Applying design patterns
## Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|----------|----------|--------|--------|-------|
| 1 | Type System | CRITICAL | `type-` | 9 |
| 2 | Modern PHP Features | CRITICAL | `modern-` | 16 |
| 3 | PSR Standards | HIGH | `psr-` | 6 |
| 4 | SOLID Principles | HIGH | `solid-` | 5 |
| 5 | Error Handling | HIGH | `error-` | 5 |
| 6 | Performance | MEDIUM | `perf-` | 5 |
| 7 | Security | CRITICAL | `sec-` | 5 |
## Quick Reference
### 1. Type System (CRITICAL) — 9 rules
- `type-strict-mode` - Declare strict types in every file
- `type-return-types` - Always declare return types
- `type-parameter-types` - Type all parameters
- `type-property-types` - Type class properties
- `type-union-types` - Use union types effectively
- `type-intersection-types` - Use intersection types
- `type-nullable-types` - Handle nullable types properly
- `type-void-never` - Use void/never for appropriate return types
- `type-mixed-avoid` - Avoid mixed type when possible
### 2. Modern PHP Features (CRITICAL) — 16 rules
**8.0+:**
- `modern-constructor-promotion` - Constructor property promotion
- `modern-match-expression` - Match over switch
- `modern-named-arguments` - Named arguments for clarity
- `modern-nullsafe-operator` - Nullsafe operator (?->)
- `modern-attributes` - Attributes for metadata
**8.1+:**
- `modern-enums` - Enums instead of constants
- `modern-enums-methods` - Enums with methods and interfaces
- `modern-readonly-properties` - Readonly for immutable data
- `modern-first-class-callables` - First-class callable syntax
- `modern-arrow-functions` - Arrow functions (7.4+, pairs well with 8.1 features)
**8.2+:**
- `modern-readonly-classes` - Readonly classes
**8.3+:**
- `modern-typed-constants` - Typed class constants (`const string NAME = 'foo'`)
- `modern-override-attribute` - `#[\Override]` to catch parent method typos
**8.4+:**
- `modern-property-hooks` - Property hooks replacing getters/setters
- `modern-asymmetric-visibility` - `public private(set)` for controlled access
**8.5+:**
- `modern-pipe-operator` - Pipe operator (`|>`) for functional chaining
### 3. PSR Standards (HIGH) — 6 rules
- `psr-4-autoloading` - Follow PSR-4 autoloading
- `psr-12-coding-style` - Follow PSR-12 coding style
- `psr-naming-classes` - Class naming conventions
- `psr-naming-methods` - Method naming conventions
- `psr-file-structure` - One class per file
- `psr-namespace-usage` - Proper namespace usage
### 4. SOLID Principles (HIGH) — 5 rules
- `solid-srp` - Single Responsibility: one reason to change
- `solid-ocp` - Open/Closed: extend, don't modify
- `solid-lsp` - Liskov Substitution: subtypes must be substitutable
- `solid-isp` - Interface Segregation: small, focused interfaces
- `solid-dip` - Dependency Inversion: depend on abstractions
### 5. Error Handling (HIGH) — 5 rules
- `error-custom-exceptions` - Create specific exceptions for different errors
- `error-exception-hierarchy` - Organize exceptions into meaningful hierarchy
- `error-try-catch-specific` - Catch specific exceptions, not generic \Exception
- `error-finally-cleanup` - Use finally for guaranteed resource cleanup
- `error-never-suppress` - Never use @ error suppression operator
### 6. Performance (MEDIUM) — 5 rules
- `perf-avoid-globals` - Avoid global variables, use dependency injection
- `perf-lazy-loading` - Defer expensive operations until needed
- `perf-array-functions` - Use native array functions over manual loops
- `perf-string-functions` - Use native string functions over regex
- `perf-generators` - Use generators for large datasets
### 7. Security (CRITICAL) — 5 rules
- `sec-input-validation` - Validate and sanitize all external input
- `sec-output-escaping` - Escape output based on context (HTML, JS, URL)
- `sec-password-hashing` - Use password_hash/verify, never MD5/SHA1
- `sec-sql-prepared` - Use prepared statements for all SQL queries
- `sec-file-uploads` - Validate file type, size, name; store outside web root
## Essential Guidelines
For detailed examples and explanations, see the rule files:
- [type-strict-mode.md](rules/type-strict-mode.md) - Strict types declaration
- [modern-constructor-promotion.md](rules/modern-constructor-promotion.md) - Constructor property promotion
- [modern-enums.md](rules/modern-enums.md) - PHP 8.1+ enums with methods
- [solid-srp.md](rules/solid-srp.md) - Single responsibility principle
### Key Patterns (Quick Reference)
```php
<?php
declare(strict_types=1);
// 8.0+ Constructor promotion + readonly (8.1+)
class User
{
public function __construct(
public readonly string $id,
private string $email,
) {}
}
// 8.1+ Enums with methods
enum Status: string
{
case Active = 'active';
case Inactive = 'inactive';
public function label(): string
{
return match($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
};
}
}
// 8.0+ Match expression
$result = match($status) {
'pending' => 'Waiting',
'active' => 'Running',
default => 'Unknown',
};
// 8.0+ Nullsafe operator
$country = $user?->getAddress()?->getCountry();
// 8.3+ Typed class constants + #[\Override]
class PaymentService extends BaseService
{
public const string GATEWAY = 'stripe';
#[\Override]
public function process(): void { /* ... */ }
}
// 8.4+ Property hooks + asymmetric visibility
class Product
{
public string $name { set => trim($value); }
public private(set) float $price;
}
// 8.5+ Pipe operator
$result = $input
|> trim(...)
|> strtolower(...)
|> htmlspecialchars(...);
```
## Output Format
When auditing code, output findings in this format:
```
file:line - [category] Description of issue
```
Example:
```
src/Services/UserService.php:15 - [type] Missing return type declaration
src/Models/Order.php:42 - [modern] Use match expression instead of switch
src/Controllers/ApiController.php:28 - [solid] Class has multiple responsibilities
```
## How to Use
Read individual rule files for detailed explanations:
```
rules/modern-constructor-promotion.md
rules/type-strict-mode.md
rules/solid-srp.md
```