mcpbeat Sign in

Php Agent Skill

Use when writing, reviewing or modernizing PHP (8.3-8.5) outside Laravel — strict types, union and intersection types, readonly classes, backed enums, property hooks and asymmetric visibility, the pipe operator and clone-with, Composer with PSR-4, PER-CS style, PSR interop, and the quality toolchain (PHPStan, Pint, Rector, PHPUnit/Pest). NOT Eloquent, Blade or Artisan (that is `laravel`).

9k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill php

What comes with it

20 510 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/tooling.md
references/type-system.md
scripts/verify.sh

The instruction itself

15 sections, as written by the author

Modern PHP (8.x)

Write PHP the way the 2025-2026 ecosystem does: declare(strict_types=1) at the top of

every file, typed everything, Composer-first, statically analyzed at the top level — not

the way a 2015 WordPress plugin did. This skill owns the **language and its

framework-agnostic ecosystem**: the type system, Composer + PSR-4, PER-CS style, the PSR

interop interfaces, and the quality toolchain.

Version targeting. Floor is 8.3 (security-only, the lowest you should support).

Default new code to 8.4 (property hooks, asymmetric visibility). Use 8.5 features

(|>, clone with, array_first/array_last, #[\NoDiscard]) only when the deploy

runtime is confirmed 8.5+ — 8.5 released 2025-11-20. 7.x and 8.0-8.2 are EOL; never target

them.

When to use

  • Authoring/reviewing/refactoring any .php file or a composer.json.
  • Designing classes: enums, DTOs, value objects, readonly classes, interfaces, traits.
  • Standing up a vanilla-PHP project: Composer, PSR-4 autoload, namespaces, entrypoint.
  • Wiring quality gates: PHPStan/Psalm, Pint/PHP-CS-Fixer, Rector, CI.
  • Modernizing legacy 5.x/7.x patterns to 8.x idioms.
  • Picking framework-agnostic libs (Symfony components, Guzzle, Monolog, Doctrine DBAL,

league/*) and PSR-compatible interop.

When NOT to use (delegate)

| The ask is about | Route to | This skill keeps |

|---|---|---|

| Eloquent, Blade, Artisan, container bindings, queues | laravel | the PHP *underneath* Laravel only |

| WP hooks, the loop, wp_*, $wpdb | wordpress | nothing WP-specific |

| Shopify app/theme SDK work | shopify | nothing Shopify-specific |

| OWASP threat modeling, authz/abuse review | secure-coding | PHP-native controls (PDO, password_hash, escaping) |

| REST resource naming, status-code contract as a discipline | api-design | PHP request/response code only |

| DB schema/index tuning | mysql / postgresdb | PDO usage from the PHP side |

The type system, Composer, PSR, and the static-analysis toolchain are canonical here and

nowhere else in the catalog.

Non-negotiables

  • declare(strict_types=1); is the first statement in every .php file. Without it

PHP silently coerces "5" to 5, 1 to true — bugs that type hints exist to stop.

  • Type every parameter, return, and property. An untyped signature is a mixed you

did not ask for; PHPStan cannot reason about it.

  • One namespace per file, PSR-4, Composer-autoloaded. No require_once chains, no

hand-rolled autoloaders. PSR-0 is deprecated.

  • final by default. Open a class for extension only when you have designed the

extension point. Inheritance you did not plan for is a maintenance bill.

  • Commit composer.lock for applications (reproducible installs); libraries commit it

for dev too but do not ship it in the package.

  • PHPStan at max + style-clean before "done". "It runs" is not the bar; the static

analyzer and formatter passing is.

The type system

| Tool | Use it for | One-line why |

|---|---|---|

| Union A\|B | a value that is genuinely one of N types | beats mixed; PHPStan narrows it |

| Intersection A&B | a value that must satisfy several interfaces | expresses "Countable *and* Traversable" without a marker type |

| readonly property | a field set once in the constructor | immutability the engine enforces, no manual guard |

| readonly class (8.2+) | a whole value object | every property readonly; can only build a changed copy |

| Pure enum | a closed set with no scalar backing | replaces stringly-typed class constants |

| Backed enum (: string/: int) | a closed set that maps to a DB/JSON value | from()/tryFrom() give safe parsing |

| never return | a function that always throws/exits | tells the analyzer the path is dead |

| true/false literal types (8.2+) | a method that only ever returns one | precise contracts |

| Nullable ?T | "may be absent" | distinct from "optional argument with a default" |

| @template docblock generics | typed collections/containers | PHPStan reads them; the engine does not have native generics |

<?php

declare(strict_types=1);

// Bad: stringly-typed, untyped, mutable, coercible.
class Order {
    public $status;            // untyped -> mixed
    public function setStatus($s) { $this->status = $s; }  // accepts anything
}

// Good: backed enum + readonly + typed signatures.
enum OrderStatus: string {
    case Pending = 'pending';
    case Paid    = 'paid';
    case Shipped = 'shipped';

    public function isFinal(): bool {
        return $this === self::Shipped;
    }
}

final readonly class Order {
    public function __construct(
        public string $id,
        public OrderStatus $status,
    ) {}
}

$status = OrderStatus::tryFrom($raw) ?? OrderStatus::Pending; // safe parse, never throws on bad input

Generics live in docblocks until the engine ships them — PHPStan enforces them:

<?php

declare(strict_types=1);

/**
 * @template T
 */
final class Collection {
    /** @var list<T> */
    private array $items = [];

    /** @param T $item */
    public function add(mixed $item): void { $this->items[] = $item; }

    /** @return list<T> */
    public function all(): array { return $this->items; }
}

See references/type-system.md for enum-with-interface patterns,

variance, the asymmetric-visibility matrix, and readonly edge cases.

Modern OO idioms

<?php

declare(strict_types=1);

final class PriceCalculator {
    // Constructor property promotion: declare + assign in one place.
    public function __construct(private readonly TaxRate $rate) {}

    public function total(Money $net): Money {
        // match (not switch): expression, strict ===, no fall-through, exhaustive-ish.
        $multiplier = match ($this->rate->region) {
            Region::EU => 1.21,
            Region::US => 1.00,
        };
        return $net->times($multiplier);
    }
}

// Named arguments: skip optional params, self-document call sites.
$client = new HttpClient(timeout: 5, retries: 3);

// First-class callable syntax: pass a method as a callable without a closure wrapper.
$ids = array_map($repo->idOf(...), $orders);

Rules: promote constructor properties; prefer match over switch; enums over class

constants; immutable DTOs over mutable bags; $fn(...) over Closure::fromCallable.

PHP 8.4: property hooks + asymmetric visibility

Property hooks give computed/guarded properties the engine and PHPStan can see — no

docblock getters. Asymmetric visibility lets a property be read widely but written

narrowly, killing get/set boilerplate.

<?php

declare(strict_types=1);

// Bad: manual getter/setter pair, invisible to static analysis as a "property".
final class Temperature {
    private float $celsius = 0.0;
    public function getFahrenheit(): float { return $this->celsius * 9 / 5 + 32; }
    public function setCelsius(float $c): void {
        if ($c < -273.15) { throw new \InvalidArgumentException('below absolute zero'); }
        $this->celsius = $c;
    }
}

// Good: a computed property hook + a guarded set hook (PHP 8.4+).
final class Temperature {
    public float $celsius = 0.0 {
        set (float $value) {
            if ($value < -273.15) { throw new \InvalidArgumentException('below absolute zero'); }
            $this->celsius = $value;
        }
    }

    public float $fahrenheit {
        get => $this->celsius * 9 / 5 + 32;
    }
}

// Asymmetric visibility: readable everywhere, writable only inside the class.
final class Account {
    public function __construct(public private(set) int $balance) {}
    public function deposit(int $amount): void { $this->balance += $amount; }
}

Trap: keep hooks pure-ish. A get hook that runs a query or mutates state turns a

field access into a hidden side effect. For lazy initialization or I/O, use an explicit

method, not a hook.

PHP 8.5: pipe, clone-with, and friends (8.5+ runtime only)

Only reach for these when the deploy target is confirmed 8.5+ (released 2025-11-20).

<?php

declare(strict_types=1);

// Bad: deeply nested calls read inside-out.
$result = array_sum(array_filter(array_map(strlen(...), $words), fn($n) => $n > 3));

// Good: pipe operator |> reads left-to-right as a transform pipeline (8.5+).
$result = $words
    |> fn($w) => array_map(strlen(...), $w)
    |> fn($n) => array_filter($n, fn($x) => $x > 3)
    |> array_sum(...);

// clone with: a with-er for readonly objects in one expression (8.5+).
$shipped = clone $order with ['status' => OrderStatus::Shipped];

// array_first / array_last: no more reset()/end() side effects (8.5+).
$head = array_first($items);
$tail = array_last($items);

#[\NoDiscard] (8.5+) marks a return value that must be used — the engine warns if a caller

ignores it. Put it on a method whose result is the whole point (a built value, a Result).

Composer & project layout

my-package/
├── composer.json
├── composer.lock        # commit it for apps
├── src/                 # PSR-4 root -> namespace App\
├── tests/
└── phpstan.neon
{
    "name": "acme/my-package",
    "type": "library",
    "require": {
        "php": ">=8.3",
        "psr/log": "^3.0"
    },
    "require-dev": {
        "phpstan/phpstan": "^2.1",
        "laravel/pint": "^1.18",
        "pestphp/pest": "^4.0",
        "rector/rector": "^2.0"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "App\\Tests\\": "tests/" }
    },
    "scripts": {
        "lint": "pint --test",
        "stan": "phpstan analyse",
        "test": "pest",
        "check": ["@lint", "@stan", "@test"]
    },
    "config": { "sort-packages": true }
}

Rules: require = runtime deps, require-dev = tools/tests; the psr-4 map points a

namespace prefix at a base dir (PSR-0 is dead); composer check is your one-shot gate.

Error handling

<?php

declare(strict_types=1);

// A typed hierarchy lets callers catch by meaning, not by string matching.
abstract class DomainException extends \RuntimeException {}
final class OrderNotFound extends DomainException {}
final class PaymentDeclined extends DomainException {}

try {
    $order = $repo->find($id) ?? throw new OrderNotFound("order {$id}");
} catch (PaymentDeclined $e) {
    $logger->warning('payment declined', ['order' => $id, 'reason' => $e->getMessage()]);
    throw $e; // rethrow; do not swallow
} finally {
    $lock->release(); // runs whether or not we threw
}

Rules: throw typed exceptions, never bare \Exception; never swallow (no empty catch);

never use the @ error-suppression operator — it hides fatals from the analyzer; clean up

in finally; catch \Throwable only at a process boundary (CLI entry, request handler).

Security controls (PHP-native)

Generic appsec (OWASP, authz, threat modeling) is ../secure-coding/SKILL.md.

The PHP-specific controls below stay here.

| Control | API | Why |

|---|---|---|

| Parametrized SQL | PDO prepared statements | the only safe defense against SQLi; never interpolate |

| Password storage | password_hash() / password_verify() | bcrypt/argon2 with per-hash salt; never md5/sha1 |

| Tokens / secrets | random_bytes() / random_int() | cryptographically secure; rand()/mt_rand() are not |

| Output to HTML | htmlspecialchars($s, ENT_QUOTES, 'UTF-8') | stops reflected/stored XSS at the boundary |

| unserialize() | ['allowed_classes' => false] | blocks object-injection gadget chains |

| Comparing secrets | hash_equals() | constant-time; === leaks length/timing |

<?php

declare(strict_types=1);

// Bad: string interpolation = SQL injection.
$pdo->query("SELECT * FROM users WHERE email = '{$email}'");

// Good: prepared statement with a bound parameter.
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);

