
Php Best Practices
- 2.5k installs
- 58 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
php-best-practices is a PHP 8.x audit skill with 51 rules for types, PSR, SOLID, security, and performance.
About
The php-best-practices skill applies modern PHP 8.x patterns, PSR standards, SOLID principles, and security guidance through 51 rules grouped by type system, modern features, PSR compliance, SOLID, error handling, performance, and security priorities. Step one detects the project PHP version from composer.json require php constraints and runtime php -v output before suggesting syntax because features differ from 8.0 through 8.5 including enums, readonly classes, property hooks, and the 8.5 pipe operator. Type rules demand strict_types, return types, parameter and property types, union and intersection types, and avoiding mixed. Modern rules promote constructor promotion, match expressions, nullsafe operators, enums with methods, readonly properties, typed constants, Override attributes, and property hooks when version allows. PSR coverage spans PSR-4 autoloading, PSR-12 style, naming, and one class per file. Security rules require prepared SQL, password_hash, input validation, contextual output escaping, and safe upload handling. Audits output file:line category descriptions such as missing return types or deprecated patterns.
- Detect PHP version from composer.json and php -v before suggesting syntax.
- 51 rules span type-, modern-, psr-, solid-, error-, perf-, and sec- prefixes.
- Feature table maps union types, enums, property hooks, and pipe operator versions.
- Security rules mandate prepared statements, password_hash, and output escaping.
- Audit output uses file:line - [category] issue description format.
Php Best Practices by the numbers
- 2,466 all-time installs (skills.sh)
- +109 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #7 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
php-best-practices capabilities & compatibility
- Capabilities
- version aware feature availability matrix for ph · type system and modern php feature rule catalog · psr autoloading, style, and naming enforcement g · solid and error handling review checkpoints · security and performance rule quick reference wi
- Use cases
- code review · security audit · refactoring
What php-best-practices says it does
Always check the project's PHP version before giving any advice.
Contains 51 rules for writing clean, maintainable PHP code.
declare(strict_types=1);
npx skills add https://github.com/asyrafhussin/agent-skills --skill php-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.5k |
|---|---|
| repo stars | ★ 58 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
How do I review PHP code for modern 8.x patterns, type safety, and security issues?
Audit PHP 8.x code against 51 typed, PSR, SOLID, security, and performance rules matched to the project PHP version.
Who is it for?
PHP 8.0 through 8.5 projects needing structured code review against community rules.
Skip if: Skip for non-PHP stacks or greenfield framework selection without PHP source to audit.
When should I use this skill?
User asks to review PHP, check PHP code, audit PHP, or apply PHP best practices.
What you get
A version-aware audit listing file:line findings across type, modern, PSR, SOLID, and security categories.
- PHP quality audit report
- PSR compliance notes
- Type-safety recommendations
By the numbers
- Contains 51 rules for PHP code quality
- Supports PHP 8.0 through 8.5
- Version 2.1.0
Files
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:
{ "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 belowAlso check the runtime version:
php -v # e.g. PHP 8.3.12Feature 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+ |
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 filetype-return-types- Always declare return typestype-parameter-types- Type all parameterstype-property-types- Type class propertiestype-union-types- Use union types effectivelytype-intersection-types- Use intersection typestype-nullable-types- Handle nullable types properlytype-void-never- Use void/never for appropriate return typestype-mixed-avoid- Avoid mixed type when possible
2. Modern PHP Features (CRITICAL) — 16 rules
8.0+:
modern-constructor-promotion- Constructor property promotionmodern-match-expression- Match over switchmodern-named-arguments- Named arguments for claritymodern-nullsafe-operator- Nullsafe operator (?->)modern-attributes- Attributes for metadata
8.1+:
modern-enums- Enums instead of constantsmodern-enums-methods- Enums with methods and interfacesmodern-readonly-properties- Readonly for immutable datamodern-first-class-callables- First-class callable syntaxmodern-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/settersmodern-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 autoloadingpsr-12-coding-style- Follow PSR-12 coding stylepsr-naming-classes- Class naming conventionspsr-naming-methods- Method naming conventionspsr-file-structure- One class per filepsr-namespace-usage- Proper namespace usage
4. SOLID Principles (HIGH) — 5 rules
solid-srp- Single Responsibility: one reason to changesolid-ocp- Open/Closed: extend, don't modifysolid-lsp- Liskov Substitution: subtypes must be substitutablesolid-isp- Interface Segregation: small, focused interfacessolid-dip- Dependency Inversion: depend on abstractions
5. Error Handling (HIGH) — 5 rules
error-custom-exceptions- Create specific exceptions for different errorserror-exception-hierarchy- Organize exceptions into meaningful hierarchyerror-try-catch-specific- Catch specific exceptions, not generic \Exceptionerror-finally-cleanup- Use finally for guaranteed resource cleanuperror-never-suppress- Never use @ error suppression operator
6. Performance (MEDIUM) — 5 rules
perf-avoid-globals- Avoid global variables, use dependency injectionperf-lazy-loading- Defer expensive operations until neededperf-array-functions- Use native array functions over manual loopsperf-string-functions- Use native string functions over regexperf-generators- Use generators for large datasets
7. Security (CRITICAL) — 5 rules
sec-input-validation- Validate and sanitize all external inputsec-output-escaping- Escape output based on context (HTML, JS, URL)sec-password-hashing- Use password_hash/verify, never MD5/SHA1sec-sql-prepared- Use prepared statements for all SQL queriessec-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 - Strict types declaration
- modern-constructor-promotion.md - Constructor property promotion
- modern-enums.md - PHP 8.1+ enums with methods
- solid-srp.md - Single responsibility principle
Key Patterns (Quick Reference)
<?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 issueExample:
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 responsibilitiesHow to Use
Read individual rule files for detailed explanations:
rules/modern-constructor-promotion.md
rules/type-strict-mode.md
rules/solid-srp.mdPHP 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.
# Check composer.json
grep '"php"' composer.json # e.g. "^8.2"
# Check runtime
php -v # e.g. PHP 8.3.12Only 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
// No strict types
function add(int $a, int $b): int {
return $a + $b;
}
add("5", "10"); // Returns 15 - strings coerced silentlyGood:
<?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
class UserRepository {
public function find($id) {
return $this->db->query("SELECT * FROM users WHERE id = ?", [$id]);
}
}Good:
<?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
function createOrder($user, $items) {
// What is $user? What format should $items be?
}Good:
<?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
class Product {
private $id;
private $name;
private $price;
}Good:
<?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
class DataLoader {
/**
* @param string|array $source
*/
public function load($source) {
// Relies on docblock, no type enforcement
}
}Good:
<?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
class UserService {
public function findByEmail(string $email) {
// Unclear if null is valid return
return $this->repository->find($email) ?: null;
}
}Good:
<?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
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
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
class OrderStatus {
public const PENDING = 'pending';
public const SHIPPED = 'shipped';
}
function updateStatus(string $status): void {
// 'invalid' would be accepted
}Good:
<?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
class Invoice {
private string $invoiceNumber;
// Setter allows modification after creation
public function setInvoiceNumber(string $number): void {
$this->invoiceNumber = $number;
}
}Good:
<?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
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
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
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
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
$doubled = array_map(function ($n) {
return $n * 2;
}, $numbers);
$multiplier = 3;
$multiplied = array_map(function ($n) use ($multiplier) {
return $n * $multiplier;
}, $numbers);Good:
<?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
class Config {
public const TIMEOUT = 30; // int? string? no enforcement
public const NAME = 'my-app';
}Good:
<?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
class UserRepo extends BaseRepo {
public function findByld(int $id): ?User { /* typo: 'l' not 'I' */ }
}Good:
<?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
class User {
private string $name;
public function getName(): string { return $this->name; }
public function setName(string $v): void { $this->name = ucfirst($v); }
}Good:
<?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
class Order {
private string $status;
public function getStatus(): string { return $this->status; }
}Good:
<?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
$result = htmlspecialchars(strtolower(trim($input)));Good:
<?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\UserServicecomposer.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
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
class User {
// Multiple responsibilities
public function save(): void { /* DB */ }
public function sendEmail(): void { /* Email */ }
public function toJson(): string { /* Serialization */ }
}Good:
<?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
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
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
class OrderService {
private MySqlDatabase $db;
public function __construct() {
$this->db = new MySqlDatabase();
}
}Good:
<?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
throw new \Exception('Email already exists'); // Caller can't distinguish error typesGood:
<?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
try {
$user = $repo->find($id);
$mailer->send($user);
} catch (\Exception $e) {
return null; // Swallows ALL errors - hides bugs
}Good:
<?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
$data = @file_get_contents($path); // Hides all errors
$conn = @mysqli_connect('localhost', 'user', 'pass');Good:
<?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
function getAllUsers(): array {
return $stmt->fetchAll(); // 1M users = huge memory spike
}Good:
<?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
if (preg_match('/^https/', $url)) { /* starts with */ }
if (preg_match('/\.pdf$/', $file)) { /* ends with */ }Good:
<?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
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
$sql = "SELECT * FROM users WHERE email = '{$email}'";
$result = $db->query($sql); // SQL injection vulnerableGood:
<?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
$hash = md5($password); // Insecure
$hash = sha1($password); // InsecureGood:
<?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
- PHP 8.3 Release
- PSR Standards
- PHPStan - Static Analysis
- Psalm - Static Analysis
- PHP The Right Way
---
Last Updated: March 2026 Version: 2.1.0 License: MIT
{
"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+)"
]
}
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
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.
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
declare(strict_types=1);
// Bad code example here
// Show common anti-patterns
class BadExample
{
// Demonstrate the problem
}Good Example
<?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 | PSR Standards
Custom Exceptions
Create specific exception classes for different error scenarios instead of using generic exceptions.
Bad Example
<?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
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
Exception Hierarchy
Organize exceptions into a meaningful hierarchy so callers can catch at different levels of specificity.
Bad Example
<?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
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
\RuntimeExceptionfor errors that can't be recovered from programmatically
Finally for Cleanup
Use finally blocks to guarantee cleanup code runs whether an exception occurs or not.
Bad Example
<?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
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:
finallyruns 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
Never Suppress Errors
Never use the @ error suppression operator. Handle errors explicitly instead.
Bad Example
<?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
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
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
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
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: $ewhen re-throwing to keep the full chain
Arrow Functions
Use arrow functions for short, single-expression closures (PHP 7.4+).
Bad Example
<?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
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); // 15Why
- 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
Asymmetric Visibility
Use asymmetric visibility to allow public reading but restrict writing (PHP 8.4+).
Bad Example
<?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(); // VerboseGood Example
<?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 outsideWhy
- Clean Public API:
$order->statusinstead 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)orprotected(set)for write access - Pairs with Hooks: Combine with property hooks for validated writes
PHP Attributes
Use native attributes for metadata instead of docblock annotations (PHP 8.0+).
Bad Example
<?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
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
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
// 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
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
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
// 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
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 $idfor immutable promoted properties (8.1+)
Enum Methods
Add methods to enums to encapsulate related behavior and logic.
Bad Example
<?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
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
$thisfor 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
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
// 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
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::SouthBacked Enum (String or Int)
<?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
// 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
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
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
// 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'); // TypeErrorIn Eloquent/Database
<?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
First-Class Callable Syntax
Use first-class callable syntax to create closures from callables (PHP 8.1+).
Bad Example
<?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
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 ofClosure::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
Match Expression
Use match expressions instead of switch statements for cleaner, safer code (PHP 8.0+).
Bad Example
<?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
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
Named Arguments
Use named arguments for clarity and flexibility (PHP 8.0+).
Bad Example
<?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
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
Nullsafe Operator
Use the nullsafe operator for cleaner null checking chains (PHP 8.0+).
Bad Example
<?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
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
Override Attribute
Use #[\Override] on methods that override a parent method (PHP 8.3+).
Bad Example
<?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
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
Pipe Operator
Use the pipe operator (|>) for readable function chaining (PHP 8.5+).
Bad Example
<?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
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:
// 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 tofn($x)— wrap in closures for multi-argument functions - Pairs with First-Class Callables:
trim(...)syntax works naturally with|>
Property Hooks
Use property hooks to define get/set logic directly on properties (PHP 8.4+).
Bad Example
<?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
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->propinstead of$obj->getProp() - Constructor Compatible: Works with constructor property promotion
Readonly Classes
Use readonly classes when all properties should be immutable (PHP 8.2+).
Bad Example
<?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
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
Readonly Properties
Use readonly properties for immutable data that should only be set once (PHP 8.1+).
Bad Example
<?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
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
Typed Class Constants
Use typed class constants to enforce type safety on constant values (PHP 8.3+).
Bad Example
<?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
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
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
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
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
Avoid Global Variables
Never use global variables or the global keyword. Use dependency injection instead.
Bad Example
<?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 timeGood Example
<?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
Generators for Large Datasets
Use generators (yield) to process large datasets without loading everything into memory.
Bad Example
<?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
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
Lazy Loading
Load resources and perform expensive operations only when they are actually needed, not at construction time.
Bad Example
<?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
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
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
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
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 thanpreg_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_containsreplace common regex patterns
PSR-12 Coding Style
Follow PSR-12 extended coding style for consistent, readable code.
Bad Example
<?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
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
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
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
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
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
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_varwith FILTER_VALIDATE_INT returns false for non-integers - Defense in Depth: Validate at every boundary, not just the frontend
Related skills
How it compares
Use php-best-practices for opinionated PSR and SOLID review guidance when a human-readable audit checklist is needed alongside or before running PHPStan or Psalm.
FAQ
Why must PHP version be detected first?
Features like enums, readonly classes, property hooks, and pipe operator differ across 8.0 to 8.5.
How many rules does the skill include?
51 rules across seven priority categories from type system through security.
What format do audit findings use?
file:line - [category] Description, for example a missing return type under [type].
Is Php Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.