Skip to content

Commit e9ef319

Browse files
authored
Merge pull request #33 from script-development/engineer/enforce-form-request-to-dto
engineer: add EnforceFormRequestToDtoRule (queue #55 instance 2, Phase-2 promotion)
2 parents 1f63c83 + ef0c5b1 commit e9ef319

15 files changed

Lines changed: 625 additions & 2 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev
2929
| `EnforceAuditTransactionScopeRule` | ADR-0029 | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` |
3030
| `ForbidEloquentMutationInControllersRule` | ADR-0011 + ADR-0019 | `forbidEloquentMutationInControllers.eloquentMutationInController` |
3131
| `EnforceResourceDataValidatorOptInRule` | ADR-0009 §EAGER_LOAD validator opt-in | `enforceResourceDataValidatorOptIn.missingValidatorCall` |
32+
| `EnforceFormRequestToDtoRule` | ADR-0012 §FormRequest → DTO Flow | `enforceFormRequestToDto.missingToDtoMethod` |
3233
| `EnforceCurrentUserAttributeRule` | War-room §Explicit over implicit | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` |
3334
| `ConnectionTransactionReturnTypeExtension` | (type extension, no rule) ||
3435

35-
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.
36+
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.
3637

3738
## Conventions
3839

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ includes:
4646
| `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. |
4747
| `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). |
4848
| `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. |
49+
| `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). |
4950
| `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). |
5051

5152
### `EnforceActionTransactionsRule` — write-method list
@@ -83,6 +84,29 @@ parameters:
8384

8485
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.
8586

87+
### `EnforceFormRequestToDtoRule` — configurable base class + exemptions
88+
89+
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`:
90+
91+
```neon
92+
parameters:
93+
formRequestBaseClass: 'App\Http\Requests\BaseFormRequest'
94+
```
95+
96+
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).
97+
98+
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:
99+
100+
```neon
101+
parameters:
102+
ignoreErrors:
103+
-
104+
identifier: enforceFormRequestToDto.missingToDtoMethod
105+
path: app/Http/Requests/LoginRequest.php
106+
```
107+
108+
Each ignore should carry a comment with rationale.
109+
86110
### Action namespace assumption
87111

