Skip to content

feat: add EnforceCurrentUserAttributeRule#26

Open
Goosterhof wants to merge 3 commits into
queue-87-forbid-eloquent-mutation-in-controllersfrom
feat/enforce-current-user-attribute-rule
Open

feat: add EnforceCurrentUserAttributeRule#26
Goosterhof wants to merge 3 commits into
queue-87-forbid-eloquent-mutation-in-controllersfrom
feat/enforce-current-user-attribute-rule

Conversation

@Goosterhof
Copy link
Copy Markdown
Contributor

Summary

  • New PHPStan rule EnforceCurrentUserAttributeRule flagging Request::user() / Auth::user() / auth()->user() calls inside classes extending Illuminate\Routing\Controller. The canonical fix is Laravel's #[\Illuminate\Container\Attributes\CurrentUser] container attribute on the method parameter — eliminates the implicit-nullable-then-assert dance ($user = $request->user(); assert($user instanceof User);) introduced by emmie PR #263 and recurring across the war-room fleet.
  • Scoped to Controller descendants only. FormRequest descendants (where $this->user() is canonical because container-attribute injection does not apply to FormRequest::rules() / toDto() / authorize() invocations), middleware, services, Actions, jobs, and console commands are silent by design.
  • Detection branches on three call shapes via CallLike registration (mirrors LogRule v0.3.0 expansion shape): type-based receiver match on Illuminate\Http\Request subtype (ObjectType::isSuperTypeOf()), AST-shape match on FuncCall('auth') receiver, FQCN match via $scope->resolveName() on Auth::class. Containing-class gate via ClassReflection::isSubclassOf(Controller::class).

Origin

War-room cross-territory recon (2026-05-22) — ~50+ violations across codebook (~40+), ublgenie (6), entreezuil (3), emmie (2); kendo already clean with 30 adopted sites. Doctrine source: war-room §Architectural Principles — Explicit over implicit. Identifier: enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser.

Versioning

