Claude Skill

sota-php

State-of-the-art PHP engineering (2026 baseline, PHP 8.3+ floor, 8.5 current) for both writing new PHP and auditing existing PHP code. Covers strict_types and modern idioms (enums, readonly, match, fibers, property hooks), OWASP-grade security (SQL injection, XSS, file uploads, L

LLM Mart · 0 points · 8 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download martinholovsky-SOTA-skills-skills_sota-php-ec2abf6.zip · 30 KB
Part of martinholovsky/sota-skills — 39 skills

Install

skills CLI npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-php
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
Git git clone https://github.com/martinholovsky/SOTA-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SOTA PHP (2026)

Purpose

This skill encodes the 2026 state of the art for PHP: a supported-version baseline (PHP 8.3+ floor; 8.5 is the latest annual line — verify current, see rules/01), strict_types everywhere, typed object-oriented design, security-by-default at every trust boundary, a locked and audited Composer supply chain, and measured runtime performance. It serves two modes:

  • BUILD — writing new code or modifying existing code to this standard.
  • AUDIT — reviewing existing code against this standard and reporting findings.

The detailed rules live in rules/*.md. Read SKILL.md fully; load rules files on demand per the index table below.

BUILD mode

When creating or modifying PHP code:

  1. Establish context first. Check composer.json (require.php, config.platform), composer.lock, the framework in use, PHPStan/Psalm config, and CS ruleset. Match the project's PHP floor — no enums on a project that still supports 8.0. For a new project, scaffold per rules/05: PHP ≥ 8.3 floor, committed lockfile, PHPStan at max level (baseline only for legacy), PER-CS formatting, CI gates from day one.
  2. Default style: declare(strict_types=1) in every file, full parameter/return/ property types, constructor promotion, readonly where state shouldn't mutate, enums over class constants, match over switch, exceptions over error codes, no @ suppression. (rules/01)
  3. Security posture is non-optional even when unrequested: PDO prepared statements, context-correct output escaping, upload validation by content, no unserialize() on external data, password_hash/sodium/random_bytes for anything secret. (rules/02–rules/04)
  4. Framework first. When a framework is present (e.g. Laravel, Symfony), use its escaping, CSRF, auth, and validation mechanisms instead of hand-rolling — but verify raw-escape hatches (DB::raw, |raw, html()) aren't fed user input.
  5. Tests accompany code (PHPUnit or Pest as the project dictates); static analysis and CS must pass before code is presented. (rules/05)
  6. Performance: OPcache assumptions belong in deploy config, not code; anything beyond correct-by-default (eager loading, streaming, generators) requires a profile first. (rules/06)

AUDIT mode

When reviewing existing PHP code:

  1. Sweep mechanically first. Run the "Audit checklist" blocks at the end of every relevant rules file — ordered grep/composer/phpstan commands. Start with composer audit --locked and a grep sweep for unserialize(, eval(, shell_exec, string-interpolated SQL, and echo $_.
  2. Then read for design: trust-boundary placement, escaping strategy (output-time or scattered?), session lifecycle, N+1 patterns, lockfile discipline.
  3. Verify every finding — open the file, trace the data flow. An unserialize() of a value the same app signed with HMAC is not CRITICAL. Note mitigations already present.
  4. Don't report style noise a fixer would auto-fix; mention once collectively.

Severity conventions

Severity Meaning Examples
CRITICAL Exploitable now, or data loss SQL built by interpolation from request data, unserialize($_GET…), include of user path, eval on input, uploads executed as PHP
HIGH Exploitable with preconditions, or prod-breaking XSS via unescaped output, md5() passwords, missing use_strict_mode/fixation, SSRF fetch of user URL, CURLOPT_SSL_VERIFYPEER => false, world-readable secrets
MEDIUM Correctness/maintenance risk no lockfile committed, no composer audit in CI, loose == on security decisions, rand() for tokens in non-auth context, N+1 on hot path, no static analysis
LOW Deviation from SOTA, friction missing strict_types, untyped properties, switch where match fits, dev deps in prod image
INFO Worth knowing newer-PHP features available after floor bump, tooling consolidation

Finding format

file:line | rule violated (rules/NN §S) | severity | effort | fix

Effort: trivial · small · medium · large. Group by severity, CRITICAL first. Borderline severities state the deciding assumption; unconfirmed findings are marked "needs verification", never asserted. End with counts per severity, the sweep commands run, and explicit "checked and clean" areas.

Rules index

File Read this when...
rules/01-language-baseline.md choosing/verifying PHP version floor (support/EOL table); writing any PHP: strict_types, typed properties, enums, readonly, match, fibers, 8.4/8.5 features, comparison pitfalls incl. strpos returning false where 0 is a real match, error handling, deprecations
rules/02-injection.md code touching SQL, shell, or HTML output: PDO prepared statements, command execution, XSS and context-aware escaping, template engines, eval-family bans
rules/03-files-deserialization-ssrf.md file uploads, include/require paths, stream wrappers (LFI/RFI/phar://), unserialize and Phar object injection, XXE, server-side URL fetching (SSRF)
rules/04-sessions-auth-web-hardening.md login/session/auth code: session cookie flags and fixation, password_hash/argon2id, sodium crypto, CSRF, security headers, production php.ini hardening
rules/05-composer-tooling.md dependencies and CI: composer.lock discipline, composer audit, platform reqs, PHPStan/Psalm levels and baseline ratcheting, PER-CS, PHPUnit/Pest, CI gates
rules/06-performance-runtime.md anything slow or deploy-shaped: OPcache and preloading, JIT reality check, PHP-FPM pool sizing, N+1/caching, autoloader optimization, profiling. Test strategy lives in sota-testing; DB depth in sota-databases.

Top-10 non-negotiables

  1. Run a supported PHP (≥ 8.2 today, and 8.2 is security-only until 2026-12-31 — plan the 8.3+ move now); new code targets 8.3+. (rules/01)
  2. declare(strict_types=1) in every file; full types on every property, parameter, and return. Untyped is legacy, not a style choice. (rules/01)
  3. SQL only via prepared statements with bound parameters (PDO/mysqli, emulation off); identifiers via allowlist. String-built SQL is CRITICAL, no exceptions for "internal" values. (rules/02)
  4. Escape at output, for the right context — htmlspecialchars(…, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') or the template engine's auto-escaping; raw-output escape hatches never receive user input. (rules/02)
  5. Never unserialize(), eval(), or include/require data you don't fully control. External data is JSON. Filter user paths for phar:// and friends. (rules/03)
  6. Uploads: validate by content, rename randomly, store non-executable — never trust client filename or MIME; never let the webserver execute uploads. (rules/03)
  7. Passwords via password_hash() (bcrypt default, or argon2id) + password_verify; secrets via random_bytes/sodium; compare with hash_equals. Never md5/sha1/ rand()/uniqid() for anything secret. (rules/04)
  8. Sessions hardened: use_strict_mode=1, cookies Secure + HttpOnly + SameSite, session_regenerate_id(true) on privilege change. (rules/04)
  9. composer.lock committed; CI runs composer install (never update) and composer audit --locked; prod installs --no-dev. (rules/05)
  10. PHPStan (or Psalm) gates CI at the highest level the project can hold; the baseline only shrinks. OPcache on in prod; performance claims require a profile. (rules/05, rules/06)
Files (sota-skills)
  • rules
    • 01-language-baseline.md 10.4 KB
      # 01 — Language baseline & idioms
      
      Modern PHP is a strictly-typed language. Most legacy-PHP pain (type juggling,
      register-globals folklore, `@` suppression) is opt-out today — these rules make
      opting out the default.
      
      ## 1. Version baseline: know the support windows
      
      Verified against php.net/supported-versions.php and php.net/releases (2026-07):
      
      | Branch | Status | Active support until | Security fixes until |
      |---|---|---|---|
      | 8.2 | security-only | ended 2024-12-31 | **2026-12-31** |
      | 8.3 | security-only | ended 2025-12-31 | 2027-12-31 |
      | 8.4 | active | 2026-12-31 | 2028-12-31 |
      | 8.5 | active (current stable, released 2025-11-20) | 2027-12-31 | 2029-12-31 |
      
      - 8.1 and older are **EOL** (8.1's final release was 8.1.34, 2025-12; PHP 7 ended
        2022-11). Running them is a HIGH finding on internet-facing systems.
      - Each branch gets 2 years active + 2 years security-only (php.net policy).
      - **BUILD:** target 8.3+ as the floor for new projects (8.2 exits security support
        2026-12-31 — months away); use 8.4/8.5 features when the floor allows.
      - **AUDIT:** check `composer.json` `require.php` and `config.platform.php` against
        the table; flag EOL floors and floors about to lapse.
      
      Feature timeline for floor decisions: enums, `readonly` properties, fibers,
      first-class callable syntax (8.1); `readonly` classes, DNF types (8.2); typed class
      constants, `#[\Override]`, `json_validate()` (8.3); property hooks, asymmetric
      visibility, `new X()->method()` without parens, bcrypt default cost 10→12 (8.4);
      pipe operator `|>`, `clone with`, `#[\NoDiscard]`, `array_first`/`array_last`,
      closures in constant expressions, URI extension (8.5, per php.net/releases/8.5).
      
      ## 2. strict_types and real types, everywhere
      
      Without `declare(strict_types=1)`, scalar type declarations *coerce* ("42abc" may
      pass an `int` parameter with a notice, `"1"` passes `bool`). With it, mismatches
      throw `TypeError`.
      
      ```php
      <?php
      
      declare(strict_types=1);   // first statement, every file — no exceptions
      ```
      
      - Every property, parameter, and return gets a type. `mixed` is a documented
        last resort, not a default; `?Type` over implicit-nullable (implicit nullable
        parameters are deprecated since 8.4).
      - Use union/intersection/DNF types where they model reality
        (`(Countable&Traversable)|null`), not to paper over unclear design.
      - `array` hides shape: for structured data prefer a small typed class (or at
        least a PHPDoc `array{id: int, name: string}` shape that PHPStan/Psalm check).
      - Value objects: constructor promotion + `readonly`:
      
      ```php
      final class Money
      {
          public function __construct(
              public readonly int $amountMinor,
              public readonly Currency $currency,
          ) {}
      
          public function withAmount(int $amountMinor): self
          {
              return clone($this, ['amountMinor' => $amountMinor]); // 8.5 clone-with
              // pre-8.5: return new self($amountMinor, $this->currency);
          }
      }
      ```
      
      - 8.4+ property hooks replace getter/setter boilerplate; asymmetric visibility
        (`public private(set)`) replaces "public getter, private setter" pairs.
      
      ## 3. Enums over constants; match over switch
      
      ```php
      enum OrderStatus: string          // backed enum when it's persisted/serialized
      {
          case Pending = 'pending';
          case Shipped = 'shipped';
          case Cancelled = 'cancelled';
      }
      
      $status = OrderStatus::tryFrom($raw) ?? throw new InvalidArgumentException(
          sprintf('unknown status "%s"', $raw),
      );
      ```
      
      - `::from()` throws `ValueError` on unknown input; `::tryFrom()` returns null —
        choose deliberately at trust boundaries.
      - Enums can carry methods and interfaces; use them instead of parallel
        `match`/lookup tables scattered around the codebase.
      
      `match` beats `switch`: strict (`===`) comparison, no fallthrough, it's an
      expression, and an unhandled value throws `\UnhandledMatchError` instead of
      silently doing nothing:
      
      ```php
      $label = match ($status) {
          OrderStatus::Pending   => 'In progress',
          OrderStatus::Shipped   => 'Done',
          OrderStatus::Cancelled => 'Cancelled',
      };  // adding a case to the enum makes this throw until handled — good
      ```
      
      Audit `switch` on security-relevant values as MEDIUM (loose comparison +
      fallthrough hazards).
      
      ## 4. Comparison and juggling discipline
      
      - `==` compares after juggling; **always `===`/`!==`** unless a comment justifies
        otherwise. Classic traps: `0 == "a"` was true before 8.0 (string-to-number
        comparison changed in PHP 8.0 — saner, but `"1" == "01"` is still true),
        `null == false == 0 == ""` are all true.
      - `in_array($needle, $arr)` and `array_search` juggle by default — pass
        `strict: true`. `switch` juggles and cannot be fixed — prefer `match`.
      - `strcmp()`-style return values and `0` are falsy: `if (strpos($s, $p))` is a
        bug when the needle is at offset 0 — use `str_contains`/`str_starts_with`
        (8.0+) or `!== false`.
      - Never use `==` on anything security-relevant (tokens, hashes, MACs): juggling
        plus magic-hash pitfalls (`"0e123..." == "0e456..."`). Use `hash_equals()`
        (see `rules/04`).
      
      ## 4a. In-band sentinels — `strpos` is the textbook case
      
      PHP's search functions return **`false`** for not-found, and a legitimate match at
      the start returns **`0`** (verified, PHP 8.5.8): `strpos("abc","z")` is `false`,
      `strpos("abc","a")` is `int(0)`. With loose comparison `0 == false` is **true**, so
      `if (strpos($h,$n) == false)` reports "not found" on a match at offset 0 — the
      canonical instance of the class in `sota-architecture` rules/02 §8a, and the reason
      §4's identity rule exists.
      
      ```php
      if (strpos($h, $n) === false) { /* not found */ }   // === is mandatory, not style
      ```
      
      - Same shape: `array_search` (returns `false`; verified), `strrpos`, `stripos`.
        `str_contains`/`str_starts_with` (8.0+) return real `bool` — prefer them whenever
        you only need the yes/no, and the trap disappears.
      - Writing your own: return `null` (with a `?int` return type under `strict_types`)
        or throw. Returning `false` from an `int`-ish function reproduces the stdlib's
        worst API in code you control.
      - `intval("x")` / `(int)"x"` is `0`, indistinguishable from `(int)"0"` — use
        `filter_var($s, FILTER_VALIDATE_INT)`, which returns `false` for invalid, and test
        it with `===`.
      - Audit: `grep -rnE '(strpos|stripos|strrpos|array_search)\s*\(' --include='*.php' src/ | grep -v '==='`
        — every hit without `===` is a finding.
      
      ## 5. Errors and exceptions, not silence
      
      - Production ini: `display_errors=Off`, `log_errors=On`; development:
        `error_reporting(E_ALL)` and fail on warnings in tests. (OWASP PHP
        Configuration Cheat Sheet.)
      - **No `@` suppression** — it hides the error *and* costs a handler round-trip.
        The only near-acceptable uses are APIs with no error-free variant; wrap those
        once and document.
      - Throw exceptions; don't return `false|string` unions from new APIs. Define a
        small package-level exception hierarchy (`DomainException` subclasses), chain
        with `previous:`.
      - `json_decode(..., flags: JSON_THROW_ON_ERROR)` — silent `null` returns are a
        classic injection/logic hazard. 8.3+ `json_validate()` for validate-only.
      - Since 8.5, uncaught fatal errors include backtraces (php.net/releases/8.5) —
        make sure stack traces still never reach responses (`rules/04` §6).
      - `DateTimeImmutable` over `DateTime`; pass an explicit `DateTimeZone`; never
        parse dates with juggling (`strtotime` on user input needs validation).
      
      ## 6. Fibers and concurrency (8.1+)
      
      `Fiber` is a *low-level* cooperative-concurrency primitive: full-stack
      interruptible functions (`Fiber::suspend()`/`resume()`). It does **not** make
      code parallel and does not schedule anything by itself.
      
      - Application code should not hand-roll fiber schedulers. Use an event-loop
        runtime built on fibers (e.g. Revolt/AMPHP v3, ReactPHP) where async I/O
        concurrency is genuinely needed.
      - Classic FPM request/response code gains nothing from fibers — concurrency
        there is process-level (see `rules/06` FPM sizing). Long-running runtimes
        (CLI workers, e.g. FrankenPHP/Swoole-style servers as neutral examples) are
        where async PHP pays off; in those, blocking calls (`PDO`, `file_get_contents`)
        stall the whole loop — same discipline as any event loop.
      - AUDIT: raw `new Fiber(` in application (non-library) code is a MEDIUM design
        smell; blocking I/O inside an event-loop callback is HIGH in async runtimes.
      
      ## 7. Deprecations and legacy constructs to remove on sight
      
      - **Removed** (fail on modern PHP): `create_function` (8.0), string-argument
        `assert()` (8.0), `preg_replace` `/e` modifier (7.0), `mcrypt_*` (7.2),
        `each()` (8.0), curly-brace string offsets `$s{0}` (8.0).
      - **Deprecated** (fix now): backtick operator `` `cmd` `` and `__sleep`/
        `__wakeup` (both deprecated in 8.5 — use `__serialize`/`__unserialize`),
        implicit nullable params (8.4), dynamic properties without
        `#[\AllowDynamicProperties]` (8.2).
      - **Legacy smells:** `extract()` on request data (variable injection),
        variable-variables `$$name` from input, `global` keyword in new code,
        `array_merge` in loops (quadratic — use spreads/`array_push`),
        `register_shutdown_function` as error handling.
      
      ## Audit checklist
      
      Run from repo root; verify each hit manually.
      
      ```bash
      # Missing strict_types — LOW per file, MEDIUM if project-wide
      grep -rL --include='*.php' 'declare(strict_types=1)' src/ | head -50
      
      # EOL / lapsing PHP floor — check require.php against the table in §1
      grep -n '"php"' composer.json
      php -v
      
      # Loose comparison on suspicious values — MEDIUM+, verify context
      grep -rnE '[^=!<>]==[^=]' --include='*.php' src/ | grep -iE 'token|password|hash|hmac|secret|sig'
      grep -rnE 'in_array\([^)]*\)' --include='*.php' src/ | grep -v 'true'
      
      # strpos truthiness bug
      grep -rnE 'if\s*\(\s*!?\s*strpos\(' --include='*.php' src/
      
      # Error suppression and silent JSON
      grep -rn '@' --include='*.php' src/ | grep -E '@\s*[a-z_]+\(' | grep -v '//'
      grep -rn 'json_decode' --include='*.php' src/ | grep -v 'JSON_THROW_ON_ERROR'
      
      # Removed/deprecated constructs
      grep -rnE '(create_function|each\(|__sleep|__wakeup|\$\$[a-zA-Z]|extract\s*\(\s*\$_)' --include='*.php' src/
      grep -rn '`' --include='*.php' src/ | grep -vE '(//|\*|#)'   # backtick exec
      
      # Untyped properties (heuristic; rely on PHPStan level 6+ for the real sweep)
      grep -rnE '^\s*(public|protected|private)\s+\$' --include='*.php' src/
      
      # switch on request-derived values — prefer match
      grep -rn 'switch\s*(' --include='*.php' src/
      ```
      
      Severity guide: EOL PHP in production HIGH; missing strict_types project-wide
      MEDIUM; loose `==` on security decisions HIGH; `@`-suppressed security function
      HIGH; style-level items LOW/INFO.
      
    • 02-injection.md 8.4 KB
      # 02 — Injection: SQL, shell, and XSS
      
      Trust-boundary thinking: `$_GET`/`$_POST`/`$_COOKIE`/headers, uploaded files, DB
      content, queue payloads, and LLM output are attacker-controlled until proven
      otherwise. PHP's history is a catalog of interpolation bugs — the fix is always
      the same: **keep data out of the code/query/markup channel.**
      
      ## 1. SQL: prepared statements with bound parameters, nothing else
      
      String-interpolated SQL is CRITICAL regardless of the value's origin — "it comes
      from our own table" is how second-order injection happens. (OWASP SQL Injection
      Prevention Cheat Sheet: parameterized queries are defense #1.)
      
      ```php
      // BAD — CRITICAL, even with (worse: because of) manual quoting/escaping
      $rows = $pdo->query("SELECT * FROM users WHERE email = '" . $email . "'");
      $rows = $pdo->query(sprintf("SELECT * FROM users WHERE id = %s", $id));
      
      // GOOD — PDO named parameters
      $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
      $stmt->execute(['email' => $email]);
      $user = $stmt->fetch();
      ```
      
      PDO setup that makes the safe path the real path:
      
      ```php
      $pdo = new PDO($dsn, $user, $password, [
          PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // default since 8.0
          PDO::ATTR_EMULATE_PREPARES   => false, // real server-side prepares
          PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
      ]);
      ```
      
      - `ATTR_EMULATE_PREPARES => false` sends the statement and data separately
        (true separation, and typed results on mysqlnd). Emulated prepares are
        client-side string splicing — safe when used correctly, but one charset or
        driver quirk away from not being safe.
      - **Identifiers can't be bound.** Table/column names and `ORDER BY ... ASC|DESC`
        from user input go through a hardcoded allowlist, never quoting:
      
      ```php
      $col = ['name', 'created_at'][$idx] ?? 'created_at';
      $dir = $desc ? 'DESC' : 'ASC';
      $stmt = $pdo->prepare("SELECT * FROM users ORDER BY $col $dir LIMIT :n");
      ```
      
      - `LIKE`: bind the parameter *and* escape wildcards in it —
        `addcslashes($term, '%_\\')` — or user input `%` scans the table.
      - `IN (...)`: build exactly as many `?` placeholders as values; never implode
        values into the string.
      - ORMs/query builders (e.g. Doctrine, Eloquent) parameterize by default, but
        their raw escape hatches don't: audit `->raw(`, `DB::raw(`, `whereRaw(`,
        `createNativeQuery`, string-concatenated DQL. Same rule applies inside them.
      - `mysqli`: same discipline (`prepare`/`bind_param`). Any use of
        `mysqli_real_escape_string` as the *primary* defense is a finding (HIGH):
        it's charset-sensitive and doesn't help outside quoted string context.
      
      ## 2. Command execution: no shell between you and the binary
      
      Prefer no process at all (native functions, extensions). When you must run one:
      
      ```php
      // BAD — CRITICAL with any user-influenced part
      shell_exec("convert $input out.png");
      system('ping -c1 ' . $host);
      $out = `ls $dir`;               // backticks = shell_exec; deprecated in 8.5
      
      // GOOD — argv array, no shell involved (proc_open array mode, 7.4+)
      $p = proc_open(['convert', $input, 'out.png'], $spec, $pipes);
      
      // Acceptable when a shell string is unavoidable: escape EVERY argument
      $cmd = 'ping -c1 ' . escapeshellarg($host);
      ```
      
      - `proc_open` with an **array** command bypasses the shell entirely — the
        strongest option (php.net proc_open). Symfony Process (array syntax) is a
        neutral-example wrapper doing the same.
      - `escapeshellarg()` escapes one argument; `escapeshellcmd()` escapes a whole
        command *but leaves argument splitting possible* — it is not a substitute.
      - Watch argument injection even with perfect quoting: a value starting with `-`
        becomes an option. Prepend `--` where the tool supports it, or validate shape.
      - `mail()`: the 4th/5th parameters historically enabled header/argument
        injection — validate/drop user input there; prefer a mailer library (e.g.
        Symfony Mailer) as a neutral example.
      - Ban list for user-reachable paths: `eval()`, `assert()` with dynamic input,
        `preg_replace_callback` with attacker-chosen callables, `call_user_func`/
        variable functions `$fn()` where `$fn` derives from input, `unserialize`
        (see `rules/03`).
      
      ## 3. XSS: escape at output, for the exact context
      
      Escaping at *input* time is the classic mistake — data gets double-escaped,
      mis-escaped for the context, or bypassed by a second write path. Store raw,
      escape at the sink. (OWASP Cross Site Scripting Prevention Cheat Sheet.)
      
      **HTML body and attribute context:**
      
      ```php
      // BAD — HIGH
      echo "<p>Hello {$_GET['name']}</p>";
      
      // GOOD — the one true incantation; wrap it in a helper e()
      echo '<p>Hello ', htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</p>';
      ```
      
      - `ENT_QUOTES` covers single-quoted attributes; `ENT_SUBSTITUTE` prevents
        invalid-UTF-8 from truncating output; the explicit `'UTF-8'` pins the charset
        (default since 5.4, pin it anyway). Attribute values must also be *quoted* in
        the markup — escaping alone doesn't save `<img src=x onerror=...>` in an
        unquoted attribute.
      - `htmlentities` is not more secure, just noisier; `strip_tags` is not an XSS
        defense (attributes survive, and it destroys data).
      
      **JavaScript context:** never splice into script; pass via JSON with hex flags:
      
      ```php
      <script>
      const cfg = <?= json_encode($cfg, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT
          | JSON_HEX_AMP | JSON_THROW_ON_ERROR) ?>;
      </script>
      ```
      
      **URL context:** `rawurlencode()` for components; validate whole user-supplied
      URLs — scheme allowlist (`https?`) — before emitting in `href` (blocks
      `javascript:`). Redirect targets: allowlist or same-origin check (open
      redirect, and see SSRF in `rules/03`).
      
      **Templates:** use an auto-escaping engine — e.g. Twig (`autoescape` on by
      default) or Blade `{{ }}` — and treat the raw syntaxes (`|raw`, `{!! !!}`,
      `<?= $x ?>` in plain PHP templates) as audit targets: every one needs a proven
      non-user-controlled source. Plain-PHP templates escape via a project-wide `e()`
      helper; naked `<?=` of a variable is a finding until traced.
      
      **Rich text** (user HTML): sanitize with a real allowlist sanitizer — e.g.
      `Symfony\Component\HtmlSanitizer` or HTML Purifier as neutral examples — never
      regex/`strip_tags`.
      
      **Defense in depth:** a strict `Content-Security-Policy` (see `rules/04` §6)
      and `X-Content-Type-Options: nosniff` cap the blast radius; they don't replace
      escaping.
      
      ## 4. Header and log injection
      
      - `header()` rejects CR/LF since PHP 5.1.2 (response splitting), but building
        headers from input still enables open redirects (`Location: $_GET['next']`)
        and cache poisoning — validate/allowlist values.
      - Strip `\r`/`\n` from user data before writing to line-oriented logs, or log
        structured JSON; otherwise attackers forge log entries.
      
      ## Audit checklist
      
      Run from repo root; verify each hit manually (greps are recall-oriented).
      
      ```bash
      # SQL built from strings — CRITICAL if user data reaches it
      grep -rnE '(->query|->exec|_query)\s*\(\s*["'"'"'].*(\$|\bsprintf|\. )' --include='*.php' src/
      grep -rnE '(SELECT|INSERT|UPDATE|DELETE)[^;]*(\{\$|"\s*\.\s*\$|\'\s*\.\s*\$)' --include='*.php' -i src/
      grep -rnE '(whereRaw|selectRaw|orderByRaw|havingRaw|DB::raw|->raw\()' --include='*.php' src/
      grep -rn 'EMULATE_PREPARES' --include='*.php' src/    # want: false
      grep -rn 'real_escape_string' --include='*.php' src/  # HIGH if primary defense
      
      # Shell — CRITICAL with tainted input
      grep -rnE '\b(exec|shell_exec|system|passthru|popen|pcntl_exec)\s*\(' --include='*.php' src/
      grep -rn 'proc_open' --include='*.php' src/           # array command = good sign
      grep -rn '`' --include='*.php' src/ | grep -vE '(//|\*|#)'
      grep -rnE '\b(eval|assert)\s*\(\s*\$' --include='*.php' src/
      
      # XSS — echo/print of request data, raw template sinks
      grep -rnE '(echo|print|<\?=)[^;]*\$_(GET|POST|REQUEST|COOKIE|SERVER)' --include='*.php' .
      grep -rnE '<\?=\s*\$(?!this)' --include='*.php' templates/ 2>/dev/null
      grep -rn '{!!' --include='*.blade.php' resources/ 2>/dev/null
      grep -rn '|raw' --include='*.twig' templates/ 2>/dev/null
      grep -rn 'strip_tags' --include='*.php' src/          # not an XSS defense
      
      # Header/redirect injection
      grep -rnE 'header\s*\(\s*["'"'"']Location:.*\$' --include='*.php' src/
      
      # json_encode into <script> without hex flags
      grep -rn 'json_encode' --include='*.php' src/ | grep -v 'JSON_HEX'
      ```
      
      Severity guide: interpolated SQL or shell with user input CRITICAL; unescaped
      output of request data HIGH; raw template sink with untraced source HIGH until
      proven benign; missing hex flags on script-embedded JSON MEDIUM; escaping at
      input time instead of output MEDIUM (design).
      
    • 03-files-deserialization-ssrf.md 9.5 KB
      # 03 — Files, deserialization, and SSRF
      
      PHP's include system, stream wrappers, and native serialization form one
      connected attack surface: a "harmless" file path becomes code execution via
      `include`, `phar://`, or `unserialize`. Treat every user-influenced path, file,
      and URL as hostile.
      
      ## 1. File uploads: validate content, own the name, deny execution
      
      Never trust anything the client sent: `$_FILES[...]['name']` and `['type']` are
      attacker-chosen. (OWASP File Upload Cheat Sheet.)
      
      ```php
      $f = $_FILES['avatar'] ?? null;
      if ($f === null || $f['error'] !== UPLOAD_ERR_OK) { /* reject */ }
      if (!is_uploaded_file($f['tmp_name'])) { /* reject */ }
      if ($f['size'] > 2 * 1024 * 1024) { /* reject */ }
      
      // 1. Content-derived type, allowlist only
      $mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']);
      $ext  = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime]
          ?? throw new RuntimeException('unsupported type');
      
      // 2. Server-generated name — client filename is display metadata at most
      $name = bin2hex(random_bytes(16)) . '.' . $ext;
      
      // 3. Non-executable destination, outside the webroot
      move_uploaded_file($f['tmp_name'], '/srv/app/storage/uploads/' . $name);
      ```
      
      - **Storage:** outside the document root, served via a controlled handler
        (with `Content-Type` you set, `Content-Disposition`, `X-Content-Type-Options:
        nosniff`) or from object storage/a separate cookieless domain. If files must
        live under the webroot, the directory gets *no PHP execution* (webserver
        config: no handler/`php_admin_flag engine off` equivalent) — double
        extensions (`x.php.jpg`), trailing-dot and case tricks defeat blocklists.
      - Extension **allowlist** derived from sniffed content, never the client name;
        reject polyglot-prone types you don't need (SVG = XSS vector unless
        sanitized). For images, re-encoding (GD/Imagick) strips embedded payloads —
        but keep Imagick patched (historic RCEs) and consider it a trade-off, not
        free.
      - Archives: extraction is a traversal vector ("zip slip") — validate each entry
        name against the target dir before writing; cap entry count/size ratios
        (zip bombs).
      - ini caps (`upload_max_filesize`, `post_max_size`, `max_file_uploads`) are the
        outer DoS guard (OWASP PHP Configuration Cheat Sheet), not validation.
      
      ## 2. Path traversal, LFI/RFI, and stream wrappers
      
      Any `include`/`require`/`fopen`/`file_get_contents`/`readfile` whose path is
      user-influenced is a code-execution candidate, not "just" file disclosure.
      
      ```php
      // BAD — CRITICAL: LFI, and with allow_url_include, RFI
      include $_GET['page'] . '.php';
      readfile('/var/reports/' . $_GET['name']);
      
      // GOOD — closed set: map input to known files
      $page = ['home' => 'home.php', 'about' => 'about.php'][$_GET['page']] ?? 'home.php';
      include __DIR__ . '/pages/' . $page;
      
      // GOOD — dynamic filenames: canonicalize, then prove containment
      $base = '/var/reports/';
      $real = realpath($base . basename($_GET['name']));
      if ($real === false || !str_starts_with($real, $base)) {
          throw new RuntimeException('invalid path');
      }
      readfile($real);
      ```
      
      - Prefer allowlist maps over sanitizing. When sanitizing: `basename()` to drop
        directories, `realpath()` to resolve `..` and symlinks, then a prefix check
        against the canonical base (with trailing separator).
      - **Stream wrappers escalate LFI:** `php://filter` (source disclosure via
        base64 chains), `phar://` (deserialization, §3), `data://`, `expect://`,
        `zip://`. Reject any user path containing `://`, or `parse_url` scheme-check
        it. Functions beyond include are affected — `file_exists('phar://…')` used
        to be enough pre-8.0 (§3).
      - ini: `allow_url_include=Off` (default off since 7.4 deprecation; removed as a
        real option risk — keep it off), and `allow_url_fopen=Off` unless remote
        fetching is genuinely needed (OWASP PHP Configuration Cheat Sheet);
        `open_basedir` as a coarse second fence.
      
      ## 3. Deserialization: unserialize() is code execution; JSON is data
      
      `unserialize()` on attacker data = **PHP object injection**: instantiated
      objects fire `__destruct`/`__wakeup`/`__toString`, and public gadget-chain
      catalogs (e.g. the phpggc project) cover major frameworks and libraries. Assume
      a chain exists for your dependency graph.
      
      ```php
      // BAD — CRITICAL on any external data (cookies, hidden fields, cache, queues)
      $prefs = unserialize($_COOKIE['prefs']);
      
      // GOOD — external data is JSON
      $prefs = json_decode($cookie, associative: true, flags: JSON_THROW_ON_ERROR);
      
      // If a legacy format forces unserialize: cap the damage AND authenticate first
      $data = unserialize($raw, ['allowed_classes' => false]);          // scalars/arrays only
      $data = unserialize($raw, ['allowed_classes' => [Point::class]]); // tight allowlist
      ```
      
      - `allowed_classes => false` (7.0+) blocks object instantiation but not all
        DoS shapes; it is the floor, not the fix. The fix is JSON (or a schema-ed
        format) plus validation.
      - Data that must round-trip internally (cache, queues) still transits
        attacker-reachable systems — sign it: `hash_hmac('sha256', $payload, $key)`
        verified with `hash_equals()` *before* deserializing.
      - **Phar:** a `.phar`'s metadata is a serialized blob. Since **PHP 8.0**, the
        `phar://` wrapper no longer auto-unserializes metadata on file operations —
        only `Phar->getMetadata()` does (PHP RFC: phar_stop_autoloading_metadata,
        accepted 25-0). Pre-8.0, `file_exists('phar://upload.jpg')` was an RCE
        primitive. Still: never call `getMetadata()` on untrusted archives (8.0+
        accepts an `unserializeOptions` allowlist argument), and keep §2's wrapper
        filtering so uploads are never addressed as `phar://`.
      - Same family: `wddx` (removed 7.4), reading `serialize()`d session/cache blobs
        populated by less-trusted code.
      
      ## 4. XML: XXE and entity expansion
      
      - Since **PHP 8.0**, libxml external entity loading is disabled by default
        (requires libxml ≥ 2.9; `libxml_disable_entity_loader()` is deprecated
        because it became unnecessary — php.net migration80). Do not re-enable.
      - Never parse untrusted XML with `LIBXML_NOENT` (entity substitution) or
        `LIBXML_DTDLOAD`. Audit any occurrence as HIGH.
      - Billion-laughs/entity expansion: reject DTDs outright on untrusted input
        (`$dom->loadXML($xml, LIBXML_NONET)` and check `$dom->doctype === null`).
      
      ## 5. SSRF: server-side fetching of user-influenced URLs
      
      A URL fetched by the server reaches things the user can't: cloud metadata
      (`169.254.169.254`), localhost admin ports, internal services. (OWASP Server
      Side Request Forgery Prevention Cheat Sheet.)
      
      ```php
      function assertSafeUrl(string $url): void
      {
          $p = parse_url($url);
          if (!in_array($p['scheme'] ?? '', ['http', 'https'], true)) fail();
          $ips = gethostbynamel($p['host'] ?? '') ?: [];
          foreach ($ips as $ip) {
              if (filter_var($ip, FILTER_VALIDATE_IP,
                  FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) fail();
          }
      }
      ```
      
      - Prefer a **positive allowlist** of hosts/URL prefixes; IP-range blocklists
        are the fallback and must run on the *resolved* address, cover IPv6
        (`::1`, `fe80::`, mapped IPv4), and beware DNS rebinding (resolve once, pin
        via `CURLOPT_RESOLVE`).
      - cURL hardening: `CURLOPT_PROTOCOLS`/`CURLOPT_REDIR_PROTOCOLS` limited to
        HTTP(S) — redirects can bounce to `gopher://`/`file://`; cap
        `CURLOPT_MAXREDIRS` or re-validate each hop; set timeouts; **never**
        `CURLOPT_SSL_VERIFYPEER => false` (HIGH).
      - The strongest control is architectural: route egress through a proxy that
        enforces the allowlist (network-level, see sota-network-security), so a
        missed validation isn't fatal.
      - `file_get_contents($url)`/`fopen` honor redirects with no protocol pinning —
        use a real HTTP client for remote fetches.
      
      ## Audit checklist
      
      Run from repo root; verify each hit manually.
      
      ```bash
      # Uploads — client-trusted name/type, executable destinations
      grep -rnE "\\\$_FILES\[[^]]+\]\['(name|type)'\]" --include='*.php' src/
      grep -rn 'move_uploaded_file' --include='*.php' src/   # trace dest: webroot? renamed?
      grep -rn 'is_uploaded_file' --include='*.php' src/     # absent near move_* = MEDIUM
      
      # LFI/RFI/traversal — user data reaching include/fs functions
      grep -rnE '(include|require)(_once)?\s*[( ][^;]*\$_(GET|POST|REQUEST|COOKIE)' --include='*.php' src/
      grep -rnE '(file_get_contents|fopen|readfile|file_put_contents|copy|unlink)\s*\([^;]*\$_' --include='*.php' src/
      grep -rnE '(phar|expect|data|zip)://' --include='*.php' src/
      grep -rn 'php://filter' --include='*.php' src/
      php -r 'echo ini_get("allow_url_include"), "|", ini_get("allow_url_fopen"), PHP_EOL;'
      
      # Deserialization — CRITICAL on external data
      grep -rn 'unserialize(' --include='*.php' src/ | grep -v 'allowed_classes'
      grep -rnE 'unserialize\s*\(\s*\$_(GET|POST|COOKIE|REQUEST)' --include='*.php' src/
      grep -rn 'getMetadata' --include='*.php' src/
      grep -rnE '__(destruct|wakeup|toString)' --include='*.php' src/  # gadget surface inventory
      
      # XML
      grep -rnE 'LIBXML_(NOENT|DTDLOAD)' --include='*.php' src/
      grep -rn 'libxml_disable_entity_loader' --include='*.php' src/   # deprecated; check PHP<8 paths
      
      # SSRF — user URLs fetched server-side
      grep -rnE '(curl_init|file_get_contents|fopen|->request|->get)\s*\([^;]*\$' --include='*.php' src/ | grep -iE 'url|uri|host|endpoint|webhook'
      grep -rn 'CURLOPT_SSL_VERIFYPEER' --include='*.php' src/          # false = HIGH
      grep -rn 'CURLOPT_FOLLOWLOCATION' --include='*.php' src/          # check REDIR_PROTOCOLS nearby
      ```
      
      Severity guide: `unserialize`/`include` of external data CRITICAL; uploads
      executable or client-named HIGH; user-URL fetch with no allowlist/IP validation
      HIGH (CRITICAL when cloud metadata is reachable); `LIBXML_NOENT` on untrusted
      XML HIGH; missing `is_uploaded_file` MEDIUM.
      
    • 04-sessions-auth-web-hardening.md 9 KB
      # 04 — Sessions, auth, crypto, and web hardening
      
      Framework-neutral: whether sessions come from raw `session_start()` or a
      framework layer (e.g. Laravel, Symfony), the same properties must hold — verify
      them in *effective* config, not defaults you assume.
      
      ## 1. Session hardening
      
      Required ini/effective settings (OWASP Session Management + PHP Configuration
      Cheat Sheets; php.net session security manual):
      
      ```ini
      session.use_strict_mode  = 1       ; reject attacker-supplied (uninitialized) IDs
      session.use_only_cookies = 1       ; never accept IDs from URLs
      session.cookie_secure    = 1       ; HTTPS-only cookie
      session.cookie_httponly  = 1       ; no JS access
      session.cookie_samesite  = Lax     ; Strict where UX allows
      session.sid_length       = 48      ; entropy of the ID (pre-8.4 tunable)
      ```
      
      - `use_strict_mode=1` is the **session fixation** kill switch — without it, PHP
        happily adopts any ID the attacker planted. Default is 0; always set it.
      - **Regenerate on privilege change:** `session_regenerate_id(true)` immediately
        after login, logout, and role elevation. The `true` deletes the old session
        file; without regeneration, a pre-login ID stays valid post-login (fixation).
      - Logout destroys server state: `$_SESSION = []; session_destroy();` plus
        expiring the cookie — not just a client-side redirect.
      - Implement **idle timeout and absolute lifetime** in app logic (timestamps in
        the session); `gc_maxlifetime` is garbage collection, not access control.
      - Bind sessions loosely to context (IP /24 or UA family) only if your users
        tolerate it; log mismatches either way.
      - Never put secrets, roles, or prices in cookies/hidden fields; the session ID
        is the only client-held session artifact. Custom session storage (e.g. Redis)
        keeps the same rules.
      
      ## 2. Passwords: password_hash, nothing homemade
      
      ```php
      $hash = password_hash($password, PASSWORD_DEFAULT);      // bcrypt today
      // or, when compiled with Argon2 (or via libsodium):
      $hash = password_hash($password, PASSWORD_ARGON2ID);
      
      if (!password_verify($password, $hash)) fail();
      if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
          // transparently upgrade cost/algorithm at successful login
          store(password_hash($password, PASSWORD_DEFAULT));
      }
      ```
      
      Verified against php.net password_hash (2026-07):
      
      - `PASSWORD_DEFAULT` = bcrypt; **default bcrypt cost rose 10 → 12 in PHP 8.4**.
        The constant is designed to change — store hashes in the self-describing
        `$2y$`/`$argon2id$` format (password_hash does) and rely on
        `password_needs_rehash` for migrations.
      - `PASSWORD_ARGON2ID` exists since 7.3 and requires Argon2 support compiled in
        (libargon2, or the sodium implementation since 7.4). OWASP Password Storage
        Cheat Sheet ranks argon2id first, bcrypt as the solid default; both are fine —
        `md5`/`sha1`/`sha256(+salt)`/`crypt()` for passwords are HIGH findings.
      - bcrypt truncates at 72 bytes; since 8.4 PHP rejects longer inputs with
        `ValueError` rather than silently truncating — cap length in validation.
      - Reset/verification tokens: `bin2hex(random_bytes(32))`, stored **hashed**
        (`hash('sha256', $token)`), single-use, short TTL.
      - Compare any secret (tokens, HMACs, API keys) with **`hash_equals($known,
        $user)`** — `==`/`===` are timing-unsafe and `==` also has magic-hash
        juggling traps (`rules/01` §4).
      - Rate-limit and lock out at the auth boundary; log failures. MFA/passkey and
        IdP architecture → sota-identity-access; app-level flows → sota-code-security.
      
      ## 3. General crypto: sodium first, random_bytes always
      
      - **CSPRNG:** `random_bytes()` / `random_int()` only. `rand`, `mt_rand`,
        `array_rand`, `str_shuffle`, `uniqid()` (timestamp-based, even with
        `more_entropy`) are predictable — HIGH wherever the value gates anything.
        8.2+ `Random\Randomizer` with `Random\Engine\Secure` is fine (same source).
      - **Authenticated encryption:** libsodium is in core since PHP 7.2 —
        `sodium_crypto_secretbox` (symmetric), `sodium_crypto_aead_xchacha20poly1305_ietf_*`,
        `sodium_crypto_box`/`sign` (asymmetric). New nonce per message
        (`random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)`), keys from a secret
        manager, `sodium_memzero()` after use.
      - If constrained to OpenSSL: AEAD only (`openssl_encrypt` with `aes-256-gcm`,
        checking/passing `$tag`); CBC without an HMAC is a padding-oracle finding.
        `mcrypt` was removed in 7.2 — its presence means abandoned code.
      - Key derivation from passwords: `sodium_crypto_pwhash` or argon2id — never a
        bare hash of the password as key material.
      - JWTs and OAuth flows: use a maintained library (e.g. lcobucci/jwt,
        web-token) — alg allowlist, `none` rejected; details in sota-code-security.
      
      ## 4. CSRF
      
      State-changing requests need a CSRF defense; `SameSite` cookies are strong but
      not sufficient alone (subdomain and same-site-scripting caveats — OWASP CSRF
      Prevention Cheat Sheet recommends token + SameSite).
      
      - Use the framework mechanism where present (e.g. Symfony form tokens, Laravel
        `@csrf`) — audit for routes excluded from verification.
      - Hand-rolled: synchronizer token — `bin2hex(random_bytes(32))` in the session,
        embedded per-form, compared with `hash_equals`, rotated on login. Verify on
        every non-GET route, centrally (middleware), not per-handler.
      - GET must never mutate state (also a CSRF hole via `<img src>`).
      
      ## 5. Production php.ini hardening
      
      Per the OWASP PHP Configuration Cheat Sheet:
      
      ```ini
      display_errors = Off               ; stack traces/paths leak internals
      display_startup_errors = Off
      log_errors = On
      expose_php = Off                   ; drop X-Powered-By
      allow_url_include = Off
      allow_url_fopen = Off              ; unless remote fetch is a real requirement
      open_basedir = /srv/app            ; coarse containment fence
      disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
                                         ; tailor to what the app truly needs
      ```
      
      - `disable_functions` is defense in depth against webshells/RCE pivots — build
        the list from what the app *doesn't* use, and keep CLI workers on a separate
        ini if they need more.
      - Run PHP-FPM as a dedicated non-root user; app files not writable by that
        user (a writable webroot turns any file-write bug into RCE); secrets in env/
        secret manager, not in webroot files (`.env` must be denied by the webserver
        — better, outside the docroot entirely).
      - Uncaught exceptions must map to a generic 500 page; the chain goes to logs
        only (`rules/01` §5).
      
      ## 6. Security headers
      
      Set centrally (middleware or webserver), not per-page:
      
      - `Content-Security-Policy` — nonce/hash-based `script-src`, `object-src
        'none'`, `frame-ancestors` (replaces `X-Frame-Options`); start
        `Content-Security-Policy-Report-Only`.
      - `Strict-Transport-Security: max-age=31536000; includeSubDomains` once HTTPS
        is universal; cookies also `__Host-` prefixed where possible.
      - `X-Content-Type-Options: nosniff`, `Referrer-Policy:
        strict-origin-when-cross-origin`, restrictive `Permissions-Policy`.
      - CORS: explicit origin allowlist; `Access-Control-Allow-Origin: *` with
        credentials is invalid anyway — reflecting arbitrary Origins with
        credentials is the real-world HIGH.
      
      ## Audit checklist
      
      Run from repo root; verify each hit manually.
      
      ```bash
      # Session config — effective values, not file greps alone
      php -r 'foreach (["use_strict_mode","use_only_cookies","cookie_secure","cookie_httponly","cookie_samesite"] as $k) echo "session.$k=", ini_get("session.$k"), PHP_EOL;'
      grep -rn 'session_regenerate_id' --include='*.php' src/   # absent near login = HIGH
      grep -rnE 'session_id\s*\(\s*\$' --include='*.php' src/   # attacker-settable ID
      
      # Password handling
      grep -rnE '\b(md5|sha1|crypt)\s*\(' --include='*.php' src/ | grep -iE 'pass|pwd'
      grep -rn 'password_hash' --include='*.php' src/
      grep -rn 'password_needs_rehash' --include='*.php' src/   # absent = MEDIUM (stuck costs)
      
      # Weak randomness / timing-unsafe compares — HIGH where security-relevant
      grep -rnE '\b(rand|mt_rand|uniqid|str_shuffle|array_rand)\s*\(' --include='*.php' src/
      grep -rnE '(===?)\s*\$.*(token|signature|hmac|hash)' -i --include='*.php' src/
      grep -rn 'hash_equals' --include='*.php' src/
      
      # Crypto
      grep -rn 'mcrypt' --include='*.php' src/                  # removed 7.2 — abandoned code
      grep -rnE "openssl_encrypt\([^)]*(cbc|ecb)" -i --include='*.php' src/
      grep -rn 'sodium_crypto' --include='*.php' src/
      
      # CSRF — token verified centrally? exclusions?
      grep -rnE '(csrf|_token)' -il --include='*.php' src/ | head
      grep -rn 'VerifyCsrfToken' -r app/ 2>/dev/null            # e.g. Laravel: check $except
      
      # ini hardening + headers
      php -r 'foreach (["display_errors","expose_php","allow_url_include","allow_url_fopen","open_basedir","disable_functions"] as $k) echo "$k=", ini_get($k), PHP_EOL;'
      curl -sI https://target/ | grep -iE 'content-security|strict-transport|x-content-type|x-powered-by'
      ```
      
      Severity guide: fixation (no strict mode + no regeneration) HIGH; md5/sha1
      passwords HIGH; predictable tokens HIGH; missing CSRF on state change HIGH;
      `display_errors=On` in prod MEDIUM (HIGH if traces confirmed reaching users);
      missing CSP/headers MEDIUM.
      
    • 05-composer-tooling.md 7 KB
      # 05 — Composer, static analysis, and CI
      
      The PHP supply chain is Composer + Packagist; code quality is enforced by
      static analysis, not convention. A project without a committed lockfile, an
      audit gate, and PHPStan/Psalm in CI is unreviewed by 2026 standards.
      
      ## 1. Composer discipline
      
      - **Apps commit `composer.lock`.** CI and prod run `composer install` — which
        installs the exact locked versions — never `composer update`, which
        re-resolves and rewrites the lock (getcomposer.org CLI docs). Updates are
        deliberate PRs with a lock diff.
      - Libraries don't ship a lock to consumers but should still test against
        lowest and highest supported dependency sets
        (`composer update --prefer-lowest` in one CI job).
      - **Production install:**
      
      ```sh
      composer install --no-dev --prefer-dist --no-interaction --no-progress \
        --optimize-autoloader
      composer check-platform-reqs   # ext-* and PHP version actually present?
      ```
      
        `--no-dev` keeps test/dev tooling out of the artifact (attack surface +
        size); `--optimize-autoloader` see `rules/06` §5.
      - **Platform requirements:** declare the PHP floor and every extension in
        `require` (`"php": "^8.3", "ext-pdo": "*", "ext-sodium": "*"`); set
        `config.platform.php` to the floor so resolution on a dev machine with a
        newer PHP can't pull packages the servers can't run (getcomposer.org docs).
      - **Plugin/script surface:** Composer plugins can run arbitrary code at install
        time; the `allow-plugins` config must be an explicit allowlist (Composer ≥2.2
        prompts by default). Review `scripts` in composer.json diffs like code.
      - Version constraints: `^` ranges (semver), never `*` or `dev-master`; pin a
        commit hash when depending on a VCS fork.
      
      ## 2. composer audit and advisory gates
      
      `composer audit` checks installed (or `--locked`) packages against security
      advisories via the Packagist API, and also reports abandoned packages; exit
      code is non-zero when issues are found — CI-gateable (getcomposer.org CLI
      docs):
      
      ```sh
      composer audit --locked --abandoned=fail   # in CI, on every PR + nightly
      ```
      
      - `--format=json` for tooling; `--ignore-severity` exists but each ignore needs
        a tracked justification.
      - Abandoned packages are a real risk class (unpatched forever) — at minimum
        `--abandoned=report` and a migration ticket per hit.
      - Alternative/complementary: requiring `roave/security-advisories` (neutral
        example) makes *installing* a known-vulnerable version impossible at resolve
        time.
      - Renovate/Dependabot-style automation keeps the lock fresh; pair with the
        audit gate so urgency is advisory-driven, not calendar-driven.
      
      ## 3. Static analysis: PHPStan or Psalm, gating CI
      
      **PHPStan** has rule levels **0–10**; 10 (added in PHPStan 2.0) also flags
      *implicit* `mixed` — missing types — not just explicit ones
      (phpstan.org/user-guide/rule-levels).
      
      ```neon
      # phpstan.neon (or phpstan.dist.neon)
      parameters:
          level: 10          # new code: max; legacy: highest level that holds
          paths: [src, tests]
      ```
      
      - **New projects start at the max level.** Legacy projects: pick the highest
        level, generate a baseline (`phpstan analyse --generate-baseline`), and
        enforce the **ratchet**: the baseline file only ever shrinks. A growing
        baseline means the gate is theater — MEDIUM finding.
      - Don't silence with `@phpstan-ignore` casually; each ignore carries a reason
        string. Prefer fixing types; use generics PHPDoc
        (`@template`, `array<int, Order>`) so collections stay typed.
      - **Psalm** is the equivalent alternative (note its levels run inverted:
        1 = strictest, 8 = loosest). Run one of them, not both, at the strictest
        sustainable setting; `--taint-analysis` mode (Psalm) is a useful audit
        supplement for injection tracing.
      - The type checker runs on PRs against the same PHP version(s) as prod, with
        extensions available (or stubs configured).
      
      ## 4. Style and tests
      
      - **Formatting is automated, not reviewed:** PHP-CS-Fixer or PHP_CodeSniffer
        (neutral examples) pinned to the **PER-CS** ruleset (PHP-FIG's successor to
        PSR-12; PER-CS 2.x current — php-fig.org). `--dry-run --diff` in CI; local
        fix via pre-commit/composer script.
      - **Tests:** PHPUnit is the baseline; Pest (neutral example) layers a concise
        syntax on the same runner. Either way: data providers over copy-paste, one
        behavior per test, no order dependence, `assertSame` over `assertEquals`
        (strict comparison). Test *strategy* — suite shape, doubles, flake policy —
        lives in `sota-testing`.
      - Coverage needs a driver: pcov (fast, coverage-only) or Xdebug in coverage
        mode — CI-only; never on prod (`rules/06` §6).
      - Mutation testing (e.g. Infection) as a periodic quality probe on core
        domains, not a per-PR gate.
      
      ## 5. CI pipeline: the minimum gate set
      
      Every PR, in rough dependency order:
      
      ```sh
      composer validate --strict          # composer.json sanity + lock in sync
      composer install --no-interaction   # from lock, cached
      composer audit --locked             # advisories + abandoned
      vendor/bin/php-cs-fixer check --diff
      vendor/bin/phpstan analyse --no-progress
      vendor/bin/phpunit                  # or: vendor/bin/pest
      ```
      
      - **Matrix:** run tests on every PHP minor the code claims to support
        (`require.php`), floor *and* current — a `^8.2` constraint tested only on
        8.5 is untested advertising.
      - Nightly job re-runs `composer audit` (advisories land independent of
        commits).
      - Pipeline/runner/secrets hardening (pinned actions, OIDC, SLSA) →
        sota-devsecops; this file owns only the PHP-specific gates.
      
      ## Audit checklist
      
      Run from repo root; verify each hit manually.
      
      ```bash
      # Lockfile discipline
      git ls-files composer.lock | grep -q . || echo "NO LOCKFILE COMMITTED (app = MEDIUM/HIGH)"
      composer validate --strict                      # flags json/lock drift
      grep -nE '"(php|ext-)' composer.json            # platform reqs declared?
      grep -n '"platform"' composer.json              # config.platform.php pinned?
      
      # Advisory + abandonment status right now
      composer audit --locked --abandoned=report
      
      # Risky constraints and install-time code
      grep -nE '"[^"]+"\s*:\s*"(\*|dev-)' composer.json
      grep -n '"scripts"' composer.json               # review script contents
      grep -n 'allow-plugins' composer.json           # explicit allowlist?
      
      # Static analysis presence + ratchet health
      ls phpstan*.neon* psalm*.xml* 2>/dev/null | grep -q . || echo "NO STATIC ANALYSIS CONFIG (MEDIUM)"
      grep -n 'level' phpstan*.neon* 2>/dev/null
      wc -l phpstan-baseline.neon 2>/dev/null         # compare against last audit: shrinking?
      git log --oneline -5 -- phpstan-baseline.neon 2>/dev/null
      
      # CI gates actually wired (adjust path to CI system)
      grep -rnE '(composer audit|phpstan|psalm|php-cs-fixer|phpcs|phpunit|pest)' .github/workflows/ .gitlab-ci.yml 2>/dev/null
      
      # Dev deps leaking into prod artifacts
      grep -rn 'composer install' Dockerfile* .github/workflows/ 2>/dev/null | grep -v -- --no-dev
      ```
      
      Severity guide: app with no committed lock or CI running `composer update`
      MEDIUM (HIGH once envs drift); no advisory gate MEDIUM; known-vulnerable dep
      currently installed HIGH/CRITICAL per advisory; no static analysis MEDIUM;
      growing baseline MEDIUM; dev deps in prod image LOW/MEDIUM.
      
    • 06-performance-runtime.md 8.5 KB
      # 06 — Performance & runtime: OPcache, FPM, JIT, profiling
      
      PHP performance is mostly *runtime configuration and I/O shape*, not
      micro-optimization: OPcache on, FPM sized to memory, queries not multiplied by
      loops. Measure before optimizing; a profile is the entry ticket for any
      optimization PR.
      
      ## 1. OPcache: non-negotiable in production
      
      Without OPcache every request re-lexes and re-compiles every file. Verified
      against php.net OPcache configuration docs (2026-07):
      
      ```ini
      opcache.enable = 1
      opcache.memory_consumption = 256      ; default 128 MB — size to the codebase,
                                            ; interned strings come out of this total
      opcache.max_accelerated_files = 20000 ; > count of .php files (vendor included)
      opcache.validate_timestamps = 0       ; immutable deploys: never stat files
      opcache.interned_strings_buffer = 16
      ```
      
      - `validate_timestamps=0` requires an OPcache reset on deploy — restart FPM or
        atomic-symlink switch with new realpaths. If deploys overwrite files in
        place, keep timestamps on (`revalidate_freq` low) or serve stale code.
      - Monitor `opcache_get_status()`: cache full, `oom_restarts`, low hit rate are
        silent performance cliffs; alert on them.
      - CLI workers: `opcache.enable_cli=1` only pays off for long-running processes.
      
      ## 2. Preloading (7.4+)
      
      `opcache.preload=/srv/app/preload.php` compiles chosen files once at FPM
      startup, linked into every worker — removing per-request autoload/compile for
      the hot core (php.net opcache.preload).
      
      - Preload the stable hot path (framework kernel, core domain classes), not all
        of `vendor/` — preloaded code is held **until server restart**; changing it
        requires an FPM restart, and it's shared across all pools of the process
        (php.net warns against preloading in shared/multi-tenant setups).
      - Not supported on Windows (php.net).
      - Measure: preloading typically buys a few percent on framework-heavy stacks —
        worth it at scale, not worth operational complexity for small apps.
      
      ## 3. JIT: reality check
      
      Verified against php.net OPcache configuration docs (2026-07): as of PHP 8.4
      the defaults are `opcache.jit=disable` with `opcache.jit_buffer_size=64M`
      reserved — i.e. JIT ships **off**; enabling is a deliberate act
      (`opcache.jit=tracing`, the recommended mode).
      
      - **Typical web workloads are I/O-bound** (DB, cache, HTTP): JIT cannot
        optimize waiting, and published benchmarks of framework request paths show
        small single-digit gains at best. Don't expect throughput wins from flipping
        it on.
      - JIT shines on CPU-bound PHP: numeric loops, image/data processing,
        long-running CLI workers with typed hot functions.
      - If enabled: benchmark your actual workload before/after, watch memory
        (buffer is shared memory), and re-test after PHP upgrades — JIT bugs
        historically surface as heisencrashes. Debuggers/profilers may need JIT off.
      - AUDIT: `opcache.jit` enabled with no benchmark justification is INFO;
        claiming "JIT will fix it" for an I/O-bound app is a wrong-tool finding.
      
      ## 4. PHP-FPM sizing and hygiene
      
      FPM concurrency is the worker count — the classic failure is `pm.max_children`
      set by folklore, either OOM-killing the box or queueing requests while RAM
      sits idle.
      
      ```ini
      pm = static                       ; dedicated app servers; dynamic/ondemand for
                                        ; shared or spiky/low-traffic hosts
      pm.max_children = 40              ; = (RAM budget for PHP) / (avg worker RSS)
      pm.max_requests = 1000            ; recycle workers to cap leak accumulation
      pm.status_path = /fpm-status      ; scrape it: active/idle, listen queue
      request_slowlog_timeout = 5s      ; slowlog with stack traces of slow requests
      slowlog = /var/log/php-fpm/slow.log
      ```
      
      - Measure average worker RSS under real traffic (`ps` on pool workers), leave
        headroom for OPcache shared memory + everything else on the host.
      - `listen.backlog` and the status page's `listen queue` reveal saturation
        before users do; alert on queue > 0 sustained.
      - One pool per app, distinct user per pool (also a security boundary,
        `rules/04` §5).
      - **Session locking:** file-backed sessions serialize concurrent requests per
        user; call `session_write_close()` as soon as writes are done, or use a
        store with proper locking semantics.
      - Long-running CLI/queue workers: recycle on a request/memory budget
        (`--max-jobs`-style flags or supervisor restarts), reconnect DB on failure,
        and remember: no OPcache revalidation surprise, but also no automatic code
        reload after deploys — restart workers on every deploy.
      
      ## 5. Application-level: N+1, caching, autoloading
      
      - **N+1 queries** dominate real PHP slowness. One query per loop iteration =
        finding. Fix with eager loading / joins (e.g. Eloquent `with()`, Doctrine
        `JOIN` fetch or `EXTRA_LAZY` deliberately). Detect: query logging in dev,
        a query-count assertion in tests for hot endpoints, APM span counts in prod.
        Index/schema depth → sota-databases.
      - **Cache layers, by scope:** OPcache (bytecode) → APCu (per-host, in-memory,
        no network — great for config/feature flags) → Redis/Memcached (shared,
        neutral examples). Every cache entry gets a TTL and a stampede story
        (lock-or-stale) on hot keys.
      - **Autoloader:** production runs `composer install --optimize-autoloader`
        (classmap generation); `--classmap-authoritative` when the deploy is truly
        immutable — skips filesystem checks for unknown classes
        (getcomposer.org autoloader optimization docs).
      - `realpath_cache_size` (default 4M since 7.x) matters for large vendor trees
        on stat-heavy configs; with `validate_timestamps=0` it's mostly moot.
      - Streams and generators (`yield`) for large datasets — `fetchAll()` on
        unbounded result sets and building giant arrays in memory are HIGH on
        memory-constrained workers; `->fetch()` loops / cursors / chunked processing
        instead.
      - `usleep`/HTTP calls inside request loops, un-batched external API calls:
        same N+1 pathology, worse latency multiplier.
      
      ## 6. Profiling: measure, don't guess
      
      - **Xdebug is a development tool**: step debugging and coverage. Its overhead
        (even in `develop`/profile modes) disqualifies it for production — never
        enabled there (also an information-exposure risk).
      - **Production:** sampling profilers/APM designed for prod — e.g. Excimer
        (low-overhead sampling), Blackfire, Tideways, or OpenTelemetry
        auto-instrumentation as neutral examples. Continuous sampling beats one-off
        local benchmarks because it sees real data shapes.
      - Workflow: profile → find the top exclusive-time frames → fix the biggest →
        re-profile. Optimizations without before/after numbers don't merge.
      - Micro-benchmarks: PHP's timers (`hrtime(true)`) with warmup and OPcache on,
        or phpbench (neutral example); beware measuring the JIT/opcache cold path.
      
      ## Audit checklist
      
      Run from repo root / against the runtime; verify each hit manually.
      
      ```bash
      # OPcache posture — the single highest-leverage check
      php -r 'var_export(function_exists("opcache_get_status") ? (opcache_get_status(false)["opcache_statistics"] ?? opcache_get_status(false)) : "OPCACHE MISSING");'
      php -r 'foreach (["enable","memory_consumption","max_accelerated_files","validate_timestamps","preload","jit","jit_buffer_size"] as $k) echo "opcache.$k=", ini_get("opcache.$k"), PHP_EOL;'
      # validate_timestamps=1 on immutable deploys = LOW perf debt; cache-full/oom_restarts>0 = MEDIUM
      
      # FPM sizing
      grep -rnE '^(pm|pm\.max_children|pm\.max_requests|request_slowlog_timeout)' /etc/php*/fpm/pool.d/ 2>/dev/null
      # max_children default-ish (5) on a production box, or no slowlog = MEDIUM
      
      # N+1 / query-in-loop heuristics — confirm by reading the loop
      grep -rnE '(foreach|while)[^{]*\{[^}]*->(query|prepare|find|get)\(' --include='*.php' src/ | head
      grep -rn 'fetchAll' --include='*.php' src/        # unbounded result sets?
      grep -rnE '(curl_exec|file_get_contents\s*\(\s*.http)' --include='*.php' src/  # HTTP in loops?
      
      # Autoloader optimization in the deploy path
      grep -rn 'optimize-autoloader\|classmap-authoritative\|-o ' Dockerfile* deploy* .github/workflows/ 2>/dev/null
      
      # Xdebug in production (HIGH if confirmed on prod hosts)
      php -m | grep -i xdebug
      
      # Session lock hygiene on slow endpoints
      grep -rn 'session_write_close' --include='*.php' src/
      
      # Performance claims without measurements — check PR/commit rationale
      git log --oneline --grep='perf\|optimi' -10
      ```
      
      Severity guide: OPcache off in production HIGH (perf); Xdebug on production
      HIGH; FPM sized by default/folklore causing OOM or queueing MEDIUM–HIGH;
      confirmed N+1 on hot path MEDIUM; missing autoloader optimization LOW;
      unmeasured optimization PRs INFO (process).
      
  • SKILL.md 8.4 KB
    ---
    name: sota-php
    description: >-
      State-of-the-art PHP engineering (2026 baseline, PHP 8.3+ floor) for both
      writing new PHP and auditing existing PHP code. Covers strict_types and modern idioms
      (enums, readonly, match, fibers, property hooks), OWASP-grade security (SQL injection,
      XSS, file uploads, LFI/RFI, unserialize/Phar object injection, sessions, password
      hashing, sodium, SSRF), framework-neutral web hardening, Composer supply chain and
      static analysis (PHPStan/Psalm levels, baselines), and runtime performance (OPcache,
      preloading, FPM tuning, JIT, N+1). Use whenever the task involves PHP source,
      composer.json, php.ini, FPM config, or a PHP framework — building features,
      scaffolding projects, reviewing PRs, or hunting bugs and vulnerabilities.
      Trigger keywords: PHP, composer, Laravel, Symfony, WordPress, PHPStan, Psalm, PHPUnit,
      Pest, PDO, php-fpm, OPcache, strict_types, phar, unserialize, htmlspecialchars.
    ---
    
    # SOTA PHP (2026)
    
    ## Purpose
    
    This skill encodes the 2026 state of the art for PHP: a supported-version baseline
    (PHP 8.3+ floor; 8.5 is the latest annual line — verify current, see `rules/01`), `strict_types` everywhere, typed
    object-oriented design, security-by-default at every trust boundary, a locked and audited
    Composer supply chain, and measured runtime performance. It serves two modes:
    
    - **BUILD** — writing new code or modifying existing code to this standard.
    - **AUDIT** — reviewing existing code against this standard and reporting findings.
    
    The detailed rules live in `rules/*.md`. Read SKILL.md fully; load rules files on demand
    per the index table below.
    
    ## BUILD mode
    
    When creating or modifying PHP code:
    
    1. **Establish context first.** Check `composer.json` (`require.php`, `config.platform`),
       `composer.lock`, the framework in use, PHPStan/Psalm config, and CS ruleset. Match the
       project's PHP floor — no enums on a project that still supports 8.0. For a *new*
       project, scaffold per `rules/05`: PHP ≥ 8.3 floor, committed lockfile, PHPStan at max
       level (baseline only for legacy), PER-CS formatting, CI gates from day one.
    2. **Default style:** `declare(strict_types=1)` in every file, full parameter/return/
       property types, constructor promotion, `readonly` where state shouldn't mutate, enums
       over class constants, `match` over `switch`, exceptions over error codes, no `@`
       suppression. (`rules/01`)
    3. **Security posture is non-optional** even when unrequested: PDO prepared statements,
       context-correct output escaping, upload validation by content, no `unserialize()` on
       external data, `password_hash`/`sodium`/`random_bytes` for anything secret.
       (`rules/02`–`rules/04`)
    4. **Framework first.** When a framework is present (e.g. Laravel, Symfony), use its
       escaping, CSRF, auth, and validation mechanisms instead of hand-rolling — but verify
       raw-escape hatches (`DB::raw`, `|raw`, `html()`) aren't fed user input.
    5. **Tests accompany code** (PHPUnit or Pest as the project dictates); static analysis
       and CS must pass before code is presented. (`rules/05`)
    6. **Performance:** OPcache assumptions belong in deploy config, not code; anything
       beyond correct-by-default (eager loading, streaming, generators) requires a profile
       first. (`rules/06`)
    
    ## AUDIT mode
    
    When reviewing existing PHP code:
    
    1. **Sweep mechanically first.** Run the "Audit checklist" blocks at the end of every
       relevant rules file — ordered grep/composer/phpstan commands. Start with
       `composer audit --locked` and a grep sweep for `unserialize(`, `eval(`, `shell_exec`,
       string-interpolated SQL, and `echo $_`.
    2. **Then read for design:** trust-boundary placement, escaping strategy (output-time or
       scattered?), session lifecycle, N+1 patterns, lockfile discipline.
    3. **Verify every finding** — open the file, trace the data flow. An `unserialize()` of a
       value the same app signed with HMAC is not CRITICAL. Note mitigations already present.
    4. **Don't report style noise** a fixer would auto-fix; mention once collectively.
    
    ### Severity conventions
    
    | Severity | Meaning | Examples |
    |---|---|---|
    | CRITICAL | Exploitable now, or data loss | SQL built by interpolation from request data, `unserialize($_GET…)`, `include` of user path, `eval` on input, uploads executed as PHP |
    | HIGH | Exploitable with preconditions, or prod-breaking | XSS via unescaped output, `md5()` passwords, missing `use_strict_mode`/fixation, SSRF fetch of user URL, `CURLOPT_SSL_VERIFYPEER => false`, world-readable secrets |
    | MEDIUM | Correctness/maintenance risk | no lockfile committed, no `composer audit` in CI, loose `==` on security decisions, `rand()` for tokens in non-auth context, N+1 on hot path, no static analysis |
    | LOW | Deviation from SOTA, friction | missing `strict_types`, untyped properties, `switch` where `match` fits, dev deps in prod image |
    | INFO | Worth knowing | newer-PHP features available after floor bump, tooling consolidation |
    
    ### Finding format
    
    ```
    file:line | rule violated (rules/NN §S) | severity | effort | fix
    ```
    
    Effort: trivial · small · medium · large. Group by severity, CRITICAL first. Borderline
    severities state the deciding assumption; unconfirmed findings are marked "needs
    verification", never asserted. End with counts per severity, the sweep commands run, and
    explicit "checked and clean" areas.
    
    ## Rules index
    
    | File | Read this when... |
    |---|---|
    | `rules/01-language-baseline.md` | choosing/verifying PHP version floor (support/EOL table); writing any PHP: strict_types, typed properties, enums, readonly, match, fibers, 8.4/8.5 features, comparison pitfalls incl. **`strpos` returning `false` where `0` is a real match**, error handling, deprecations |
    | `rules/02-injection.md` | code touching SQL, shell, or HTML output: PDO prepared statements, command execution, XSS and context-aware escaping, template engines, eval-family bans |
    | `rules/03-files-deserialization-ssrf.md` | file uploads, include/require paths, stream wrappers (LFI/RFI/`phar://`), `unserialize` and Phar object injection, XXE, server-side URL fetching (SSRF) |
    | `rules/04-sessions-auth-web-hardening.md` | login/session/auth code: session cookie flags and fixation, password_hash/argon2id, sodium crypto, CSRF, security headers, production php.ini hardening |
    | `rules/05-composer-tooling.md` | dependencies and CI: composer.lock discipline, `composer audit`, platform reqs, PHPStan/Psalm levels and baseline ratcheting, PER-CS, PHPUnit/Pest, CI gates |
    | `rules/06-performance-runtime.md` | anything slow or deploy-shaped: OPcache and preloading, JIT reality check, PHP-FPM pool sizing, N+1/caching, autoloader optimization, profiling. **Test *strategy* lives in `sota-testing`; DB depth in `sota-databases`.** |
    
    ## Top-10 non-negotiables
    
    1. **Run a supported PHP** (≥ 8.2 today, and 8.2 is security-only until 2026-12-31 —
       plan the 8.3+ move now); new code targets 8.3+. (`rules/01`)
    2. **`declare(strict_types=1)` in every file; full types on every property, parameter,
       and return.** Untyped is legacy, not a style choice. (`rules/01`)
    3. **SQL only via prepared statements with bound parameters** (PDO/mysqli, emulation
       off); identifiers via allowlist. String-built SQL is CRITICAL, no exceptions for
       "internal" values. (`rules/02`)
    4. **Escape at output, for the right context** — `htmlspecialchars(…, ENT_QUOTES |
       ENT_SUBSTITUTE, 'UTF-8')` or the template engine's auto-escaping; raw-output escape
       hatches never receive user input. (`rules/02`)
    5. **Never `unserialize()`, `eval()`, or `include`/`require` data you don't fully
       control.** External data is JSON. Filter user paths for `phar://` and friends.
       (`rules/03`)
    6. **Uploads: validate by content, rename randomly, store non-executable** — never trust
       client filename or MIME; never let the webserver execute uploads. (`rules/03`)
    7. **Passwords via `password_hash()` (bcrypt default, or argon2id) + `password_verify`;
       secrets via `random_bytes`/`sodium`; compare with `hash_equals`.** Never md5/sha1/
       `rand()`/`uniqid()` for anything secret. (`rules/04`)
    8. **Sessions hardened:** `use_strict_mode=1`, cookies `Secure` + `HttpOnly` +
       `SameSite`, `session_regenerate_id(true)` on privilege change. (`rules/04`)
    9. **`composer.lock` committed; CI runs `composer install` (never `update`) and
       `composer audit --locked`; prod installs `--no-dev`.** (`rules/05`)
    10. **PHPStan (or Psalm) gates CI at the highest level the project can hold; the
        baseline only shrinks. OPcache on in prod; performance claims require a profile.**
        (`rules/05`, `rules/06`)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related