88112
`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.

extension.neon

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,21 @@ parameters:
33
# base class. Default matches the kendo / emmie / ublgenie / entreezuil
44
# convention `App\Http\Resources\ResourceData`. Override per consumer
55
# `phpstan.neon` if a territory ships the base under a different FQCN.
6-
resourceDataBaseClass: 'App\\Http\\Resources\\ResourceData'
6+
# NOTE: single backslashes — NEON only unescapes `\\` inside DOUBLE quotes;
7+
# in single quotes (and unquoted) `\\` stays two literal characters, which
8+
# decodes to a class name that matches nothing and silently no-ops the rule.
9+
resourceDataBaseClass: 'App\Http\Resources\ResourceData'
10+
11+
# `EnforceFormRequestToDtoRule`: FQCN of the FormRequest base class.
12+
# Default matches the Laravel framework base every territory's requests
13+
# ultimately extend. Override per consumer `phpstan.neon` to narrow the
14+
# contract to a territory-local base FQCN. Single backslashes — see the
15+
# NEON-quoting note above.
16+
formRequestBaseClass: 'Illuminate\Foundation\Http\FormRequest'
717

818
parametersSchema:
919
resourceDataBaseClass: string()
20+
formRequestBaseClass: string()
1021

1122
services:
1223
-
@@ -38,6 +49,11 @@ services:
3849
arguments:
3950
resourceDataBaseClass: %resourceDataBaseClass%
4051
tags: [phpstan.rules.rule]
52+
-
53+
class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceFormRequestToDtoRule
54+
arguments:
55+
formRequestBaseClass: %formRequestBaseClass%
56+
tags: [phpstan.rules.rule]
4157
-
4258
class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceCurrentUserAttributeRule
4359
tags: [phpstan.rules.rule]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace ScriptDevelopment\PhpstanWarroomRules\Rules;
6+
7+
use Illuminate\Foundation\Http\FormRequest;
8+
use PhpParser\Node;
9+
use PhpParser\Node\Stmt\Class_;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Node\InClassNode;
12+
use PHPStan\Reflection\ClassReflection;
13+
use PHPStan\Rules\Rule;
14+
use PHPStan\Rules\RuleErrorBuilder;
15+
16+
use function sprintf;
17+
18+
/**
19+
* Enforces ADR-0012 §FormRequest → DTO Flow: every concrete `FormRequest`
20+
* subclass must define (or inherit) a `toDto()` method so validated input
21+
* crosses the HTTP boundary as a typed DTO, never as a raw validated array.
22+
* Without the method, controllers hand `$request->validated()` arrays to
23+
* Actions — untyped, key-renameable, and invisible to static analysis.
24+
*
25+
* Doctrine source: ADR-0012 (FormRequest → DTO Flow). Promoted from
26+
* entreezuil's reflection-based Pest arch test
27+
* (`tests/Arch/FormRequestsTest.php`, "form requests with mutation actions
28+
* define toDto method") — the second instance of the "arch test detects
29+
* misuse but not omission" enforcement shape under war-room enforcement
30+
* queue #55, dispositioned for Phase-2 promotion by the Commander on
31+
* 2026-05-07. Sister of `EnforceResourceDataValidatorOptInRule` (instance 3,
32+
* PR #20).
33+
*
34+
* Scope: classes whose ancestor chain includes the configured base FQCN
35+
* (default: `Illuminate\Foundation\Http\FormRequest`). Inheritance is
36+
* matched via PHPStan reflection — short-name collisions in unrelated
37+
* namespaces do not fire. Abstract classes are skipped (the per-territory
38+
* `BaseFormRequest` shape is an intermediate layer, not a mutation request).
39+
*
40+
* Detection (all three must hold):
41+
* 1. Class transitively extends the configured base class.
42+
* 2. Class is concrete (abstract intermediates are exempt).
43+
* 3. Class neither declares nor inherits a `toDto()` method — own
44+
* declarations, parent-class declarations, and trait-provided methods
45+
* all satisfy the contract (mirroring the source-of-truth Pest test's
46+
* `method_exists()` matcher).
47+
*
48+
* Legitimately DTO-less requests (entreezuil precedent: `LoginRequest`,
49+
* whose auth flow calls `AuthManager::attempt()` directly) are suppressed
50+
* per consumer `phpstan.neon` `ignoreErrors` keyed on the identifier —
51+
* never by name inside the rule, per the package convention.
52+
*
53+
* Implementation note: the constructor default uses `FormRequest::class`
54+
* (compile-time constant, never autoloads) instead of an FQCN string
55+
* literal. Pint's class_keyword fixer calls class_exists() on string
56+
* literals that look like class names, and the Pint phar bundles a real
57+
* `Illuminate\Foundation\Http\FormRequest` whose ValidatesWhenResolvedTrait
58+
* dependency is NOT bundled — a bare FQCN string literal anywhere in this
59+
* package's PHP source makes `composer format` fatal with "Trait not
60+
* found". The `use` import is alias-only; consumers analysing non-Laravel
61+
* trees are unaffected because `::class` resolution requires no autoload.
62+
*
63+
* @implements Rule<InClassNode>
64+
*/
65+
final class EnforceFormRequestToDtoRule implements Rule
66+
{
67+
private const string DTO_METHOD_NAME = 'toDto';
68+
69+
public function __construct(
70+
private string $formRequestBaseClass = FormRequest::class,
71+
) {}
72+
73+
public function getNodeType(): string
74+
{
75+
return InClassNode::class;
76+
}
77+
78+
public function processNode(Node $node, Scope $scope): array
79+
{
80+
$classNode = $node->getOriginalNode();
81+
82+
if (!$classNode instanceof Class_) {
83+
return [];
84+
}
85+
86+
if ($classNode->isAbstract()) {
87+
return [];
88+
}
89+
90+
$classReflection = $node->getClassReflection();
91+
92+
if (!$this->extendsFormRequestBase($classReflection)) {
93+
return [];
94+
}
95+
96+
if ($classReflection->hasNativeMethod(self::DTO_METHOD_NAME)) {
97+
return [];
98+
}
99+
100+
return [
101+
RuleErrorBuilder::message(sprintf(
102+
'%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).',
103+
$classReflection->getName(),
104+
))
105+
->identifier('enforceFormRequestToDto.missingToDtoMethod')
106+
->line($classNode->getStartLine())
107+
->build(),
108+
];
109+
}
110+
111+
/**
112+
* Inheritance gate: the class must be a (transitive) subclass of the
113+
* configured base FQCN. Uses PHPStan reflection — handles intermediate
114+
* abstract layers and namespace-relative `extends` clauses. Short-name
115+
* collisions in unrelated namespaces do not match, and the base class
116+
* itself is not a subclass of itself.
117+
*/
118+
private function extendsFormRequestBase(ClassReflection $classReflection): bool
119+
{
120+
return $classReflection->isSubclassOf($this->formRequestBaseClass);
121+
}
122+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
use Illuminate\Foundation\Http\FormRequest;
8+
9+
// Per-territory abstract intermediate (the entreezuil `BaseFormRequest`
10+
// shape) — not a mutation request, declares no toDto(), and must NOT fire:
11+
// abstract classes are exempt from the contract.
12+
abstract class AbstractBaseRequest extends FormRequest
13+
{
14+
public function authorize(): bool
15+
{
16+
return true;
17+
}
18+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
use App\DataTransferObjects\StoreUserData;
8+
use Illuminate\Foundation\Http\FormRequest;
9+
10+
// The intermediate abstract layer declares toDto(); the concrete leaf
11+
// inherits it. Both must stay clean: abstract classes are exempt, and
12+
// inherited declarations satisfy the contract (mirroring the source-of-truth
13+
// Pest test's method_exists() matcher).
14+
abstract class RequestWithSharedDto extends FormRequest
15+
{
16+
public function toDto(): StoreUserData
17+
{
18+
/** @var string $name */
19+
$name = $this->validated()['name'];
20+
21+
return new StoreUserData($name);
22+
}
23+
}
24+
25+
final class CompliantInheritedToDtoRequest extends RequestWithSharedDto
26+
{
27+
/**
28+
* @return array<string, mixed>
29+
*/
30+
public function rules(): array
31+
{
32+
return [
33+
'name' => ['required', 'string'],
34+
];
35+
}
36+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
use App\DataTransferObjects\StoreUserData;
8+
use Illuminate\Foundation\Http\FormRequest;
9+
10+
final class CompliantToDtoRequest extends FormRequest
11+
{
12+
/**
13+
* @return array<string, mixed>
14+
*/
15+
public function rules(): array
16+
{
17+
return [
18+
'name' => ['required', 'string'],
19+
];
20+
}
21+
22+
public function toDto(): StoreUserData
23+
{
24+
/** @var string $name */
25+
$name = $this->validated()['name'];
26+
27+
return new StoreUserData($name);
28+
}
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
use App\DataTransferObjects\StoreUserData;
8+
use Illuminate\Foundation\Http\FormRequest;
9+
10+
// Trait-provided toDto() must satisfy the contract — the rule routes through
11+
// PHPStan's hasNativeMethod(), which flattens trait-composed methods, mirroring
12+
// the source-of-truth Pest test's method_exists() matcher. Pins the trait leg
13+
// of the promise documented in the rule docblock, README, and CHANGELOG, which
14+
// otherwise rests on an untested assumption about PHPStan internals.
15+
trait ProvidesToDto
16+
{
17+
public function toDto(): StoreUserData
18+
{
19+
/** @var string $name */
20+
$name = $this->validated()['name'];
21+
22+
return new StoreUserData($name);
23+
}
24+
}
25+
26+
final class TraitProvidedToDtoRequest extends FormRequest
27+
{
28+
use ProvidesToDto;
29+
30+
/**
31+
* @return array<string, mixed>
32+
*/
33+
public function rules(): array
34+
{
35+
return [
36+
'name' => ['required', 'string'],
37+
];
38+
}
39+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
// Concrete leaf extending the abstract intermediate `AbstractBaseRequest`,
8+
// which declares no toDto() anywhere in the chain. Transitive-violation
9+
// detection must fire HERE, at the leaf — the inverse of
10+
// CompliantInheritedToDtoRequest (where the abstract parent supplies toDto()).
11+
// Proves the FQCN ancestor traversal detects omission through an intermediate
12+
// abstract layer, not only direct framework-base extension.
13+
final class TransitiveViolatorRequest extends AbstractBaseRequest
14+
{
15+
/**
16+
* @return array<string, mixed>
17+
*/
18+
public function rules(): array
19+
{
20+
return [
21+
'name' => ['required', 'string'],
22+
];
23+
}
24+
}

0 commit comments

Comments
 (0)