Languages No. 113

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.

  • namephp-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

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.

~/.claude/skills/php-modern/SKILL.md

--- name: php-modern description: Writes and modernizes PHP 8.3+ code — strict_types, typed properties and constants, enums, readonly classes, property hooks and asymmetric… license: MIT ---

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" becomes 5 with 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 mixed or 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

FeatureSinceUse for
Constructor promotion, named args, match, nullsafe ?->8.0Concise DTOs and services
Enums (pure and backed), readonly properties, never, first-class callables8.1Status values, immutable fields
Readonly classes, DNF types, true/false/null types8.2Value objects
Typed class constants, #[\Override], json_validate()8.3Safer inheritance, cheap JSON checks
Property hooks, asymmetric visibility public private(set), new Foo()->bar(), array_find8.4Fewer 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; tryFrom returns null. Use from when invalid data is a bug, tryFrom at input boundaries with validation.
  • match is strict (===) and throws on no match; prefer it over switch.
  • Mark classes final by default; open them deliberately.

Static analysis and refactoring tools

ToolRoleCommand
PHPStanType and bug finding (levels 0–10 in 2.x)vendor/bin/phpstan analyse
RectorAutomated upgrades and refactorsvendor/bin/rector process --dry-run
PHP-CS-Fixer or Laravel PintFormatting (PER Coding Style)vendor/bin/php-cs-fixer fix --dry-run --diff / vendor/bin/pint --test
ComposerDeps, autoload, auditcomposer 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.lock for applications; libraries commit it too but test against lowest and highest versions (composer update --prefer-lowest in 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 throws ErrorException).
  • 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:cache in deploys; never call env() outside config files (it returns null once config is cached).

Gotchas

  • readonly properties cannot be re-initialized, even in __clone before PHP 8.3; use with* methods returning new instances.
  • Enum cases are not strings: compare $status === InvoiceStatus::Paid, serialize with ->value.
  • array_filter keeps keys; wrap in array_values before JSON encoding a list.
  • == between strings that look numeric still compares numerically in some cases; use ===.
  • DateTime is mutable; use DateTimeImmutable everywhere.
  • json_decode returns null on error silently; pass JSON_THROW_ON_ERROR.
  • OPcache must be on in production; check opcache.validate_timestamps=0 plus 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.

esc