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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- php-8-modern Installs to ~/.agents/skills/php-8-modern/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install php-8-modern The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project php-8-modern Installs to .agents/skills/php-8-modern/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/php-8-modern.zipunzip -o php-8-modern.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) php-8-modern PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The work is Laravel-specific (Eloquent, queues, Livewire): use laravel-apps
- The work is Symfony services, Doctrine or Messenger: use symfony-apps
- You are building a WordPress plugin: use wordpress-plugins
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.
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
- Read
AGENTS.md,composer.json(require.php,autoload,scripts),composer.lock,phpstan.neon(.dist),rector.php,.php-cs-fixer.dist.phporphpcs.xml. - Confirm the runtime:
php -vlocally and the version in Docker/CI. Never use a feature newer thanrequire.phpallows. - Find the test command (
composer test,vendor/bin/phpunit,vendor/bin/pest). composer installmay need network access; ask before running it ifvendor/is missing.
Feature map by version
| Version | Features worth using |
|---|---|
| 8.0 | constructor promotion, match, nullsafe ?->, named arguments, union types, attributes, str_contains |
| 8.1 | enums, readonly properties, never, first-class callables strlen(...), new in initializers, pure intersection types, fibers |
| 8.2 | readonly classes, DNF types (A&B)|null, true/false/null as standalone types; dynamic properties deprecated |
| 8.3 | typed class constants, #[\Override], json_validate(), dynamic class constant fetch Foo::{$name}, readonly props re-initialisable in __clone |
| 8.4 | property 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.5 | pipe 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_ERRORtojson_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, neverFoo $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 --strictandcomposer 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.lockfor 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
mixedhandling, 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-ignorewithout 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.phpand 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 tointnow throws). - Replace class constants used as enums with backed enums at the edges (DB, JSON).
- Fix deprecations: run tests with
error_reporting=E_ALLand convert deprecations to failures in PHPUnit (failOnDeprecation="true"in phpunit.xml). - Raise PHPStan level; shrink baseline.
Gotchas
readonlyproperties cannot have defaults and can be initialised only once, from inside the class (child classes too since 8.4); cloning-with-changes needs awith*()method (or 8.5 clone-with).- Enums cannot hold state and cannot be instantiated;
cases()order is declaration order. strict_typesaffects calls made from the declaring file, not calls into it.matchwithout 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-
stdClassobjects 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.yaml
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.