PSR interop

Depend on PSR interfaces, not concrete vendors, so code stays portable:

  • PSR-3 Psr\Log\LoggerInterface — type-hint this; inject Monolog as the impl.
  • PSR-4 autoloading — the Composer mapping above.
  • PSR-7 RequestInterface/ResponseInterface — HTTP messages; Guzzle/Nyholm implement them.
  • PSR-11 ContainerInterface — a container contract with get()/has().
  • PSR-15 middleware/handler — process(Request, Handler): Response.
<?php

declare(strict_types=1);

use Psr\Log\LoggerInterface;

final class Mailer {
    public function __construct(private readonly LoggerInterface $log) {} // PSR-3, not "new Monolog"
}

Quality toolchain

  • PHPStan 2.x at level: max (Psalm at max is the alternative) — your type contract.

2.1+ understands 8.4 property hooks.

  • Pint or PHP-CS-Fixer enforcing PER-CS (the living standard that replaced the

now-frozen PSR-12). Run in --test/--dry-run in CI.

  • Rector 2.x for mechanical upgrades (e.g. 7.x → 8.x rule sets) — review the diff.
  • PHPUnit 12 or Pest 4 (built on PHPUnit 12) for tests.

Wire them as Composer scripts (above) so composer check and CI run the same gate. Full

