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
7 changes: 7 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev
| `EnforceAuditTransactionScopeRule` | ADR-0029 | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` |
| `ForbidEloquentMutationInControllersRule` | ADR-0011 + ADR-0019 | `forbidEloquentMutationInControllers.eloquentMutationInController` |
| `EnforceResourceDataValidatorOptInRule` | ADR-0009 §EAGER_LOAD validator opt-in | `enforceResourceDataValidatorOptIn.missingValidatorCall` |
| `EnforceFormRequestToDtoRule` | ADR-0012 §FormRequest → DTO Flow | `enforceFormRequestToDto.missingToDtoMethod` |
| `EnforceCurrentUserAttributeRule` | War-room §Explicit over implicit | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` |
| `ConnectionTransactionReturnTypeExtension` | (type extension, no rule) | — |

Phase 2 expands the rule set: `EnforceAuditSnapshotOnRetryRule` (ADR-0001 §Snapshot-on-Retry Safety) was the first Phase 2 addition, promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). `EnforceResourceDataValidatorOptInRule` (ADR-0009 §EAGER_LOAD validator opt-in) is the second Phase 2 addition, promoted from kendo PR #1084 under war-room enforcement queue #55. `EnforceExplicitHydrationRule` (ADR-0019) is the next Phase 2 candidate.
Phase 2 expands the rule set: `EnforceAuditSnapshotOnRetryRule` (ADR-0001 §Snapshot-on-Retry Safety) was the first Phase 2 addition, promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). `EnforceResourceDataValidatorOptInRule` (ADR-0009 §EAGER_LOAD validator opt-in) is the second Phase 2 addition, promoted from kendo PR #1084 under war-room enforcement queue #55. `EnforceFormRequestToDtoRule` (ADR-0012) is the third Phase 2 addition, promoted from entreezuil's `tests/Arch/FormRequestsTest.php` under the same queue #55 (instance 2). `EnforceExplicitHydrationRule` (ADR-0019) is the next Phase 2 candidate.

## Conventions

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ includes:
| `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). |
| `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). |

### `EnforceActionTransactionsRule` — write-method list
Expand Down Expand Up @@ -83,6 +84,29 @@ parameters:

Inheritance is matched via PHPStan reflection (FQCN ancestor traversal), not short-name matching — a class named `ResourceData` in an unrelated namespace will not be matched. Compliant call shapes are `self::validateRelationsLoaded($model)`, `static::validateRelationsLoaded($model)`, and `$this->validateRelationsLoaded($model)` — the production base method is `protected static`, but the instance form is also accepted for compatibility with the source-of-truth Pest arch test's permissive matcher. Empty-array constants (`EAGER_LOAD_COUNT = []`) do not fire — they are no-ops.

### `EnforceFormRequestToDtoRule` — configurable base class + exemptions

The rule scopes to concrete classes extending `Illuminate\Foundation\Http\FormRequest` by default. To narrow the contract to a territory-local base FQCN, override the `formRequestBaseClass` parameter in `phpstan.neon`:

```neon
parameters:
formRequestBaseClass: 'App\Http\Requests\BaseFormRequest'
```

Inheritance is matched via PHPStan reflection (FQCN ancestor traversal), not short-name matching. Abstract classes never fire — a per-territory abstract `BaseFormRequest` intermediate is exempt by shape, not by name. A `toDto()` declared on a parent class or provided by a trait satisfies the contract (mirroring the source-of-truth entreezuil Pest arch test's `method_exists()` matcher).

Legitimately DTO-less requests (e.g. a `LoginRequest` whose auth flow calls `AuthManager::attempt()` directly, or read-only filter/query requests) are suppressed per territory via `phpstan.neon` — never by name inside the rule:

```neon
parameters:
ignoreErrors:
-
identifier: enforceFormRequestToDto.missingToDtoMethod
path: app/Http/Requests/LoginRequest.php
```

Each ignore should carry a comment with rationale.

### 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
18 changes: 17 additions & 1 deletion extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ parameters:
# base class. Default matches the kendo / emmie / ublgenie / entreezuil
# convention `App\Http\Resources\ResourceData`. Override per consumer
# `phpstan.neon` if a territory ships the base under a different FQCN.
resourceDataBaseClass: 'App\\Http\\Resources\\ResourceData'
# NOTE: single backslashes — NEON only unescapes `\\` inside DOUBLE quotes;
# in single quotes (and unquoted) `\\` stays two literal characters, which
# decodes to a class name that matches nothing and silently no-ops the rule.
resourceDataBaseClass: 'App\Http\Resources\ResourceData'

# `EnforceFormRequestToDtoRule`: FQCN of the FormRequest base class.
# Default matches the Laravel framework base every territory's requests
# ultimately extend. Override per consumer `phpstan.neon` to narrow the
# contract to a territory-local base FQCN. Single backslashes — see the
# NEON-quoting note above.
formRequestBaseClass: 'Illuminate\Foundation\Http\FormRequest'

parametersSchema:
resourceDataBaseClass: string()
formRequestBaseClass: string()

services:
-
Expand Down Expand Up @@ -38,6 +49,11 @@ services:
arguments:
resourceDataBaseClass: %resourceDataBaseClass%
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceFormRequestToDtoRule
arguments:
formRequestBaseClass: %formRequestBaseClass%
tags: [phpstan.rules.rule]
-
class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceCurrentUserAttributeRule
tags: [phpstan.rules.rule]
Expand Down
122 changes: 122 additions & 0 deletions src/Rules/EnforceFormRequestToDtoRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types = 1);

