Skip to content
Back to skills

Php Expert

ASecurity

Production PHP 8.3+ house rules for naming, style, typed constants, Override attributes, PHP 8.4 gotchas, strict types, Composer constraints, exceptions, PHPUnit, and anti-patterns. Use when writing or reviewing PHP code.

  • 2 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 29, 2026
testinggophpbashsqlexpressdockerkubernetestestinggitdatabase

Works with

  • cli

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 29, 2026

npx -y skills add abnegate/claudes --skill php-expert --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Php Expert?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Php Expert
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/abnegate-php-expert/badge)](https://www.skillsdirectory.com/skills/abnegate-php-expert)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: php-expert
description: Production PHP 8.3+ house rules for naming, style, typed constants, Override attributes, PHP 8.4 gotchas, strict types, Composer constraints, exceptions, PHPUnit, and anti-patterns. Use when writing or reviewing PHP code.
---

# PHP Expert

Opinion and house-rule reference for PHP 8.3+. **Assumes baseline PHP 8.0–8.2 knowledge** (constructor promotion, readonly, enums, match, nullsafe, named args, first-class callables, arrow functions, `#[\SensitiveParameter]`) — this file only covers what's house-specific, what's load-bearing, or what shifts year-to-year.

Non-negotiable defaults:
- **PHP 8.3 minimum, 8.4 preferred.** Check `composer.json` before using an 8.4-only feature.
- Every new class: `final` + `private readonly` promoted properties unless there's a reason otherwise.
- Typed class constants with the `public const string FOO = '...';` form — always the type keyword.
- `#[\Override]` on every overriding method. No exceptions.
- `match` always, `switch` never. `$this->method(...)` always, `[$this, 'method']` never.
- Singular namespace nouns, no doubled-up filenames, no abbreviations in names, camelCase acronyms in method names.
- Imports: alphabetical, one per statement, grouped `const` / `class` / `function`.
- `assertSame` always, `assertEquals` never.

---

## 1. Version landscape (April 2026)

| Version | Status | Use for |
|---|---|---|
| **8.5** (Nov 2025) | Active | Experimental / greenfield only |
| **8.4** (Nov 2024) | **Active** | Greenfield — target this |
| **8.3** (Nov 2023) | Security-only | Current practical floor |
| **8.2** (Dec 2022) | Security-only | Legacy only |
| 8.1 and below | **EOL** | Never |

Before reaching for an 8.4-only feature (property hooks, asymmetric visibility, `#[\Deprecated]`, `new Foo()->bar()` without parens, `array_find`/`array_any`/`array_all`, lazy objects), verify the target repo's `composer.json` `require.php` field.

---

## 2. Load-bearing language rules

### 2.1 Typed class constants — always with the type keyword

```php
class Exception extends \Exception
{
    public const string GENERAL_UNKNOWN = 'general_unknown';
    public const string USER_NOT_FOUND  = 'user_not_found';
    public const int    MAX_RETRIES     = 3;
}
```

The `string` / `int` / `array` keyword is what makes it a *typed* constant (PHP 8.3+) — without it, overriding subclasses can change the type. Easy to forget. Pint will not add it for you.

### 2.2 `#[\Override]` on every override

```php
use Override;

final class Autoscale extends Base
{
    #[Override]
    protected function getName(): string
    {
        return 'autoscale';
    }
}
```

Apply it to every method that overrides a parent — including `__construct`, `__toString`, and interface implementations. When touching a file that doesn't use it yet, add it alongside your changes.

### 2.3 Class defaults — `final` + `private readonly` promoted

```php
final class Autoscale
{
    public function __construct(
        private readonly KubernetesCluster $kubernetes,
        private readonly Concurrency $concurrency,
        private readonly array $projects,
        private readonly int $percentage,
    ) {
    }
}
```

Rules:
- **Every class is `final` unless it's explicitly designed for inheritance.** Opt into extensibility, don't opt out.
- **Every property is promoted and `private readonly`** unless a subclass needs it (`protected readonly`) or it's part of the public contract (`public readonly`).
- Multi-line constructors with trailing commas. One property per line. Empty `{ }` on its own line.
- **For value objects / DTOs, use `readonly class`** instead of per-property `readonly` — one word covers every field.

### 2.4 Enum house idiom

```php
enum DatabaseType: string
{
    case Shared    = 'shared';
    case Dedicated = 'dedicated';

    /** @return list<string> */
    public static function values(): array
    {
        return array_map(fn (self $case) => $case->value, self::cases());
    }

    public function isShared(): bool
    {
        return $this === self::Shared;
    }

    public function getDescription(): string
    {
        return match ($this) {
            self::Shared    => 'Serverless database (scales to zero when idle)',
            self::Dedicated => 'Dedicated database (always running)',
        };
    }
}
```

The three idioms to copy:
- `static function values(): array` returning `list<string>` — for validators and form options.
- `public function is<State>(): bool` — one per case.
- `getDescription()` / `getLabel()` / `getPort()` using `match ($this)` with **no `default:`** — an added case becomes a compile-time error via `UnhandledMatchError` instead of a silent fall-through.

Centralize the behavior on the enum itself — don't leave it bare and write an inline `match ($type) { ... }` at every call site.

### 2.5 PHP 8.4 — the parts that trip me up

**Property hooks** — useful but boxed in:

```php
class User
{
    public string $fullName {
        get => trim("{$this->firstName} {$this->lastName}");
    }

    public string $email {
        set(string $value) {
            if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new \InvalidArgumentException('Invalid email');
            }
            $this->email = strtolower($value);
        }
    }

    public function __construct(public string $firstName, public string $lastName) {}
}
```

Limitations I forget:
- **Cannot combine with `readonly`.**
- No `unset()` on hooked properties.
- `set`-hooked properties cannot be assigned by reference (`$r = &$o->name`) or indirectly mutated (`$o->arr[] = ...`).
- Virtual properties (hooks that don't touch `$this->name`) have no backing storage and no default value.

Use for: validation, normalization, derived fields. Avoid for: multi-statement logic (use methods).

**Asymmetric visibility** — replaces "public getter + private setter":

```php
final class Session
{
    public function __construct(
        public private(set) string $token,
        public private(set) int $expiresAt,
    ) {
    }

    public function refresh(string $token, int $expiresAt): void
    {
        $this->token     = $token;
        $this->expiresAt = $expiresAt;
    }
}
```

Set-visibility must be `≤` get-visibility. `private(set)` alone is shorthand for `public private(set)`.

**`new Foo()->method()`** — drop the outer parens:

```php
$slug = new Slugger()->slug($title);   // 8.4+
$slug = (new Slugger())->slug($title); // pre-8.4
```

Constructor parens are still required even for zero-arg constructors.

**`#[\Deprecated]`** — native replacement for `@deprecated` PHPDoc:

```php
#[\Deprecated(message: 'Use createFromRequest() instead', since: '2.0')]
public function legacyCreate(array $input): self { /* ... */ }
```

Emits `E_USER_DEPRECATED` at call time; static analyzers pick it up.

**New array functions** — prefer over `foreach` + break:

```php
$admin = array_find($users,     fn (User $u) => $u->role === 'admin');  // first match or null
$key   = array_find_key($users, fn (User $u) => $u->role === 'admin');  // first key or null
$has   = array_any($errors,     fn (Error $e) => $e->isFatal());        // bool (any)
$ok    = array_all($validators, fn (Validator $v) => $v->isValid($x));  // bool (every)
```

**Implicit nullable parameters are deprecated.** `function f(string $x = null)` must become `function f(?string $x = null)`. Rector will fix a whole repo in one pass.

### 2.6 `declare(strict_types=1)` — match the repo

If the repo uses it, put it at the top of every new file. If the repo doesn't use it, don't sprinkle it in — the change shows up in every diff forever. For greenfield repos, turn it on everywhere from day one.

```php
<?php

declare(strict_types=1);

namespace Acme\Database\Exception;
```

---

## 3. Naming and style

### 3.1 Naming rules (house)

- **Single-word names when context makes meaning obvious.** `connections` not `backendConnections`; `pools` not `backendPools`; `timeout` not `connectTimeout` (when it's the only timeout).
- **No abbreviations in general names.** `certificate` not `cert`, `connection` not `conn`, `request` not `req`, `message` not `msg`, `database` not `db` (outside variable names like `$dbHandle`), `authorization` not `auth` (in class names).
- **Well-known acronyms are fine** — `TLS`, `HTTP`, `TCP`, `CA`, `JWT`, `SDK`, `DNS`, `MFA`, `MTLS`.
- **Acronyms in method names are camelCase, not UPPER.** `updateMfa()` not `updateMFA()`. Upper-case runs break SDK generation into `create_m_f_a` instead of `create_mfa`.
- **In constants / config keys**, acronyms are fully uppercase — `APP_AUTH_TYPE_JWT`, `cacheTTL`, `parseURL`.
- **No doubled-up namespace in filenames.** `Engine/Driver.php` not `Engine/EngineDriver.php` — namespace already provides context. Concrete implementations go in nested subdirectories: `Engine/Driver/{Postgres,MySQL,Mongo}.php`.
- **Singular nouns for namespaces.** `Adapter` not `Adapters`, `Validator` not `Validators`, `Worker` not `Workers`. A namespace is a folder; plurality is implied.
- **REST endpoints**: plural nouns (`/collections`, not `/collection`), kebab-case for multi-word paths (`/acme-challenge`, not `/acmeChallenge`).

### 3.2 Imports

- Alphabetical.
- One per statement. **Never** combined imports (`use Foo\{A, B};`).
- Grouped `const` / `class` / `function`, in that order. Pint rule:

```json
"ordered_imports": {
    "sort_algorithm": "alpha",
    "imports_order": ["const", "class", "function"]
}
```

Use `as` to disambiguate clashing names (`Acme\Exception as AcmeException`) — don't work around with fully-qualified names in the body.

### 3.3 Strings

Single quotes by default. Double quotes only when the string contains a single quote, or when interpolation beats concatenation (`"Deleted pod: {$pod->getName()}"`). Heredoc/nowdoc for long multiline SQL/JSON — prefer nowdoc (`<<<'SQL'`) when no interpolation is needed.

### 3.4 Comments

- **Never** use section-header comments like `// ---------------- HANDLERS ----------------` or `// === Section ===`. If you see them, delete them.
- PHPDoc only for non-trivial generic or shape information (`@param array<string, mixed>`, `@return list<Document>`, `@throws`). Do NOT add PHPDoc that just repeats the type signature.
- Inline `//` only when intent can't be inferred from the code (external-bug workaround, deliberately empty block, subtle invariant). Do not narrate what the code already says.
- `/** @phpstan-ignore ... */` inline when silencing PHPStan — always add a reason.

### 3.5 Domain-driven organization

No `helpers/`. No `utils/`. No `common.php` of global functions. Group by **domain**:

```
src/Acme/
├── Auth/          # passwords, OAuth, sessions
├── Event/         # event publishers
├── Exception/     # custom exception hierarchy
├── Http/          # HTTP glue
├── Messaging/     # SMS, email, push
├── Payment/       # billing, invoicing
└── Storage/       # file upload, blob storage
```

One class per file; filename matches symbol name. A `MetricsCollector` lives in `src/Acme/Metric/`, not `src/Acme/Util/MetricsCollector.php`.

### 3.6 Sparse updates

When updating a record, pass only the **changed attributes**, never the whole object. Full-object updates cause unnecessary conflict writes in high-concurrency paths and break audit diffing.

### 3.7 Fix nearby violations

When you edit a file that contains older style (dynamic callables, `switch` statements, untyped params, plural namespaces, section-header comments), **fix them in the same commit** if the scope is reasonable. Don't leave inconsistent patterns in files you just touched.

---

## 4. Project structure

| Directory | Contents | Autoloaded? |
|---|---|---|
| `src/` | PSR-4 production code. No side effects on file load — class definitions only. | Yes (`autoload`) |
| `app/` | Bootstrap code with side effects — route/container/listener wiring. Loaded by entry scripts. | No — explicit `require` |
| `bin/` | Executable CLI scripts. Each file is a tiny entry that hands off to a `src/` class. | No |
| `tests/unit/` | Pure unit tests — no IO, no subprocess. | Yes (`autoload-dev` as `Tests\Unit\`) |
| `tests/e2e/` | Integration tests — real DB, real HTTP, real queues. | Yes (`autoload-dev` as `Tests\E2E\`) |
| `tests/resources/` | Fixtures. Never PSR-4. Exclude from PHPStan scan. | No |

**Library** (`"type": "library"`) — only `src/`, `tests/`, tooling config. **Project** (`"type": "project"`) — add `app/`, `bin/`, Docker, CI.

Test-only classes go in `autoload-dev`, not `autoload` — `composer install --no-dev` strips them entirely:

```json
"autoload": {
    "psr-4": {"Acme\\": "src/Acme"}
},
"autoload-dev": {
    "psr-4": {
        "Tests\\Unit\\": "tests/unit",
        "Tests\\E2E\\":  "tests/e2e"
    }
}
```

---

## 5. Composer

### 5.1 Constraint style — `^` carets

| Style | Meaning | When |
|---|---|---|
| `^1.2` | `>=1.2, <2.0` | Default for every dependency |
| `^0.33` | `>=0.33, <0.34` | Pre-1.0 packages — the caret already locks the minor |
| `dev-branchname` | VCS branch | For forks, paired with a `repositories` entry |

**Never `~` or `*` wildcards.** `"utopia-php/framework": "^0.33"`, not `"0.33.*"` or `"~0.33.0"`.

### 5.2 VCS repositories for forks

Never shim a dependency locally. If you need a fork or branch, add a VCS repo:

```json
"repositories": [
    {"type": "vcs", "url": "https://github.com/acme/php-k8s"}
],
"require": {
    "acme/php-k8s": "dev-main"
}
```

**Never** write a patch file or copy a package into `vendor-patches/`. Fix upstream, commit, push, then `composer update <package>` in the consumer.

### 5.3 Standard script names

```json
"scripts": {
    "test":    "vendor/bin/phpunit",
    "lint":    "vendor/bin/pint --test",
    "format":  "vendor/bin/pint",
    "check":   "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=2G",
    "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=2G",
    "refactor":"vendor/bin/rector process",
    "fix":     ["@refactor", "@analyze", "@format"]
}
```

Run `composer format` before every commit. Run `composer check` (or `analyze`) before every PR.

### 5.4 `config.platform` + deploy flags

Pin the target PHP version so local installs resolve like production:

```json
"config": {
    "platform": {"php": "8.3"},
    "allow-plugins": {"php-http/discovery": false}
}
```

Deploy invocation (never `composer update`):

```bash
composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
```

Composer 2 generates `vendor/composer/platform_check.php` by default — keep it on, run `composer check-platform-reqs` in CI.

---

## 6. Pint + PHPStan + Rector

### 6.1 Pint (`pint.json`) — house preset

```json
{
    "preset": "psr12",
    "exclude": [
        "./tests/resources"
    ],
    "rules": {
        "array_indentation": true,
        "single_import_per_statement": true,
        "simplified_null_return": true,
        "ordered_imports": {
            "sort_algorithm": "alpha",
            "imports_order": ["const", "class", "function"]
        }
    }
}
```

Preset is always `psr12` unless the project is a framework-specific one (then `laravel` / `symfony`).

### 6.2 PHPStan — target `level: max`

Greenfield code starts at **`level: max`**. Retrofitting to higher levels later is painful — start strict, keep it strict. PHPStan 2.x has 11 levels (0–10); `max` is level 10, which treats all `mixed` strictly.

```neon
includes:
    - phpstan-baseline.neon

parameters:
    level: max
    paths:
        - src
        - tests
    tmpDir: .phpstan-cache
    excludePaths:
        - tests/resources
```

Existing codebases that can't reach max yet can sit at a lower level **as long as there's a written plan to raise it**. Never lower the level to silence an error. Add to `phpstan-baseline.neon` with a dated `# TODO(2026-09): revisit` instead, and shrink the baseline over time.

Useful extensions: `phpstan/phpstan-strict-rules`, `phpstan/phpstan-deprecation-rules`, `phpstan/phpstan-phpunit`.

### 6.3 Rector (`rector.php`)

```php
<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withPhpSets(php84: true)
    ->withSets([
        SetList::DEAD_CODE,
        SetList::CODE_QUALITY,
        SetList::TYPE_DECLARATION,
        SetList::PRIVATIZATION,
    ])
    ->withPhpunitSets(phpunit120: true);
```

Adopt in greenfield. Rector handles: implicit nullable → explicit, docblock types → native types, PHPUnit annotations → attributes, `switch` → `match`, `[$this, 'method']` → `$this->method(...)`, constructor promotion migration, PHP version upgrades.

---

## 7. Typed exceptions (the house pattern)

### 7.1 Typed string error codes

Every service has one Exception class with **typed class constants** for each error type:

```php
namespace Acme\Extend;

class Exception extends \Exception
{
    public const string GENERAL_UNKNOWN          = 'general_unknown';
    public const string GENERAL_ACCESS_FORBIDDEN = 'general_access_forbidden';
    public const string GENERAL_RATE_LIMITED     = 'general_rate_limited';
    public const string USER_NOT_FOUND           = 'user_not_found';
    public const string USER_EMAIL_EXISTS        = 'user_email_already_exists';
    public const string USER_BLOCKED             = 'user_blocked';

    public function __construct(
        string $type = self::GENERAL_UNKNOWN,
        ?string $message = null,
        int|string|null $code = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message ?? $type, (int) ($code ?? 0), $previous);
    }
}
```

Naming: `ENTITY_ERRORTYPE` in SCREAMING_SNAKE for the constant, `entity_errortype` in snake_case for the value. The value is a stable string used by SDKs, error pages, and translations — **never rename it** once published.

Throwing:

```php
if ($email === '') {
    throw new AcmeException(AcmeException::USER_EMAIL_INVALID);
}

throw new AcmeException(
    AcmeException::USER_COUNT_EXCEEDED,
    "User count exceeded: {$total}/{$limit}",
);
```

**Never** throw the base `\Exception` or `\RuntimeException` in new code. Throw a domain exception with a typed code so the global error handler can map it to an HTTP status + user-facing message.

### 7.2 Custom exceptions with public readonly context

Specialized exceptions carry structured context as **public readonly properties** — far better than stashing data in `getMessage()` with `sprintf`:

```php
declare(strict_types=1);

namespace Acme\Database\Exception;

use RuntimeException;
use Throwable;

final class Provisioning extends RuntimeException
{
    public function __construct(
        public readonly string $databaseId,
        public readonly string $step,
        string $message,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
```

Caller reads context fields directly:

```php
try {
    $this->provision($id);
} catch (Provisioning $e) {
    $log->error("Step {$e->step} failed for database {$e->databaseId}: {$e->getMessage()}");
    throw $e;
}
```

### 7.3 Typed exception hierarchy

Expose a tree so callers can `catch` at any level of specificity:

```
Acme\Database\Exception (base)
├── Exception\Authorization
├── Exception\Conflict
├── Exception\Duplicate
├── Exception\Limit
├── Exception\Structure
├── Exception\Timeout
└── Exception\Transaction
```

Callers write `catch (Conflict $e)` for a narrow case, `catch (DatabaseException $e)` for a broad fallback.

### 7.4 `finally` for cleanup, not log-and-rethrow

```php
// Right
$handle = $this->open();
try {
    return $this->process($handle);
} finally {
    $handle->close();
}

// Wrong — adds noise, loses stack
try {
    return $this->process($handle);
} catch (\Throwable $e) {
    $log->error($e->getMessage());
    throw $e;
}
```

Only catch exceptions you can actually handle. Leave logging to a top-level error handler. Cleanup goes in `finally`.

---

## 8. Array idioms worth the rule

Most array idioms are elementary. These two are the foot-guns worth naming:

### 8.1 `array_push($arr, ...$new)` in loops, not `array_merge`

`array_merge` copies the entire left array on every call — O(n²) in a loop.

```php
// Right
foreach ($batches as $batch) {
    array_push($results, ...$fetch($batch));
}

// Wrong — quadratic
foreach ($batches as $batch) {
    $results = array_merge($results, $fetch($batch));
}
```

### 8.2 PHPStan shape annotations on `array` returns

`array` is a useless return type — PHPStan/Psalm can't help you. Annotate shape:

```php
/** @return list<User> */
public function allActive(): array { /* ... */ }

/** @return array<string, int> */
public function countByEmail(): array { /* ... */ }

/** @return array{name: string, age: int, tags: list<string>} */
public function getProfile(): array { /* ... */ }
```

At `level: max` PHPStan enforces these. Prefer a typed collection class over `list<>` when the collection has behavior (methods, iteration).

---

## 9. Testing — PHPUnit 12

### 9.1 Attribute migration reminder

PHPUnit 12 **removed** docblock annotations. Attributes to know:

- `#[Test]` — mark a method as a test (lets you drop the `test` prefix).
- `#[TestDox('human readable')]` — override the reported name.
- `#[DataProvider('methodName')]` / `#[DataProviderExternal(Class::class, 'method')]` — parameterised tests.
- `#[CoversClass(Foo::class)]` — coverage target (replaces `@covers`).
- `#[Group('slow')]` — tag for `--group slow` / `--exclude-group slow`.
- `#[Before]`, `#[After]`, `#[BeforeClass]`, `#[AfterClass]` — lifecycle.
- `#[RequiresPhp('>=8.4')]`, `#[RequiresPhpExtension('gd')]` — skip tests.

Migration tool: Rector's `AnnotationsToAttributesRector`.

### 9.2 `assertSame` always

`assertSame` = strict `===` (checks type and value). `assertEquals` = loose `==` (tolerates `'1' == 1`, object field shuffles). **Default to `assertSame` in new tests.** Reach for `assertEquals` only when you genuinely want loose semantics (rare — use `assertEqualsWithDelta` for floats, assert on `$date->format('c')` for dates).

### 9.3 Test organisation — unit vs e2e is a hard line

- One test file per class under test. Filename = `<Class>Test.php`.
- Namespace = `Tests\Unit\` + the class's package suffix. Test for `Acme\Auth\Hash` → `Tests\Unit\Auth\HashTest`.
- `final class FooTest extends TestCase` — tests are `final`; no test inheritance chains.
- **Integration tests go in `tests/e2e/`, not `tests/unit/`.** Keep unit tests pure — no IO, no subprocess, no network. If it touches a real DB, HTTP server, or queue, it's not a unit test.

### 9.4 Never mock the database

Hit a real database — SQLite in memory, a dedicated test DB, or a testcontainer. Mocked database tests pass while real migrations/schemas fail, which is a guaranteed future bug.

Never mock your own code under test — you're only testing the mock.

### 9.5 Mandatory regression test for bug fixes

**Every bug fix must include a regression test that fails without the fix and passes with it. No exceptions.**

There are no "pre-existing issues". If tests fail, fix them regardless of when the issue was introduced.

### 9.6 Use paratest

Anything beyond ~100 tests should run through `brianium/paratest`, not raw phpunit:

```bash
vendor/bin/paratest --configuration phpunit.xml --functional --processes 4
```

Drop-in compatible with PHPUnit. CI should always use paratest.

---

## 10. Wrong defaults — refuse on sight

**Every entry in this section is a pattern that comes from training data.** The left column is what I'll write if I'm not actively applying the rules in §2–§9. The right column is the correct pattern. Scan this section before committing any PHP; every row is a likely diff in a review.

### 10.1 Language

| About to write | Write instead | Why |
|---|---|---|
| `public const FOO = 'foo';` | `public const string FOO = 'foo';` | Typed class constant (8.3+). Pint won't add the type keyword for you. |
| Overriding method with no attribute | `#[\Override]` on the method | Catches rename/refactor typos at class load. |
| `class Foo { private Database $db; public function __construct(Database $db) { $this->db = $db; } }` | `final class Foo { public function __construct(private readonly Database $db) {} }` | House default: `final` + constructor-promoted `private readonly`. |
| Bare enum + inline `match ($type) { ... }` at every call site | Methods on the enum (`values()`, `is<State>()`, `getDescription()` using `match ($this)`) | Centralize behavior on the enum. |
| `switch ($x) { case 'a': ...; break; }` | `match ($x) { 'a' => ..., }` | Strict `===`, no fall-through, expression-valued, exhaustive. |
| `[$this, 'method']` / `Closure::fromCallable('foo')` | `$this->method(...)` / `foo(...)` | First-class callable syntax (8.1+). |
| `function f(string $x = null)` | `function f(?string $x = null)` | Implicit nullable deprecated in 8.4. |
| `function f($x, $y) { ... }` (untyped) | Full type hints on every param and return | `mixed` only when genuinely unbounded. |
| `json_decode($body)` → `stdClass` DTO | Typed `readonly class` hydrated from an array | No `stdClass` in the domain layer. |
| `throw new \RuntimeException("User {$id} not found")` | `throw new AcmeException(AcmeException::USER_NOT_FOUND)` with a `public const string` code | Typed domain exception with stable error code. |
| Exception message built via `sprintf(...)` with context | `public readonly` context fields on the exception, read directly at the catch site | Caller reads `$e->databaseId` instead of parsing strings. |
| `try { ... } catch (\Throwable $e) { $log->error(...); throw $e; }` | `try { ... } finally { $cleanup(); }` — let the exception propagate | Logging belongs at the top-level handler. |
| `global $db;` / static `Container::get('db')` | Inject through the constructor | No service locators. |
| Magic strings for a closed set | Enum (backed or pure) | No magic strings anywhere. |
| `die()` / `exit()` for control flow | `throw new DomainException(...)` | Only `exit` at program entry points. |
| `sprintf('Hello %s', $name)` | `"Hello {$name}"` | Interpolation beats `sprintf` for simple concat. |
| `strftime` / `gmstrftime` / `utf8_encode` / `utf8_decode` | `IntlDateFormatter` / `date()` / `mb_convert_encoding(..., 'UTF-8', 'ISO-8859-1')` | Deprecated or removed. |
| Docblock `@var` / `@param` / `@return` that duplicates a native type | Native type, drop the docblock | PHPDoc is for shape/generic info, not native types. |
| `Validators\`, `Adapters\`, `Workers\` namespace | `Validator\`, `Adapter\`, `Worker\` | Singular namespace nouns. |
| `Adapter/MySQLAdapter.php` | `Adapter/MySQL.php` | No doubled-up namespace in filenames. |
| `updateMFA()` / `parseHTML()` / `toJSON()` | `updateMfa()` / `parseHtml()` / `toJson()` | camelCase acronyms in methods — UPPER breaks SDK generation into `update_m_f_a`. |
| Hydrate full record, mutate, pass whole thing back to `update()` | Build a small array with only the dirty fields | Sparse updates only. |
| `array_merge($acc, $batch)` inside a loop | `array_push($acc, ...$batch)` | `array_merge` in a loop is O(n²). |
| `array` return type with no docblock | `array` return type + `@return list<X>` / `@return array<string, X>` / `@return array{...}` shape | PHPStan at `level: max` can't type-check otherwise. |

### 10.2 Testing

| About to write | Write instead | Why |
|---|---|---|
| `$this->assertEquals($a, $b)` | `$this->assertSame($a, $b)` | Strict `===` checks type and value. |
| `$mock = $this->createMock(Database::class)` for a "unit" test | Real database — SQLite in memory, testcontainer, or test DB | Mocked DB tests pass while real migrations fail. |
| Mocking the class under test itself | Don't — you're only testing the mock | You're not testing anything real. |
| Integration test inside `tests/unit/` | Move to `tests/e2e/` | Unit tests are pure — no IO, no network, no subprocess. |
| `/** @dataProvider cases */` | `#[DataProvider('cases')]` | Docblock annotations removed in PHPUnit 12. |
| `class FooTest extends TestCase` (not `final`) | `final class FooTest extends TestCase` | No test inheritance chains. |
| Shipping a bug fix PR without a new test | Add a regression test that fails without the fix and passes with it | Mandatory for every bug fix. |

### 10.3 Tooling & workflow

| About to do | Do instead | Why |
|---|---|---|
| `composer update` in production | `composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader` from a committed `composer.lock` | Lockfile is the contract. |
| Writing a `~` or `*` constraint (`"~0.33.0"`, `"0.33.*"`) | `"^0.33"` caret range | House rule: carets for every dependency. |
| Writing a patch file / `vendor-patches/` / copying a dep locally | Fix the dep upstream, commit, push, `composer update <package>` | No shims. |
| Committing without running `composer format` / `composer lint` | Format first, then commit | Pre-commit hook if possible. |
| Lowering PHPStan level to make an error disappear | Fix the error, or add a line to `phpstan-baseline.neon` with a dated `# TODO(2026-09): revisit` | Shrink the baseline over time; never grow it. |
| `git commit --no-verify` to skip hooks | Investigate why the hook fails; fix the underlying issue | Only skip if the user explicitly asks. |
| Leaving `// TODO: remove`, dead code, abandoned branches, commented-out iterations in the final commit | Clean up before stopping — the last commit of a finished change reads as if the iterations never happened | Finalize, don't accrete. |
| Leaving "we can migrate this later" comments | Finish the migration in the same commit | No loose ends. |
| Creating `helpers.php` / `utils.php` / `src/Util/` | Put the code in the domain that owns it | No helper files. |

Files in this skill

  • README.md3 KB
  • SKILL.md29.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…