configs — phpstan.neon, pint.json, rector.php, phpunit.xml — are in

references/tooling.md.

Anti-patterns

| Pattern | Why it is bad | Do instead |

|---|---|---|

| Associative array as a DTO | no types, no autocomplete, typo = silent null | a readonly class or backed enum |

| Missing declare(strict_types=1) | silent scalar coercion defeats your type hints | first line of every file |

| Untyped property/param/return | every one is an invisible mixed | type everything; let PHPStan reason |

| @ error suppression | hides fatals and warnings from you and the analyzer | handle the error or let it throw |

| String-interpolated SQL / mysql_* | SQL injection; mysql_* removed in PHP 7 | PDO prepared statements |

| Global state / static mutable singletons | untestable, order-dependent, hidden coupling | constructor injection |

| Fat static "helper" classes | a namespace masquerading as an object; no DI, no mocking | small injected services |

| mixed everywhere | abandons the type system you are paying for | precise union/intersection types |

| Manual getter/setter pairs (on 8.4+) | boilerplate the engine can express natively | property hooks / private(set) |

| switch with fall-through | accidental fall-through bugs; statement not expression | match (strict, exhaustive) |

References & siblings

  • references/type-system.md — enums (backed + interface +

methods), docblock generics, readonly/clone with, property-hook edge cases, the

asymmetric-visibility matrix.

  • references/tooling.md — full phpstan.neon, pint.json,

.php-cs-fixer.dist.php, rector.php, phpunit.xml / Pest, composer scripts, CI snippet.

  • Laravel framework surface (Eloquent, Blade, Artisan): the laravel skill.
  • Generic appsec / OWASP: ../secure-coding/SKILL.md.
  • DB schema/index tuning: the mysql / ../postgresdb/SKILL.md skills.

How to use it

Copy the folder

Take ericrisco/php from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.