php-conventions

Modern PHP idioms for any PHP project (PHP 8.1+): readonly properties, enums, match expressions, constructor property promotion, typed properties, named arguments, nullsafe operator, first-class calla

SKILL.md

GitHub

PHP Conventions (stack-agnostic, PHP 8.1+)

This skill encodes idioms that reduce bugs and improve readability in any PHP codebase. Apply alongside the active framework plugin's conventions skill (e.g., laravel:laravel-conventions, symfony:symfony-conventions).

Detection

Read composer.json first to learn the target PHP version:

  • "require": { "php": "^8.1" } (or higher) → all idioms below apply.
  • "config": { "platform": { "php": "8.x" } } pins the resolver version.

Match the project's minimum version — do not use PHP 8.2+ features (e.g. readonly classes, DNF types) on an 8.1 project.

strict_types — always on

Every .php file with logic starts with:

<?php

declare(strict_types=1);

namespace App\Domain;

Strict typing turns silent coercions into TypeErrors at the boundary, catching bugs early. The declaration must be the first statement.

Constructor property promotion

Collapse the declare/assign boilerplate into the signature.

// Prefer
final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly Currency $currency,
    ) {
    }
}

// Avoid — manual field + assignment
final class Money
{
    private int $amount;
    private Currency $currency;

    public function __construct(int $amount, Currency $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }
}

readonly properties — immutability by default

final class OrderLine
{
    public function __construct(
        public readonly string $sku,
        public readonly int $quantity,
    ) {
    }

    public function withQuantity(int $quantity): self
    {
        return new self($this->sku, $quantity); // return a new instance, never mutate
    }
}

readonly properties can be assigned once (from within the declaring scope) and never again. Use them for value objects and DTOs. On PHP 8.2+ a whole class may be marked readonly.

Enums — replace magic constants

Use backed enums for closed sets of values; attach behaviour with methods.

enum Status: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Archived = 'archived';

    public function isPublished(): bool
    {
        return $this === self::Active;
    }

    public function label(): string
    {
        return match ($this) {
            self::Draft => 'Draft',
            self::Active => 'Active',
            self::Archived => 'Archived',
        };
    }
}

$status = Status::from('active');        // throws ValueError on unknown
$maybe = Status::tryFrom($input);        // null on unknown

Never model a closed set with bare string constants or class const lists when a backed enum fits.

match — exhaustive, type-safe branching

$discount = match (true) {
    $total >= 1000 => 0.15,
    $total >= 500  => 0.10,
    $total >= 100  => 0.05,
    default        => 0.0,
};

match uses strict comparison (===), has no fallthrough, and throws UnhandledMatchError if nothing matches and no default is present — far safer than switch.

Typed properties, parameters, and returns

final class UserService
{
    public function findById(int $id): ?User      // explicit nullable return
    {
        return $this->repository->find($id);
    }

    /** @return list<User> */
    public function active(): array                // PHPDoc narrows array element type
    {
        return $this->repository->whereActive();
    }
}
  • Type every parameter, property, and return value. Use ?T for nullable, union types (int|string) where genuinely needed.
  • PHP has no generics — use PHPDoc (list<T>, array<K, V>) so static analysers (PHPStan/Psalm) can check element types.

Nullsafe operator and null discipline

$country = $order?->customer?->address?->country;   // short-circuits to null

// Validate at boundaries instead of propagating null
public function charge(Customer $customer): void
{
    $card = $customer->defaultCard()
        ?? throw new NoPaymentMethodException($customer->id);
    // ...
}
  • Do not return null to signal errors from public methods — throw a domain exception or return an empty collection.
  • Use the nullsafe operator for optional read chains, not to hide missing required data.

Named arguments

$response = $client->request(
    method: 'POST',
    uri: '/orders',
    options: ['json' => $payload],
);

Use named arguments to clarify boolean/optional parameters at the call site. Do not reorder — pass them in declaration order for readability.

First-class callable syntax

$names = array_map(strtoupper(...), $words);
$ids = array_map($user->id(...), $users);

Prefer $callable(...) over string callable names or array [$obj, 'method'] forms.

Code style — PSR-12

  • 4-space indentation, no tabs. One class per file. Files use <?php (no closing ?>).
  • final by default; open for extension only when designed for it.
  • Single quotes for strings without interpolation.
  • One blank line between methods; trailing comma in multi-line arrays/arguments.
  • Run a formatter (the active framework plugin provides a Stop hook — Laravel Pint or PHP-CS-Fixer with the @PSR12 / @Symfony ruleset). Do not hand-tune whitespace the formatter owns.

Class design rules

  • Single Responsibility: one reason to change per class.
  • Prefer composition over inheritance; keep inheritance trees shallow.
  • Keep the public API minimal — private/protected by default, public only when needed.
  • Define contracts with interface; depend on abstractions, not concretions.
  • Avoid static state. Pure static helpers for stateless functions are acceptable.