Modern PHP 8 skill for Codex

Typed, enum-driven PHP that PHPStan trusts. Codex reads composer.json and the CI PHP version before touching code, then applies the features that version actually supports: readonly value objects, backed enums with match, property hooks on 8.4, #[\Override] on 8.3. Upgrades run through Rector sets one at a time with tests between, and PHPStan levels go up while the baseline goes down, never the reverse.

  • Languages
  • $php-8-modern
  • 221 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- php-8-modern

Installs to ~/.agents/skills/php-8-modern/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $php-8-modern, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Modernises and writes PHP 8.x code - strict_types, constructor promotion, readonly classes, enums, match, first-class callables, PHP 8.4 property hooks and asymmetric visibility, typed constants and the Override attribute - with Composer autoloading, PHPStan at a raised level and Rector for safe automated upgrades. Use when upgrading a PHP 7 or early PHP 8 codebase, adding types, raising PHPStan levels, cleaning composer.json, or writing new framework-agnostic PHP libraries.

Use it when

  • Upgrading a codebase to PHP 8.x or bumping require.php
  • Adding strict types, enums or readonly value objects
  • Raising a PHPStan level or shrinking a baseline
  • Cleaning up composer.json autoloading and scripts

Use something else when

How to check the result

php -l, PHPStan at the configured level and the full test suite pass, with the PHPStan baseline the same size or smaller than before.

The mistake it prevents

Adding declare(strict_types=1) across many files at once, which turns silent string-to-int coercions at call sites into TypeErrors in production.

~/.agents/skills/php-8-modern/SKILL.md

--- name: php-8-modern description: Modernises and writes PHP 8.x code - strict_types, constructor promotion, readonly classes, enums, match, first-class callables, PHP 8.4 property… license: MIT ---

Modern PHP code is fully typed (declare(strict_types=1), typed properties, return types, no untyped arrays at boundaries), models closed sets with enums and values with readonly objects, and passes PHPStan at the level the project has committed to without new baseline entries. Upgrades are mechanical (Rector) where possible and reviewed where not.

Before changing code

  1. Read AGENTS.md, composer.json (require.php, autoload, scripts), composer.lock, phpstan.neon(.dist), rector.php, .php-cs-fixer.dist.php or phpcs.xml.
  2. Confirm the runtime: php -v locally and the version in Docker/CI. Never use a feature newer than require.php allows.
  3. Find the test command (composer test, vendor/bin/phpunit, vendor/bin/pest).
  4. composer install may need network access; ask before running it if vendor/ is missing.

Feature map by version

VersionFeatures worth using
8.0constructor promotion, match, nullsafe ?->, named arguments, union types, attributes, str_contains
8.1enums, readonly properties, never, first-class callables strlen(...), new in initializers, pure intersection types, fibers
8.2readonly classes, DNF types (A&B)|null, true/false/null as standalone types; dynamic properties deprecated
8.3typed class constants, #[\Override], json_validate(), dynamic class constant fetch Foo::{$name}, readonly props re-initialisable in __clone
8.4property hooks, asymmetric visibility public private(set), new Foo()->bar() without wrapping parens, array_find/array_any/array_all, #[\Deprecated], lazy objects; implicitly nullable params deprecated
8.5pipe operator |>, clone($obj, ['prop' => $v]) (“clone with”), #[\NoDiscard], array_first/array_last

Idioms to apply

Value objects

<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class Money
{
    public function __construct(
        public int $amount,          // minor units, never float
        public Currency $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Amount must be non-negative');
        }
    }

    public function add(self $other): self
    {
        $this->currency === $other->currency
            || throw new CurrencyMismatch($this->currency, $other->currency);

        return new self($this->amount + $other->amount, $this->currency);
    }
}

Enums instead of string constants

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Refunded = 'refunded';

    public function isFinal(): bool
    {
        return match ($this) {
            self::Paid, self::Refunded => true,
            self::Pending => false,
        };
    }
}

$status = OrderStatus::tryFrom($row['status'])
    ?? throw new \UnexpectedValueException("Unknown status {$row['status']}");

match is strict (===) and throws UnhandledMatchError when nothing matches, unlike switch, which falls through and compares loosely.

PHP 8.4 property hooks and asymmetric visibility

final class User
{
    public private(set) string $email;

    public string $displayName {
        set(string $value) {
            $trimmed = trim($value);
            if ($trimmed === '') {
                throw new \InvalidArgumentException('Display name required');
            }
            $this->displayName = $trimmed;
        }
    }

