PHP
Strict types, explicit contracts, no magic. If a class needs a docblock to explain what its properties do, the properties are named wrong.
PHP 8.5+ is the baseline. Use modern syntax unconditionally — union types, enums, readonly classes, property hooks,
named arguments, match, pipe operator. No backward compatibility with older PHP versions unless the project explicitly
requires it.
Every PHP file starts with declare(strict_types=1).
References
- Type system →
${CLAUDE_SKILL_DIR}/references/typing.md— Union/intersection/DNF types, nullable patterns, typed properties and constants, coercion rules, variance - OOP patterns →
${CLAUDE_SKILL_DIR}/references/oop.md— Interfaces, traits, readonly, property hooks, enums, constructor promotion, lazy objects, magic methods - Concurrency →
${CLAUDE_SKILL_DIR}/references/concurrency.md— Fiber API, generator coroutines, comparison table, async library guidance - Packaging →
${CLAUDE_SKILL_DIR}/references/packaging.md— composer.json templates, version constraints, project layouts, namespace conventions
Naming
Classes, interfaces, traits, enums — PascalCase:
UserService,Renderable,StatusMethods, functions — camelCase:
findById,getFullNameProperties, variables — camelCase:
$userName,$isActiveConstants (class and global) — UPPER_SNAKE_CASE:
MAX_RETRIES,DEFAULT_LOCALENamespaces — PascalCase segments:
App\Http\ControllerEnum cases — PascalCase:
Status::Active,Suit::HeartsDescriptive names.
$userCountnot$n. Short names ($i,$k,$v) only in tiny scopes (loops, array operations).No redundant context.
$car->makenot$car->carMake.Boolean names:
is/has/can/shouldprefix:$isValid,$hasAccess.Abbreviations as words.
HttpClientnotHTTPClient,JsonParsernotJSONParser. Treat abbreviations and acronyms as regular words — uppercase first letter only (PER-CS).No underscore prefix for protected/private visibility. Visibility modifiers exist for that.
Type Declarations
PHP 8.5+ provides a complete type system. Use it everywhere.
Core Rules
- Type all public API boundaries — function parameters, return types, class properties, class constants. Internal/private code benefits from types too.
declare(strict_types=1)in every file. No exceptions.- Short type names:
bool,int,float,string. Neverboolean,integer,double. - Union types with
|:string|int,Foo|null. Prefer?Tfor single-type nullable. - Intersection types with
&:Countable&Traversable. Class/interface types only. - DNF types:
array|(ArrayAccess&Traversable)— union of intersections in parentheses. voidreturn: annotate on functions that return nothing.neverreturn: functions that always throw or exit.- Avoid
mixed— it disables type safety. Useobjectwhen you mean "any object." Usemixedonly at true interop boundaries with untyped code. nulllast in unions:string|int|null, notnull|string|int.
Typed Properties
- Every class property gets a type declaration.
- Typed properties must be initialized before access — use constructor promotion, default values, or constructor assignment.
callablecannot be used as a property type. UseClosureinstead.
Typed Constants (8.3+)
class Config
{
public const int MAX_RETRIES = 3;
public const string DEFAULT_LOCALE = 'en';
protected const float TAX_RATE = 0.21;
}
- Type all class constants. Interface constants especially — they enforce the contract at compile time.
Variance
- Parameters are contravariant — child class can accept wider types.
- Return types are covariant — child class can return narrower types.
mixedreturn can be narrowed to any type in a subclass.
See ${CLAUDE_SKILL_DIR}/references/typing.md for the complete type system reference.
Enumerations
- Use enums for categorical constants. Never bare strings or ints as pseudo-enums.
- Backed enums (
stringorint) when the value must interoperate with external systems (JSON, database, API). from()throws on invalid value;tryFrom()returnsnull. Choose based on whether invalid input is a caller error or expected.- Enums can implement interfaces and define methods. Use this for behavior tied to the enum's domain.
- Enums cannot have state (no properties), cannot be extended, cannot be
new'd. - Dynamic access:
Status::{$name}(8.3+) for variable-based case resolution.
enum Status: string
{
case Active = 'active';
case Inactive = 'inactive';
case Suspended = 'suspended';
public function label(): string
{
return match ($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
self::Suspended => 'Suspended',
};
}
}
Classes
Properties and Visibility
- Explicit visibility on everything — properties, methods, constants.
- Constructor promotion for data-carrying classes:
class User { public function __construct( public readonly string $name, private string $email, protected int $age = 0, ) {} } - Readonly properties (8.1+) for immutable state. Must have a type declaration.
- Readonly classes (8.2+) — all properties implicitly readonly, no dynamic properties.
- Asymmetric visibility (8.4+) —
public protected(set)for publicly readable, internally writable properties. - Property hooks (8.4+) —
get/setlogic on properties. Use instead of trivial getter/setter methods. Incompatible withreadonly.
Inheritance and Composition
- Composition over inheritance. Use inheritance only for true "is-a" relationships.
- Interfaces for contracts. All interface methods are public. As of 8.4, interfaces can declare property requirements.
- Abstract classes when you need shared implementation alongside a contract.
- Traits for horizontal reuse. Never use traits as a substitute for interfaces. One
usestatement per trait, each on its own line. #[Override](8.3+) on every method that overrides a parent or implements an interface method. Catches signature drift at compile time.super()equivalent: always useparent::method(). Never hardcode grandparent class names.
Magic Methods
- Avoid property overloading (
__get,__set) in new code. Typed properties with hooks are strictly better. __toString()— define when string conversion has meaningful semantics.__invoke()— for single-method objects that act as callables.__serialize()/__unserialize()— prefer over__sleep()/__wakeup().
Object Patterns
- Value objects —
readonly classwith constructor promotion. Immutable by default. - DTOs — readonly classes with public properties. No behavior.
- Service classes — constructor injection for dependencies, no public state.
- Lazy objects (8.4+) — defer initialization via
ReflectionClass::newLazyGhost().
Functions
- Early return. Guard clauses first, happy path flat. Reduce nesting.
- One function, one job. If the name contains "and", split it.
- Type all parameters and return types on public functions.
- Named arguments for functions with boolean flags or many optional parameters:
createUser(name: 'John', admin: true). matchoverswitch—matchis an expression, uses strict comparison, and does not fall through.- Pipe operator (8.5+) for functional chaining:
$result = $input |> trim(...) |> strtolower(...) |> ucfirst(...); - First-class callables with
...syntax:array_map(strlen(...), $strings). - Arrow functions for short closures:
fn($x) => $x * 2. Arrow functions capture by value, not by reference. - Closures for multi-statement callbacks. Use
useto capture outer variables. Preferstatic function/static fnwhen$thisis not needed. - Closures in constants (8.5+) — static closures and first-class callables are valid in constant expressions, default values, and attributes.
Error Handling
- Be specific. Catch the narrowest exception type:
catch (InvalidArgumentException)notcatch (Exception). - Never bare
catch (\Throwable)at arbitrary depths. Use at application boundaries only (controllers, CLI entry points, queue workers). throwis an expression (8.0+):$value ?? throw new InvalidArgumentException().- Chain exceptions.
throw new AppException('context', previous: $e)preserves the original cause. - Custom exception hierarchy:
class AppException extends \RuntimeException {} class NotFoundException extends AppException {} class ValidationException extends AppException {} - Prefer
\RuntimeExceptionsubtree for application errors.\LogicExceptionsubtree for programming errors (wrong arguments, unimplemented methods). - Error strings: lowercase, no trailing punctuation. They compose in chains:
"user not found: invalid ID format". #[Deprecated]attribute (8.4+) on functions, methods, and constants to emitE_USER_DEPRECATEDwhen called.#[NoDiscard]attribute (8.5+) on functions whose return value must be consumed. Use(void)cast to intentionally suppress.finallyfor unconditional cleanup. But prefer RAII-style patterns (destructors, resource wrappers) when possible.
Strings
- Double-quoted interpolation for simple variables:
"Hello, {$name}". sprintf()for complex formatting:sprintf('Item %d: %s', $id, $name).- Heredoc for multiline strings. Nowdoc (
<<<'EOT') when no interpolation needed. str_contains(),str_starts_with(),str_ends_with()(8.0+) — neverstrposwith=== falsefor substring checks.mb_trim(),mb_ltrim(),mb_rtrim()(8.4+) for multibyte-safe trimming.mb_ucfirst(),mb_lcfirst()(8.4+) for multibyte-safe case conversion."".join()equivalent:implode(', ', $parts)for building strings from arrays.
Arrays
- Short syntax:
$arr = [1, 2, 3]. Neverarray(). array_map(),array_filter(),array_reduce()for functional transforms.array_find(),array_find_key()(8.4+) — find first element matching a callback.array_any(),array_all()(8.4+) — existence/universal checks.array_first(),array_last()(8.5+) — get first/last value without resetting the internal pointer.- Spread operator for merging:
$merged = [...$defaults, ...$overrides]. - Trailing commas on multi-line arrays (last element gets a comma).
- Destructuring:
[$first, $second] = $arrayor['key' => $value] = $assoc.
Match Expression
$result = match ($status) {
Status::Active => 'active',
Status::Inactive, Status::Suspended => 'inactive',
default => throw new \UnexpectedValueException("Unknown status: {$status->value}"),
};
matchis an expression — it returns a value. Use instead ofswitch.- Strict comparison (
===) — no type coercion. - No fallthrough — each arm is isolated.
- Multiple conditions per arm with commas.
- Exhaustiveness — always include
defaultunless provably exhaustive. Unmatched value throwsUnhandledMatchError.
Closures and Callables
- First-class callable syntax (8.1+):
strlen(...),$obj->method(...),ClassName::method(...). - Arrow functions (
fn) for single-expression closures — auto-captures by value. - Static closures (
static fn,static function) when$thisis not needed — saves memory, prevents accidental binding. Closure::bind()andClosure::fromCallable()for advanced callable manipulation.- Type hint callables as
Closurein property types (notcallable).
Packaging and Toolchain
Composer
composer.jsonis the single source of truth for project metadata, dependencies, autoloading, and scripts.- Caret
^constraints for dependencies:"vendor/package": "^2.0". - Lock file: commit for applications, skip for libraries.
- PSR-4 autoloading: map namespace prefixes to directories.
- Separate
autoload-devfor test namespaces.
Project Structure
my-project/
├── composer.json
├── composer.lock
├── src/
│ └── ... (PSR-4: App\)
├── tests/
│ ├── Unit/
│ ├── Integration/
│ └── bootstrap.php
├── config/
├── public/
│ └── index.php
└── var/
├── cache/
└── log/
src/— application code, one class per file, PSR-4 mapped.tests/— mirrorssrc/structure withUnit/andIntegration/separation.public/— web root, single entry point (index.php).var/— generated files (cache, logs). Git-ignored.vendor/— Composer dependencies. Git-ignored.
File Header
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Repository\UserRepository;
use Psr\Log\LoggerInterface;
Order: <?php tag, blank line, declare(strict_types=1), blank line, namespace, blank line, use imports (classes,
then functions, then constants), blank line, code. No leading backslash on imports.
See ${CLAUDE_SKILL_DIR}/references/packaging.md for composer.json templates and PSR-4 mapping.
Formatting (PER-CS)
PER Coding Style is the baseline. These are conventions, not tool configuration.
- 4-space indentation. No tabs.
- Opening braces on their own line for classes, interfaces, traits, enums, methods.
- Opening braces on the same line for control structures (
if,for,while, etc.). - One statement per line. No multi-statement lines.
- Soft line limit: 120 characters. Prefer 80 for readability.
- Trailing commas on multi-line argument lists, arrays,
matcharms,uselists. - No trailing commas on single-line constructs.
- Visibility on everything — properties, methods, constants.
- Modifier order:
abstract/final, visibility,static,readonly. new Foo()— always use parentheses when instantiating (even without arguments), unless immediately chaining:new Foo()->method().- Compound types: no spaces around
|and&. Parentheses for DNF without internal spaces. - Empty exception classes on one line:
class NotFoundException extends AppException {}
Application
When writing PHP code: apply all conventions silently — don't narrate each rule. If an existing codebase contradicts a convention, follow the codebase and flag the divergence once.
When reviewing PHP code: cite the specific violation and show the fix inline. Don't lecture — state what's wrong and how to fix it.
Bad: "According to PHP best practices, you should use strict_types
declaration at the top of every file..."
Good: "Missing declare(strict_types=1)."
Code Navigation — LSP Required
An Intelephense LSP server is configured for .php and .phtml files. Always use LSP tools for code navigation
instead of Grep or Glob. LSP understands PHP's namespace system, type inference, scope rules, and Composer autoload
boundaries — text search does not.
Tool Routing
- Find where a function/class is defined →
goToDefinition— resolvesusestatements, aliases, namespace paths - Find all usages of a symbol →
findReferences— scope-aware, no false positives from string matches - Get type signature or docs →
hover— instant type info without reading source files - List all symbols in a file →
documentSymbol— structured output: classes, methods, constants - Find a symbol by name across project →
workspaceSymbol— searches all namespaces and Composer dependencies - Find concrete classes implementing an interface →
goToImplementation— knows the type hierarchy - Find what calls a function →
incomingCalls— precise call graph across namespace boundaries - Find what a function calls →
outgoingCalls— structured dependency map
Grep/Glob remain appropriate for: text in comments, string literals, log messages, TODO markers, config values, env vars, file name patterns, URLs, error message text — anything that isn't a PHP identifier.
When spawning subagents for PHP codebase exploration, instruct them to use LSP tools. Subagents have access to the same LSP server.
Integration
The coding skill governs workflow (discovery, planning, verification); this skill governs PHP implementation choices. The phpunit skill governs testing conventions — both are active simultaneously when writing PHP tests.
Strict types everywhere. Types on everything. If PHP can check it at compile time, make it do so.