namespace ScriptDevelopment\PhpstanWarroomRules\Rules;

use Illuminate\Foundation\Http\FormRequest;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

use function sprintf;

/**
* Enforces ADR-0012 §FormRequest → DTO Flow: every concrete `FormRequest`
* subclass must define (or inherit) a `toDto()` method so validated input
* crosses the HTTP boundary as a typed DTO, never as a raw validated array.
* Without the method, controllers hand `$request->validated()` arrays to
* Actions — untyped, key-renameable, and invisible to static analysis.
*
* Doctrine source: ADR-0012 (FormRequest → DTO Flow). Promoted from
* entreezuil's reflection-based Pest arch test
* (`tests/Arch/FormRequestsTest.php`, "form requests with mutation actions
* define toDto method") — the second instance of the "arch test detects
* misuse but not omission" enforcement shape under war-room enforcement
* queue #55, dispositioned for Phase-2 promotion by the Commander on
* 2026-05-07. Sister of `EnforceResourceDataValidatorOptInRule` (instance 3,
* PR #20).
*
* Scope: classes whose ancestor chain includes the configured base FQCN
* (default: `Illuminate\Foundation\Http\FormRequest`). Inheritance is
* matched via PHPStan reflection — short-name collisions in unrelated
* namespaces do not fire. Abstract classes are skipped (the per-territory
* `BaseFormRequest` shape is an intermediate layer, not a mutation request).
*
* Detection (all three must hold):
* 1. Class transitively extends the configured base class.
* 2. Class is concrete (abstract intermediates are exempt).
* 3. Class neither declares nor inherits a `toDto()` method — own
* declarations, parent-class declarations, and trait-provided methods
* all satisfy the contract (mirroring the source-of-truth Pest test's
* `method_exists()` matcher).
*
* Legitimately DTO-less requests (entreezuil precedent: `LoginRequest`,
* whose auth flow calls `AuthManager::attempt()` directly) are suppressed
* per consumer `phpstan.neon` `ignoreErrors` keyed on the identifier —
* never by name inside the rule, per the package convention.
*
* Implementation note: the constructor default uses `FormRequest::class`
* (compile-time constant, never autoloads) instead of an FQCN string
* literal. Pint's class_keyword fixer calls class_exists() on string
* literals that look like class names, and the Pint phar bundles a real
* `Illuminate\Foundation\Http\FormRequest` whose ValidatesWhenResolvedTrait
* dependency is NOT bundled — a bare FQCN string literal anywhere in this
* package's PHP source makes `composer format` fatal with "Trait not
* found". The `use` import is alias-only; consumers analysing non-Laravel
* trees are unaffected because `::class` resolution requires no autoload.
*
* @implements Rule<InClassNode>
*/
final class EnforceFormRequestToDtoRule implements Rule
{
private const string DTO_METHOD_NAME = 'toDto';

public function __construct(
private string $formRequestBaseClass = FormRequest::class,
) {}

public function getNodeType(): string
{
return InClassNode::class;
}

public function processNode(Node $node, Scope $scope): array
{
$classNode = $node->getOriginalNode();

if (!$classNode instanceof Class_) {
return [];
}

if ($classNode->isAbstract()) {
return [];
}

$classReflection = $node->getClassReflection();

if (!$this->extendsFormRequestBase($classReflection)) {
return [];
}

if ($classReflection->hasNativeMethod(self::DTO_METHOD_NAME)) {
return [];
}

return [
RuleErrorBuilder::message(sprintf(
'%s extends FormRequest but does not define a toDto() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
Comment thread
Goosterhof marked this conversation as resolved.
$classReflection->getName(),
))
->identifier('enforceFormRequestToDto.missingToDtoMethod')
->line($classNode->getStartLine())
->build(),
];
}

/**
* Inheritance gate: the class must be a (transitive) subclass of the
* configured base FQCN. Uses PHPStan reflection — handles intermediate
* abstract layers and namespace-relative `extends` clauses. Short-name
* collisions in unrelated namespaces do not match, and the base class
* itself is not a subclass of itself.
*/
private function extendsFormRequestBase(ClassReflection $classReflection): bool
{
return $classReflection->isSubclassOf($this->formRequestBaseClass);
Comment thread
Goosterhof marked this conversation as resolved.
}
}
18 changes: 18 additions & 0 deletions tests/Fixtures/FormRequestToDto/AbstractBaseRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types = 1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