    public function __construct(string $email, string $displayName)
    {
        $this->email = strtolower($email);
        $this->displayName = $displayName;
    }
}

Use hooks to replace trivial getter/setter pairs only on 8.4+ codebases; keep explicit methods when the logic is more than validation or derivation.

Smaller wins

  • First-class callables: array_map($this->normalize(...), $rows) instead of [$this, 'normalize'].
  • Named arguments for boolean flags: json_encode($data, flags: JSON_THROW_ON_ERROR).
  • Always pass JSON_THROW_ON_ERROR to json_encode/json_decode.
  • #[\Override] on methods that implement a parent or interface method (8.3+), so a renamed parent method fails loudly.
  • Explicit nullable parameters ?Foo $x = null, never Foo $x = null (deprecated 8.4).
  • Array shapes at boundaries belong in DTOs; inside code, document generics for PHPStan: /** @return list<Order> */, /** @param array<string, int> $counts */.

Composer hygiene

{
  "require": { "php": "^8.3" },
  "autoload": { "psr-4": { "App\\": "src/" } },
  "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } },
  "config": { "sort-packages": true, "optimize-autoloader": true },
  "scripts": {
    "analyse": "phpstan analyse --memory-limit=1G",
    "test": "phpunit"
  }
}
  • composer validate --strict and composer audit (known vulnerabilities; needs network).
  • After moving classes, composer dump-autoload. Class name and file path must match PSR-4 exactly, including case (works on macOS, breaks on Linux).
  • Commit composer.lock for applications; for libraries, test against lowest and highest dependencies (composer update --prefer-lowest).

PHPStan

vendor/bin/phpstan analyse                         # uses phpstan.neon(.dist)
vendor/bin/phpstan analyse --level 8 src tests
vendor/bin/phpstan analyse --generate-baseline     # freeze existing errors once
  • PHPStan 2.x has levels 0-10; level 9 enforces strict mixed handling, level 10 also treats implicit mixed strictly. Pick the highest level that new code can meet.
  • Raise one level at a time; fix new errors rather than growing phpstan-baseline.neon. A shrinking baseline is the goal; never regenerate it to hide new errors.
  • Framework extensions (larastan, phpstan-symfony, phpstan-doctrine) are needed for magic methods; install only after asking.
  • Avoid @phpstan-ignore without an identifier and reason: // @phpstan-ignore argument.type (legacy SDK returns mixed).

Rector for upgrades

// rector.php
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withPhpSets(php83: true)
    ->withPreparedSets(deadCode: true, typeDeclarations: true);
vendor/bin/rector process --dry-run   # review the diff first
vendor/bin/rector process

Run one set at a time on a clean working tree, run tests and PHPStan after each, and commit each set separately so reverts are cheap.

Upgrade workflow

  • Record baseline: tests green, PHPStan output, PHP version in CI.
  • Bump require.php and the CI/Docker PHP image together.
  • Run Rector PHP sets in dry-run; apply; tests; commit.
  • Add declare(strict_types=1) file by file with tests; it changes scalar coercion at call sites inside that file ("5" passed to int now throws).
  • Replace class constants used as enums with backed enums at the edges (DB, JSON).
  • Fix deprecations: run tests with error_reporting=E_ALL and convert deprecations to failures in PHPUnit (failOnDeprecation="true" in phpunit.xml).
  • Raise PHPStan level; shrink baseline.

Gotchas

  • readonly properties cannot have defaults and can be initialised only once, from inside the class (child classes too since 8.4); cloning-with-changes needs a with*() method (or 8.5 clone-with).
  • Enums cannot hold state and cannot be instantiated; cases() order is declaration order.
  • strict_types affects calls made from the declaring file, not calls into it.
  • match without a default arm throws on unexpected input; that is usually what you want.
  • Floats for money lose cents; store integers of minor units or use brick/money.
  • array_is_list() (8.1) distinguishes JSON arrays from objects after decode.
  • Dynamic properties on non-stdClass objects emit deprecations since 8.2; declare them or add #[\AllowDynamicProperties] only on legacy classes.

Verification

Run, in order: php -l on changed files, the formatter/linter the repo uses, vendor/bin/phpstan analyse, the test suite. Report the PHPStan level, baseline size before and after, and any Rector sets applied.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Modern PHP 8"
  short_description: "Typed, enum-driven PHP that PHPStan trusts"
  default_prompt: "$php-8-modern Upgrade this PHP 7.4 library to PHP 8.3 and add proper types"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem