Modern PHP skill for Claude Code
Typed, enum-driven PHP that PHPStan trusts. Claude writes PHP with strict_types in every file, full type coverage, backed enums matched exhaustively, and readonly value objects instead of loose arrays. It adopts PHPStan with a baseline that only shrinks, uses Rector for mechanical version upgrades in their own commits, and turns on Laravel's strict model mode so lazy loading and silent attribute drops fail loudly in development.
- name
php-modern - size211 lines · ≈2,000 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and modernizes PHP 8.3+ code — strict_types, typed properties and constants, enums, readonly classes, property hooks and asymmetric visibility in 8.4, Composer autoloading and auditing, PSR standards, PHPStan at high levels, Rector upgrades, and Laravel-specific strictness settings. Use when writing or reviewing PHP, upgrading from PHP 7 or early 8.x, adding static analysis, setting up Composer packages, or hardening a Laravel codebase. Try asking
- Upgrade this PHP 7.4 codebase to 8.3 and add types everywhere
- Set up PHPStan on this legacy project without fixing 3,000 errors today
- Replace these status string constants with a backed enum
Use it when
- Writing or reviewing PHP 8.x code
- Upgrading PHP versions or modernizing legacy classes
- Adding PHPStan, Rector, or a code style fixer
- Hardening a Laravel app against lazy loading and mass assignment
Skip it when
- The problem is slow Eloquent query counts — use n-plus-one-queries
- Reviewing an app for injection and auth flaws — use owasp-review
How to check the result
composer validate and composer audit are clean, PHPStan passes at the configured level with an equal or smaller baseline, Rector dry-run shows no pending changes, and tests pass.
The common mistake it prevents
Regenerating the PHPStan baseline to make CI green, which quietly accepts every new type error introduced since the last run.
Done well means: every file declares strict_types=1, every property, parameter, and
return is typed, fixed sets of values are enums, value objects are readonly, PHPStan runs
at level 8 or higher in CI with a shrinking baseline, and composer audit is clean.
Ground rules
declare(strict_types=1);at the top of every PHP file. Without it scalar types coerce silently ("5 apples"becomes5with a warning, or worse).- Types everywhere; docblocks only add what PHP types cannot say (
list<User>,array{id: int, name: string}, generics via@template). - No
mixedor untyped arrays crossing layer boundaries. Arrays of shapes become DTOs. - Never build SQL by string concatenation; use prepared statements or the query builder.
- Do not upgrade PHP and refactor code style in the same change.
Inspect before changing
php -v
composer show --platform | grep -E "^php "
grep -nE '"php"|"phpstan|"rector|"laravel/framework"' composer.json
grep -rLE "declare\(strict_types=1\)" --include=*.php src app 2>/dev/null | head
ls phpstan.neon* rector.php .php-cs-fixer* pint.json 2>/dev/null
Pin the platform for consistent dependency resolution:
{ "require": { "php": "^8.3" }, "config": { "platform": { "php": "8.3.0" }, "sort-packages": true } }
Language features by version
| Feature | Since | Use for |
|---|---|---|
Constructor promotion, named args, match, nullsafe ?-> | 8.0 | Concise DTOs and services |
Enums (pure and backed), readonly properties, never, first-class callables | 8.1 | Status values, immutable fields |
Readonly classes, DNF types, true/false/null types | 8.2 | Value objects |
Typed class constants, #[\Override], json_validate() | 8.3 | Safer inheritance, cheap JSON checks |
Property hooks, asymmetric visibility public private(set), new Foo()->bar(), array_find | 8.4 | Fewer getters/setters |
| Pipe operator ` | >, #[\NoDiscard]` | 8.5 |
<?php
declare(strict_types=1);
namespace App\Billing;
enum InvoiceStatus: string
{
case Draft = 'draft';
case Sent = 'sent';
case Paid = 'paid';
public function isFinal(): bool
{
return match ($this) {
self::Paid => true,
self::Draft, self::Sent => false, // no default: UnhandledMatchError surfaces new cases
};
}
}
final readonly class Money
{
public function __construct(
public int $cents,
public string $currency,
) {
if ($cents < 0) {
throw new \InvalidArgumentException("Negative amount: {$cents}");
}
}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new \DomainException('Currency mismatch');
}
return new self($this->cents + $other->cents, $this->currency);
}
}
// PHP 8.4: hooks + asymmetric visibility instead of getter/setter pairs
final class Customer
{
public private(set) string $email;
public string $displayName {
set(string $value) => trim($value);
}
public function __construct(string $email, string $displayName)
{
$this->email = strtolower($email);
$this->displayName = $displayName;
}
}
InvoiceStatus::from($value)throws on unknown input;tryFromreturns null. Usefromwhen invalid data is a bug,tryFromat input boundaries with validation.matchis strict (===) and throws on no match; prefer it overswitch.- Mark classes
finalby default; open them deliberately.
Static analysis and refactoring tools
| Tool | Role | Command |
|---|---|---|
| PHPStan | Type and bug finding (levels 0–10 in 2.x) | vendor/bin/phpstan analyse |
| Rector | Automated upgrades and refactors | vendor/bin/rector process --dry-run |
| PHP-CS-Fixer or Laravel Pint | Formatting (PER Coding Style) | vendor/bin/php-cs-fixer fix --dry-run --diff / vendor/bin/pint --test |
| Composer | Deps, autoload, audit | composer validate --strict && composer audit |
# phpstan.neon
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths: [src, tests]
treatPhpDocTypesAsCertain: false
Adopting PHPStan on legacy code: start at the highest level you can, generate a baseline
(vendor/bin/phpstan analyse --generate-baseline), forbid new errors in CI, and shrink
the baseline with every touched file. Never regenerate the baseline to hide new errors.
// rector.php: upgrade syntax to the project's PHP version
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withPhpSets(php83: true)
->withPreparedSets(deadCode: true, typeDeclarations: true);
Run Rector with --dry-run first, review, apply in a dedicated commit, then run tests.
Composer and PSR
- PSR-4 autoload in
composer.json("App\\": "src/"); one class per file, path matches namespace. After changes:composer dump-autoload. - Production install:
composer install --no-dev --optimize-autoloader --classmap-authoritative. - Commit
composer.lockfor applications; libraries commit it too but test against lowest and highest versions (composer update --prefer-lowestin CI). - Program to PSR interfaces at boundaries: PSR-3 logger, PSR-7/17 HTTP messages, PSR-18 HTTP client, PSR-11 container, PSR-20 clock.
- Coding style: PER Coding Style (successor to PSR-12).
Errors and exceptions
- Throw domain exceptions (
final class InsufficientFunds extends \DomainException), catch them at the HTTP/CLI boundary, map to responses there. - Never
@-suppress errors. Convert warnings to exceptions in development (frameworks do this; plain scripts can set an error handler that throwsErrorException). catch (\Throwable $e)only at the top-level boundary, and log with context.
Laravel notes
Turn on strictness in AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
Model::shouldBeStrict(! $this->app->isProduction()); // lazy loading, silent discards, missing attributes throw
- Validate with Form Requests; pass validated data (
$request->validated()) or DTOs to services, never$request->all(). - Prevent mass assignment holes: explicit
$fillable, never$guarded = []with raw input. - Use enum casts (
protected function casts(): array { return ['status' => InvoiceStatus::class]; }). - Eager load with
with(); check query counts with Laravel Debugbar or Telescope in dev. - Static analysis: Larastan (PHPStan extension) understands Eloquent magic.
- Queue anything slow (mail, webhooks, PDFs); make jobs idempotent.
php artisan config:cache route:cache view:cachein deploys; never callenv()outside config files (it returns null once config is cached).
Gotchas
readonlyproperties cannot be re-initialized, even in__clonebefore PHP 8.3; usewith*methods returning new instances.- Enum cases are not strings: compare
$status === InvoiceStatus::Paid, serialize with->value. array_filterkeeps keys; wrap inarray_valuesbefore JSON encoding a list.==between strings that look numeric still compares numerically in some cases; use===.DateTimeis mutable; useDateTimeImmutableeverywhere.json_decodereturns null on error silently; passJSON_THROW_ON_ERROR.- OPcache must be on in production; check
opcache.validate_timestamps=0plus a reload on deploy.
Verification
composer validate --strict
composer audit
vendor/bin/phpstan analyse --no-progress
vendor/bin/rector process --dry-run # no pending changes for the configured sets
vendor/bin/php-cs-fixer fix --dry-run --diff # or vendor/bin/pint --test
vendor/bin/phpunit # or vendor/bin/pest
Report the PHPStan level and baseline size before and after.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.