Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

**Release-as-a-whole: backward-compatible MINOR** — one new optional PHPStan parameter (`controllerNamespacePrefixes`, default `['App\Http\Controllers']`) shared by all three controller-scoped rules (`ForbidEloquentMutationInControllersRule`, `EnforceCurrentUserAttributeRule`, `ForbidResourceWrappedInJsonResponseRule`). The default reproduces the prior hardcoded `App\Http\Controllers` gate byte-for-byte ⇒ zero new errors in any existing consumer on adoption (NOT a candidate-major — contrast the v0.5.0 rules that surfaced errors in clean code); new violations appear only when a consumer opts a divergent controller namespace in by configuring the param. A consumer adopts on its own `^0.6 → ^0.7` pin bump. Seed: war-room WR-0283 / WR-0275 DayUpdate pilot — emmie ships controllers under `App\Http\Client\Controllers` (4) + `App\Http\Admin\Controllers` (9), outside the hardcoded prefix, so their inline Eloquent mutations, `$request->user()` calls, and resource-wrapped JSON responses were rule-invisible.

### Added

- `ForbidEloquentMutationInControllersRule` + `EnforceCurrentUserAttributeRule` + `ForbidResourceWrappedInJsonResponseRule` — new shared optional `controllerNamespacePrefixes` PHPStan parameter (default `['App\Http\Controllers']`): a list of namespace prefixes whose classes are treated as controllers. A class is in scope when its namespace `str_starts_with` **any** listed prefix, so sub-namespaces (kendo's `App\Http\Controllers\Central\*`) still match the canonical prefix naturally — the default is behaviour-identical to the prior hardcoded `private const CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'` + single-prefix `str_starts_with` gate all three rules carried before. Wired through `extension.neon` (`controllerNamespacePrefixes: ['App\Http\Controllers']` parameter + `listOf(string())` schema + `controllerNamespacePrefixes: %controllerNamespacePrefixes%` on all three rules' service registrations), mirroring the `formRequestBaseClass` / `resourceDataBaseClass` / `formRequestToDtoExemptClasses` parameter precedent. A consumer with divergent controller namespaces (emmie's `App\Http\Client\Controllers` + `App\Http\Admin\Controllers`) brings them into scope by adding the prefixes in its `phpstan.neon`. No consumer namespace is ever hardcoded in a rule body — the prefix list is *config*, preserving the package's "never by name inside the rule" convention. This resolves the `ForbidEloquentMutationInControllersRule` docblock's own standing TODO ("if a future consumer ships controllers under a divergent namespace, lift this into a `controllerNamespacePrefixes` parameter") — now done, and applied uniformly to all three controller-scoped rules so the one knob governs the whole controller surface. README documents the param + a "Covering sub-namespaced controllers" recipe using emmie's real namespaces. The default-unchanged invariant is pinned end-to-end by a container-resolved test per rule (`testRuleResolvesFromExtensionNeonAndFiresOnDefaultPrefix` — resolves the rule from the PHPStan container so the shipped NEON default + `%controllerNamespacePrefixes%` wiring are exercised, then asserts a canonical/sub-namespaced controller still flags) plus every pre-existing fixture and test (the kendo `App\Http\Controllers\Central\*` sub-namespace still flags under the default). A new sub-namespaced-controller fixture per rule proves the emmie shape is CLEAN under the default and FLAGGED once the sub-namespace prefix is configured. **Versioning: backward-compatible MINOR** (new optional parameter, default reproduces current behaviour ⇒ zero new errors in existing consumers — the default-list means no consumer sees behaviour change until it opts in). Do NOT push / tag from this entry — the release PR + tag is a separate ally-gated step.

## [0.6.1] — 2026-07-01

**Release-as-a-whole: PATCH (false-positive narrowing — `^0.6` consumers inherit on a plain `composer update`, no pin bump).** Removes errors from previously-flagged code; adds none. Not a candidate-major.
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ includes:
| `LogBuilderTruncateRule` | `logRule.logModification` | `Builder->truncate()` calls | If the fluent chain's most recent `table()` call targets a Log-named table (string-literal argument matching `"log"` / `"logs"`, case-insensitive), error. Sibling rule to `LogRule`; shares the `logRule.logModification` identifier so a single `ignoreErrors` entry covers both. Eloquent `from()` chains and Model-`$table`-property-driven tables are acceptable misses. Doctrine: ADR-0001 §Append-only. |
| `EnforceAuditSnapshotOnRetryRule` | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | `App\Actions\*` whose constructor injects an entity audit logger | The first statement inside `$connection->transaction(...)` must reset the model's in-memory state (`$model->refresh()`, fresh fetch, or fresh instantiation). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. |
| `EnforceAuditTransactionScopeRule` | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` | `App\Actions\*` whose `execute()` calls `transaction(...)` with a literal closure | Mutating `StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` state (or their `Illuminate\Support\Facades\*` counterparts) inside the closure is an error. Reads (`Auth::user()`, `Session::get()`, `Cache::get()`) are permitted. Doctrine: ADR-0029 (Audit Row Durability Contract) §Decision rule 3. |
| `ForbidEloquentMutationInControllersRule` | `forbidEloquentMutationInControllers.eloquentMutationInController` | `App\Http\Controllers\*` (including sub-namespaces) | Calling Eloquent persistence APIs (`save`, `update`, `delete`, `create`, `destroy`, `forceDelete`, `forceFill`, `push`, `restore`, `touch`, and their `*OrFail` / `*Quietly` / `*OrCreate` variants — 24-method blocklist) on `Illuminate\Database\Eloquent\Model` subclasses or `Illuminate\Database\Eloquent\Builder` chains is an error. Reads (`find`, `where`, `get`, `first`, `paginate`, `pluck`, `count`, `exists`, `query`) are permitted. Delegate mutations to an Action. Doctrine: ADR-0011 (Action Class Architecture) + ADR-0019 (Explicit Model Hydration). |
| `ForbidEloquentMutationInControllersRule` | `forbidEloquentMutationInControllers.eloquentMutationInController` | `App\Http\Controllers\*` (including sub-namespaces; configurable via `controllerNamespacePrefixes`) | Calling Eloquent persistence APIs (`save`, `update`, `delete`, `create`, `destroy`, `forceDelete`, `forceFill`, `push`, `restore`, `touch`, and their `*OrFail` / `*Quietly` / `*OrCreate` variants — 24-method blocklist) on `Illuminate\Database\Eloquent\Model` subclasses or `Illuminate\Database\Eloquent\Builder` chains is an error. Reads (`find`, `where`, `get`, `first`, `paginate`, `pluck`, `count`, `exists`, `query`) are permitted. Delegate mutations to an Action. Doctrine: ADR-0011 (Action Class Architecture) + ADR-0019 (Explicit Model Hydration). |
| `EnforceResourceDataValidatorOptInRule` | `enforceResourceDataValidatorOptIn.missingValidatorCall` | Classes extending `App\Http\Resources\ResourceData` | If the class declares a non-empty `EAGER_LOAD_COUNT` / `EAGER_LOAD_SUM` constant but never calls `validateRelationsLoaded()` in any method, error. |
| `EnforceFormRequestToDtoRule` | `enforceFormRequestToDto.missingToDtoMethod` | Concrete classes extending `Illuminate\Foundation\Http\FormRequest` | If the class neither declares nor inherits a `toDto()` method, error. Abstract intermediates (`BaseFormRequest`) are exempt. Hand Actions a typed DTO, not `$request->validated()` arrays. Doctrine: ADR-0012 (FormRequest → DTO Flow). |
| `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `Request::user()` / `Auth::user()` / `auth()->user()` calls inside `App\Http\Controllers\*` classes (namespace prefix, incl. sub-namespaces) | Use `#[\Illuminate\Container\Attributes\CurrentUser] User $user` on the method parameter. Scope is decided by namespace, not class ancestry — a base-less `final` controller in `App\Http\Controllers` fires; FormRequests (`App\Http\Requests`), middleware (`App\Http\Middleware`), services, Actions (`App\Actions`), jobs, and console commands are silent because their namespaces do not start with the controller prefix (container-attribute injection does not apply to FormRequest methods regardless). |
| `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `Request::user()` / `Auth::user()` / `auth()->user()` calls inside `App\Http\Controllers\*` classes (namespace prefix, incl. sub-namespaces; configurable via `controllerNamespacePrefixes`) | Use `#[\Illuminate\Container\Attributes\CurrentUser] User $user` on the method parameter. Scope is decided by namespace, not class ancestry — a base-less `final` controller in `App\Http\Controllers` fires; FormRequests (`App\Http\Requests`), middleware (`App\Http\Middleware`), services, Actions (`App\Actions`), jobs, and console commands are silent because their namespaces do not start with the controller prefix (container-attribute injection does not apply to FormRequest methods regardless). |

### `EnforceActionTransactionsRule` — write-method list

Expand Down Expand Up @@ -153,6 +153,32 @@ parameters:

Confirmed cross-territory (n=2, 2026-06-15): entreezuil `AuthenticatedSessionController::store`, ublgenie `AuthController::store`. Each consumer adds this on its `^0.4` bump.

### Configurable controller namespaces (`controllerNamespacePrefixes`)

The three controller-scoped rules — `ForbidEloquentMutationInControllersRule`, `EnforceCurrentUserAttributeRule`, and `ForbidResourceWrappedInJsonResponseRule` — decide "is this class a controller?" by namespace prefix, not class ancestry (consumer controllers are base-less `final` classes with no `extends Controller`, so an ancestry walk catches nothing). The prefix set is the shared `controllerNamespacePrefixes` parameter, default `['App\Http\Controllers']`:

```neon
parameters:
controllerNamespacePrefixes:
- 'App\Http\Controllers'
```

A class is in scope when its namespace `str_starts_with` **any** listed prefix, so canonical sub-namespaces (kendo's `App\Http\Controllers\Central\*`) are covered by the default automatically. The default reproduces the prior hardcoded gate byte-for-byte — leave it unset and nothing changes.

#### Covering sub-namespaced controllers

A territory that ships controllers **outside** `App\Http\Controllers` — e.g. emmie's `App\Http\Client\Controllers` and `App\Http\Admin\Controllers` — opts them into both rules by listing their prefixes:

```neon
parameters:
controllerNamespacePrefixes:
- 'App\Http\Controllers'
- 'App\Http\Client\Controllers'
- 'App\Http\Admin\Controllers'
```

All three rules then flag inline Eloquent mutations, `Request::user()` / `Auth::user()` / `auth()->user()` calls, and `JsonResource`-wrapped JSON responses in those namespaces too. Prefixes are *config* — no consumer namespace is ever hardcoded in a rule body, preserving the "never by name inside the rule" convention. (Each backslash is single — NEON only unescapes `\\` inside double quotes; single-quoted `\\` stays two literal characters and would match nothing.)

### Action namespace assumption

`EnforceActionTransactionsRule` and `ForbidDatabaseManagerInActionsRule` only fire on classes whose namespace starts with `App\Actions`. This matches the Laravel convention used in every `script-development` territory. Territories using a different actions namespace should open a PR to make this configurable.
Expand Down
21 changes: 21 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,25 @@ parameters:
# single backslashes — see the NEON-quoting note above.
formRequestToDtoExemptClasses: []

# `ForbidEloquentMutationInControllersRule` + `EnforceCurrentUserAttributeRule`
# + `ForbidResourceWrappedInJsonResponseRule`: namespace prefixes whose
# classes are treated as controllers. A class is
# in scope when its namespace `str_starts_with` ANY listed prefix, so
# sub-namespaces (kendo's `App\Http\Controllers\Central\*`) match the
# canonical prefix naturally. The default reproduces the prior hardcoded
# `App\Http\Controllers` gate byte-for-byte. A consumer with divergent
# controller namespaces (e.g. emmie's `App\Http\Client\Controllers` /
# `App\Http\Admin\Controllers`) adds those prefixes here to bring them into
# scope. Each prefix uses single backslashes — see the NEON-quoting note
# above.
controllerNamespacePrefixes:
- 'App\Http\Controllers'

parametersSchema:
resourceDataBaseClass: string()
formRequestBaseClass: string()
formRequestToDtoExemptClasses: listOf(string())
controllerNamespacePrefixes: listOf(string())

services:
-
Expand All @@ -43,6 +58,8 @@ services:
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\ForbidResourceWrappedInJsonResponseRule
arguments:
controllerNamespacePrefixes: %controllerNamespacePrefixes%
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\LogRule
Expand All @@ -58,6 +75,8 @@ services:
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\ForbidEloquentMutationInControllersRule
arguments:
controllerNamespacePrefixes: %controllerNamespacePrefixes%
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceResourceDataValidatorOptInRule
Expand All @@ -72,6 +91,8 @@ services:
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceCurrentUserAttributeRule
arguments:
controllerNamespacePrefixes: %controllerNamespacePrefixes%
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Type\ConnectionTransactionReturnTypeExtension
Expand Down
42 changes: 35 additions & 7 deletions src/Rules/EnforceCurrentUserAttributeRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@
* Use the #[\Illuminate\Container\Attributes\CurrentUser] container attribute on the
* method parameter instead — eliminates the nullable-then-assert dance.
*
* Scoped to classes in the `App\Http\Controllers` namespace. FormRequest
* (`App\Http\Requests`) is excluded by design — container-attribute injection
* does not apply to FormRequest::rules() / toDto() / authorize() invocations.
* Scoped to classes whose namespace starts with ANY of the configured
* `controllerNamespacePrefixes` (default `['App\Http\Controllers']`). A
* consumer that ships controllers under a divergent namespace (e.g. emmie's
* `App\Http\Client\Controllers` / `App\Http\Admin\Controllers`) opts them in
* by adding the prefix to `controllerNamespacePrefixes` in its `phpstan.neon`
* — mirrors the `formRequestBaseClass` / `resourceDataBaseClass` parameter
* precedent. FormRequest (`App\Http\Requests`) is excluded by design —
* container-attribute injection does not apply to FormRequest::rules() /
* toDto() / authorize() invocations.
* Middleware (`App\Http\Middleware`), services, Actions (`App\Actions`), jobs,
* and console commands are likewise out of scope: each context has its own
* canonical resolution path (constructor DI for Actions, authenticated payload
Expand All @@ -47,7 +53,8 @@
* handles aliased imports.
*
* Containing-class gate (applied to all three branches): the rule fires only
* when `$scope->getNamespace()` starts with `App\Http\Controllers`. This
* when `$scope->getNamespace()` starts with a configured controller prefix
* (default `App\Http\Controllers`). This
* mirrors `ForbidEloquentMutationInControllersRule` and the canonical
* "controllers are identified by the `App\Http\Controllers` namespace"
* convention — consumer controllers are base-less `final` classes with no
Expand All @@ -68,12 +75,27 @@ final class EnforceCurrentUserAttributeRule implements Rule
{
private const string TARGET_METHOD = 'user';

private const string CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers';

private const string REQUEST_FQCN = Request::class;

private const string AUTH_FACADE_FQCN = Auth::class;

/**
* @param list<string> $controllerNamespacePrefixes namespace prefixes whose
* classes are treated as
* controllers (match via
* `str_starts_with`); the
* default reproduces the
* canonical
* `App\Http\Controllers`
* gate. Consumers with
* sub-namespaced
* controllers add their
* prefixes from config.
*/
public function __construct(
private array $controllerNamespacePrefixes = ['App\Http\Controllers'],
) {}

public function getNodeType(): string
{
return CallLike::class;
Expand Down Expand Up @@ -114,7 +136,13 @@ private function insideControllerMethod(Scope $scope): bool
return false;
}

return str_starts_with($namespace, self::CONTROLLER_NAMESPACE_PREFIX);
foreach ($this->controllerNamespacePrefixes as $prefix) {
if (str_starts_with($namespace, $prefix)) {
return true;
}
}

return false;
}

/**
Expand Down
Loading
Loading