// Per-territory abstract intermediate (the entreezuil `BaseFormRequest`
// shape) — not a mutation request, declares no toDto(), and must NOT fire:
// abstract classes are exempt from the contract.
abstract class AbstractBaseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types = 1);

namespace App\Http\Requests;

use App\DataTransferObjects\StoreUserData;
use Illuminate\Foundation\Http\FormRequest;

// The intermediate abstract layer declares toDto(); the concrete leaf
// inherits it. Both must stay clean: abstract classes are exempt, and
// inherited declarations satisfy the contract (mirroring the source-of-truth
// Pest test's method_exists() matcher).
abstract class RequestWithSharedDto extends FormRequest
{
public function toDto(): StoreUserData
{
/** @var string $name */
$name = $this->validated()['name'];

return new StoreUserData($name);
}
}

final class CompliantInheritedToDtoRequest extends RequestWithSharedDto
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
];
}
}
29 changes: 29 additions & 0 deletions tests/Fixtures/FormRequestToDto/CompliantToDtoRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types = 1);

namespace App\Http\Requests;

use App\DataTransferObjects\StoreUserData;
use Illuminate\Foundation\Http\FormRequest;

final class CompliantToDtoRequest extends FormRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
];
}

public function toDto(): StoreUserData
{
/** @var string $name */
$name = $this->validated()['name'];

return new StoreUserData($name);
}
}
39 changes: 39 additions & 0 deletions tests/Fixtures/FormRequestToDto/TraitProvidedToDtoRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types = 1);

namespace App\Http\Requests;

use App\DataTransferObjects\StoreUserData;
use Illuminate\Foundation\Http\FormRequest;

// Trait-provided toDto() must satisfy the contract — the rule routes through
// PHPStan's hasNativeMethod(), which flattens trait-composed methods, mirroring
// the source-of-truth Pest test's method_exists() matcher. Pins the trait leg
// of the promise documented in the rule docblock, README, and CHANGELOG, which
// otherwise rests on an untested assumption about PHPStan internals.
trait ProvidesToDto
{
public function toDto(): StoreUserData
{
/** @var string $name */
$name = $this->validated()['name'];

return new StoreUserData($name);
}
}

final class TraitProvidedToDtoRequest extends FormRequest
{
use ProvidesToDto;

/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
];
}
}
24 changes: 24 additions & 0 deletions tests/Fixtures/FormRequestToDto/TransitiveViolatorRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types = 1);

namespace App\Http\Requests;

// Concrete leaf extending the abstract intermediate `AbstractBaseRequest`,
// which declares no toDto() anywhere in the chain. Transitive-violation
// detection must fire HERE, at the leaf — the inverse of
// CompliantInheritedToDtoRequest (where the abstract parent supplies toDto()).
// Proves the FQCN ancestor traversal detects omission through an intermediate
// abstract layer, not only direct framework-base extension.
final class TransitiveViolatorRequest extends AbstractBaseRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
];
}
}
23 changes: 23 additions & 0 deletions tests/Fixtures/FormRequestToDto/UnrelatedShortNameCollision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types = 1);

namespace App\Unrelated;

// A class with the short name `FormRequest` in an unrelated namespace MUST
// NOT be matched as the rule's base class. The detection uses the FQCN, not
// the short name.
abstract class FormRequest {}

final class UnrelatedShortNameCollision extends FormRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
];
}
}
Loading
Loading