Per ADR-0021 §Versioning, this is a candidate Major bump (new errors in already-clean code wherever consumer territories carry un-migrated controllers). The release PR will decide whether this collapses into the existing v0.3.0 [Unreleased] block (already a Major, currently gated on PR #25 recovery) or cuts as v0.4.0 after v0.3.0 ships. CHANGELOG entry added to [Unreleased] per package convention.

Pre-cascade audit verified at filing time across the 5 Laravel territories (recon counts above). Post-release adoption plan:

  • emmie / ublgenie / entreezuil: Medic dispatches to migrate violations (single PR each).
  • codebook: PHPStan-baseline staging — safer than mass-edit on lightly-staffed AVG/NEN-7510-downstream territory; violations drain in scheduled passes.
  • kendo: constraint bump only (0 violations) — regression protection.

Out of scope v1

  • Auth::guard('name')->user() and other guard-specific resolution paths — rare, substitution is more nuanced (#[CurrentUser(guard: 'name')]), do not appear in the recon yield. Track as follow-up issue if a future recon surfaces them.
  • Multi-guard ergonomics work as expected: #[CurrentUser] Client $client resolves via client guard, #[CurrentUser] User $user via user guard — verified in emmie's ClientController::me. Laravel dispatches by typed parameter.

Mutation-test notes

  • Mutation iteration added a 10th fixture (TopLevelAuthCall.php) to kill the FalseValue mutant on insideControllerMethod()'s null-class-reflection branch.
  • Two LogicalOr mutants remain alive on the early-return guards (lines 118 and 140) — equivalent-mutant precedent already accepted on LogRule lines 84/99 (same defensive-guard shape, same family).

Test plan

  • composer test — 60 tests pass, 101 assertions (10 new test methods on the new rule)
  • composer phpstan — level max self-analysis clean (9 services discovered)
  • composer format:check — Pint clean
  • composer test:coverage + composer coverage:check — 86.00% line coverage (381/443) ≥ 83% gate
  • composer mutation:ci — MSI 82.47%, Covered MSI 82.47% (both ≥ 75% gate)
  • CI matrix (8.4 + 8.5) — auto-runs on PR open
  • Reviewer scan: detection-scope sanity check (does the Controller-ancestry gate fire where it should and stay silent where it shouldn't?)

Doctrine artifacts

  • Deployment order (war-room): orders/phpstan-warroom-rules/enforce-current-user-attribute-rule-engineer-deployment.md
  • Execution report (war-room): reports/phpstan-warroom-rules/execution/2026-05-22-engineer-enforce-current-user-attribute-rule.md
  • ADR-0021 §Versioning + §Identifier convention + §Doctrine source in docblock — all observed

🤖 Generated with Claude Code

@jasperboerhof
Copy link
Copy Markdown
Contributor

PR Reviewer · claimed

  • Target: phpstan-warroom-rules
  • PR: feat: add EnforceCurrentUserAttributeRule #26
  • Branch: feat/enforce-current-user-attribute-rule
  • AC anchor: none (no linked Kendo issue, no plan dir, no AC heading in PR body)
  • Worktree: ~/Code/agent-worktrees/phpstan-warroom-rules/review-26

@jasperboerhof
Copy link
Copy Markdown
Contributor

PR Reviewer · 10/10 · PASS

Findings

  • none — all reviewers clean

Action

merge-ready

jasperboerhof
jasperboerhof previously approved these changes May 29, 2026
Copy link
Copy Markdown
Contributor

@jasperboerhof jasperboerhof left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved by /review-open-prs — review verdict is PASS. See the verdict comment for the per-reviewer breakdown.

@Goosterhof Goosterhof added the Agent Review Requested Requesting review of specialized AI review agents. label May 29, 2026
Copy link
Copy Markdown
Contributor Author

@Goosterhof Goosterhof left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blockers, two concerns, one nit, one praise.

The rule is well-constructed mechanically — clean CallLike registration, three detection shapes that mirror the established LogRule pattern, and a thorough fixture matrix that exercises every out-of-scope context (FormRequest, middleware, Action, job, top-level). The detection logic for the three call shapes is correct. The problem is not the detection — it is the scope gate, and it is load-bearing.

Blockers

1. The Controller-ancestry gate does not match the actual fleet — the rule is silent on most real controllers. insideControllerMethod() (src/Rules/EnforceCurrentUserAttributeRule.php:156-169) fires only when $classReflection->isSubclassOf('Illuminate\Routing\Controller'). But across the consumer territories, the overwhelming majority of controllers do not extend any base class at all — modern Laravel scaffolds controllers without a base Controller:

  • kendo: 59 controllers, 0 extend a base. The PR positions kendo as "clean with 30 adopted sites" getting a "constraint bump only — regression protection." There is no regression protection: a future $request->user() added to any of those 59 controllers fires nothing, because the ancestry gate returns false for all of them.
  • ublgenie: 12 controllers, 0 extend. Recon claims 6 violations — the rule catches 0.
  • entreezuil: 7 controllers, 0 extend. Recon claims 3 violations.
  • emmie is the only territory where the gate works: 88 of 105 controllers extend a local abstract class Controller extends Illuminate\Routing\Controller (app/Http/Controllers/Controller.php:9,11). The other 17 escape.

So the rule's claimed cross-territory yield (50+ violations) is almost entirely unreachable as written. The fixtures pass because every controller fixture (RequestUserInController.php:10, AuthFacadeInController.php:10, etc.) was authored with extends Controller — they validate the gate against a controller shape three of the four territories don't actually use. The test suite is green against a strawman, and the kendo "regression protection" the PR explicitly promises does not exist.

2. The sibling rule already solved this, and this rule diverges from it. ForbidEloquentMutationInControllersRule scopes "controller" by namespace prefix (App\Http\Controllers, ForbidEloquentMutationInControllersRule.php:140-146) and its docblock explicitly documents why it avoids ancestry: $scope->getClassReflection() is unreliable, and namespace gating catches sub-namespaces like kendo's App\Http\Controllers\Central\* "naturally." Shipping a second controller-scoped rule in the same package with a contradictory, weaker definition of "controller" is an internal inconsistency that will confuse every consumer — one rule fires on a controller, the sibling doesn't, for the same class. Fix path: replace the ancestry gate with $scope->getNamespace() + str_starts_with($namespace, 'App\Http\Controllers'), copy the controllerNamespacePrefixes parameterization hook the sibling already carries. The FormRequest exclusion survives this unchanged — FormRequests live in App\Http\Requests, verified across all four territories, so a namespace gate keeps them silent without the ancestry crutch.

The two together: the rule as written will pass CI in every territory and protect almost nothing. Re-anchoring the gate to namespace is the correction, and it makes the existing fixture matrix more honest (drop the extends Controller from the controller fixtures so they reflect real scaffolding, and add a fixture for a sub-namespace controller).

Concerns

1. getName() === self::CONTROLLER_BASE_FQCN early-return (:164-166) is dead weight under the current gate and unmotivated. It exists to avoid flagging the base Illuminate\Routing\Controller itself, but that class lives in vendor/ and won't be analyzed, and isSubclassOf is already strict-ancestor in the relevant direction. No fixture exercises it. If the gate moves to namespace this branch disappears anyway; if it stays, justify it with a fixture or drop it.

2. The auth() AST-shape match is namespace-naive (receiverIsAuthHelper, :232-243). It matches any FuncCall named auth regardless of namespace resolution — a user-defined App\Support\auth() in a controller would false-positive. Low probability given the helper's ubiquity, but the sibling rules lean on $scope->resolveName() for exactly this discrimination on the facade branch (:206) while the helper branch skips it. Worth a one-line note in the docblock that this is a deliberate AST-only match accepting that collision, or a function_exists-style guard if you want to be strict.

Nits

The CHANGELOG entry is a single ~1900-character paragraph carrying versioning strategy, pre-cascade audit plan, and per-territory rollout. That's campaign-report and release-PR content, not a changelog line. Trim to the rule's behavior and identifier; the rollout plan already lives in the deployment order and PR body.

Praise

The TopLevelAuthCall.php fixture (:453-468) and its test (:699-712) are a genuine catch — exercising the null-getClassReflection() path to kill the FalseValue mutant on the no-class-reflection guard is the kind of mutation-driven coverage that prevents a real crash on top-level code, not a coverage-percentage chase. Named in the PR body with the specific mutant it kills. That's load-bearing.

Verdict: this is a COMMENT, not an approval. The detection engine is sound and the test discipline is real, but the scope gate is anchored to a controller shape three of the four consumer territories don't use — as written it would ship green and enforce almost nothing, including the kendo regression protection the PR explicitly promises. Re-anchor the gate to the namespace prefix the sibling rule already uses, re-author the controller fixtures to drop the artificial extends Controller, and the rule does what it claims. I'd want to re-review the gate change and the fixture re-authoring before merge.

Goosterhof and others added 3 commits May 29, 2026 16:00
Flags authenticated-user resolution via Request::user() / Auth::user() /
auth()->user() inside controller methods. The canonical fix is Laravel's
#[\Illuminate\Container\Attributes\CurrentUser] container attribute on the
method parameter — eliminates the implicit-nullable-then-assert dance.

Detection branches on three call shapes via CallLike registration:
  - MethodCall on Illuminate\Http\Request subtype receiver (type-based via
    ObjectType::isSuperTypeOf())
  - MethodCall whose receiver is a FuncCall('auth') (AST-shape match — the
    helper's return type is unloaded in stub-only environments)
  - StaticCall resolving to Illuminate\Support\Facades\Auth (FQCN comparison
    via \$scope->resolveName())

Scoped to Controller descendants via ClassReflection::isSubclassOf(
Illuminate\Routing\Controller). FormRequest descendants, middleware, services,
Actions, jobs, and console commands are silent by design.

Identifier: enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser
Doctrine: war-room §Architectural Principles — Explicit over implicit
Origin: war-room cross-territory recon 2026-05-22

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 10 fixtures and 10 test methods covering every detection branch and
every legitimate-skip case:

Fire (4):
  - RequestUserInController — shape 1 (Request subtype receiver)
  - AuthFacadeInController — shape 3 (Auth::user() static call)
  - AuthHelperInController — shape 2 (auth() helper receiver)
  - RequestUserInControllerWithAssert — exact PR #263 shape; fires once
    on the \$request->user() call (assert is downstream noise)

Silent (5):
  - UsesCurrentUserAttribute — already-canonical #[CurrentUser] adoption
  - RequestUserInFormRequest — FormRequest descendant, by-design exclusion
  - AuthInMiddleware — not a Controller descendant
  - AuthInAction — not a Controller descendant
  - AuthInJob — not a Controller descendant

Mutation-killing:
  - TopLevelAuthCall — top-level call outside any class (kills FalseValue
    mutant on the null-class-reflection branch)

The _stubs.php file provides bracketed-namespace stubs for
Illuminate\Routing\Controller, Illuminate\Http\Request,
Illuminate\Foundation\Http\FormRequest, App\Models\User, and the global
auth() helper — illuminate/routing / illuminate/http are not vendor-installed
on this package, mirroring the AuditSnapshotOnRetry _stubs.php pattern.

60 tests pass, 101 assertions. Coverage 86.00% (≥83 gate); MSI 82.47%
(≥75 gate); Covered MSI 82.47% (≥75 gate). Two LogicalOr mutants on the
Identifier-name guard clauses escape — equivalent-mutant precedent already
accepted on LogRule lines 84/99.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ributeRule

- CHANGELOG.md [Unreleased] § Added — new entry capturing detection shape,
  scope gate, doctrine source, identifier, origin recon yield, versioning
  posture (candidate Major bump per ADR-0021 §Versioning), pre-cascade
  audit demand, multi-guard ergonomics note, and v1 out-of-scope items
  (Auth::guard('name')->user()).
- README.md ## Rules table — new row in the canonical seven-rule table.
- CLAUDE.md ## Rules shipped table — new row matching the rules-shipped
  format (rule | doctrine | identifier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Goosterhof Goosterhof force-pushed the feat/enforce-current-user-attribute-rule branch from 0fc4ce3 to c2dff7f Compare May 29, 2026 14:06
@Goosterhof Goosterhof changed the base branch from main to queue-87-forbid-eloquent-mutation-in-controllers May 29, 2026 14:06
@Goosterhof
Copy link
Copy Markdown
Contributor Author

Restacked for v0.4.0 — re-review requested. Rebased onto the restacked #28; base retargeted to queue-87-forbid-eloquent-mutation-in-controllers so this diff shows only the EnforceCurrentUserAttributeRule. Rule source + tests byte-identical to your prior approval — CHANGELOG repositioned into the fresh v0.4.0 [Unreleased] only. Top of the stack — merge last (order: #27#28#26).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Review Requested Requesting review of specialized AI review agents.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants