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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

## [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.

### Fixed

- `EnforceFormRequestToDtoRule` — now accepts the plural `toDtos()` bulk-list convention, not only the singular `toDto()`. The rule checked a single `DTO_METHOD_NAME = 'toDto'` and false-positived on every FormRequest that converts its validated input to a `list<…Data>` via `toDtos()` — the canonical ADR-0020 bulk-reorder pattern (e.g. codebook's 5 bulk requests, kendo's `ReorderEpicsRequest`). A concrete FormRequest that declares or inherits **`toDto()` OR `toDtos()`** now satisfies the contract; only a class with **neither** is flagged (`DTO_METHOD_NAME` → `DTO_METHOD_NAMES = ['toDto', 'toDtos']`, pass if `hasNativeMethod()` is true for any). This realigns the Level-2 PHPStan rule with the Level-1 source-of-truth arch test (`FormRequestsTest`), which already accepts `toDtos()` and is green — the rule was stricter than the doctrine it encodes, contradicting the arch test it mirrors. The abstract-skip gate, the FormRequest-base inheritance gate, and the `formRequestToDtoExemptClasses` exemption param are unchanged. Error message updated to read `toDto()/toDtos()`. Regression-pinned by a new `CompliantToDtosRequest` fixture (defines only `toDtos(): array`, must not flag) alongside the existing neither-method positive case. **Versioning: PATCH per ADR-0021 §Versioning (false-positive narrowing — removes errors, adds none).** Seed: fleet survey 2026-07-01 (`origin/development`) — codebook (5 requests) + kendo (`ReorderEpicsRequest`, 1) define `toDtos()`; both were about to suppress this false positive per-consumer (the wrong level).

## [0.6.0] — 2026-07-01

**Release-as-a-whole: backward-compatible MINOR** — one new optional PHPStan parameter (`formRequestToDtoExemptClasses`, default `[]`) on `EnforceFormRequestToDtoRule`. Default-empty ⇒ zero new errors in existing consumers; a consumer adopts on its own `^0.5 → ^0.6` pin bump. Seed: war-room enforcement queue #131 — a class-keyed exemption surface so a territory can retire a duplicate local `FormRequestsTest` arch test and consolidate its exempt list into package config.
Expand Down
42 changes: 30 additions & 12 deletions src/Rules/EnforceFormRequestToDtoRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@

/**
* 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.
* subclass must define (or inherit) a `toDto()` method — or its ADR-0020
* bulk-list sibling `toDtos()` (one request → `list<…Data>`) — so validated
* input crosses the HTTP boundary as a typed DTO, never as a raw validated
* array. Without either 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
Expand All @@ -41,10 +43,13 @@
* 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).
* 3. Class neither declares nor inherits a `toDto()` OR `toDtos()`
* method — own declarations, parent-class declarations, and
* trait-provided methods of EITHER name all satisfy the contract
* (mirroring the source-of-truth Pest test's `method_exists()` matcher,
* which already accepts both). `toDtos()` is the ADR-0020 bulk-list
* convention (bulk-reorder requests convert to `list<…Data>`); the
* singular check alone false-positived on every such request.
*
* Legitimately DTO-less requests (entreezuil precedent: `LoginRequest`,
* whose auth flow calls `AuthManager::attempt()` directly) are suppressed
Expand Down Expand Up @@ -74,7 +79,18 @@
*/
final class EnforceFormRequestToDtoRule implements Rule
{
private const string DTO_METHOD_NAME = 'toDto';
/**
* The DTO-handoff contract is satisfied by either the singular `toDto()`
* (one request → one typed DTO) or the plural `toDtos()` (ADR-0020
* bulk-list pattern — one request → `list<…Data>`, e.g. a bulk-reorder
* request). A class declaring or inheriting EITHER satisfies the
* contract; only a class with NEITHER is flagged. Mirrors the
* source-of-truth Pest test (`FormRequestsTest`) which already accepts
* both method names.
*
* @var list<string>
*/
private const array DTO_METHOD_NAMES = ['toDto', 'toDtos'];

/**
* @param list<string> $exemptClasses fully-qualified class names to skip
Expand Down Expand Up @@ -110,8 +126,10 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

Comment thread
jasperboerhof marked this conversation as resolved.
if ($classReflection->hasNativeMethod(self::DTO_METHOD_NAME)) {
return [];
foreach (self::DTO_METHOD_NAMES as $method) {
if ($classReflection->hasNativeMethod($method)) {
return [];
}
}

// Class-keyed consumer exemption: exact-FQCN match against the
Expand All @@ -124,7 +142,7 @@ public function processNode(Node $node, Scope $scope): array

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).',
'%s extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
$classReflection->getName(),
))
->identifier('enforceFormRequestToDto.missingToDtoMethod')
Expand Down
42 changes: 42 additions & 0 deletions tests/Fixtures/FormRequestToDto/CompliantToDtosRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types = 1);

namespace App\Http\Requests;

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

/**
* ADR-0020 bulk-list convention: a concrete FormRequest that converts its
* validated input to a `list<…Data>` via `toDtos()` (plural) instead of a
* single `toDto()`. This is the canonical bulk-reorder shape — the rule must
* NOT flag it. The singular-only check false-positived on every request of
* this shape; this fixture pins the plural leg of the contract.
*/
final class CompliantToDtosRequest extends FormRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'names' => ['required', 'array'],
];
}

/**
* @return list<StoreUserData>
*/
public function toDtos(): array
{
/** @var list<string> $names */
$names = $this->validated()['names'];

return array_map(
static fn(string $name): StoreUserData => new StoreUserData($name),
$names,
);
}
}
31 changes: 24 additions & 7 deletions tests/Rules/EnforceFormRequestToDtoRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function testViolatorWithoutToDtoIsFlagged(): void
],
[
[
'App\Http\Requests\ViolatorRequest 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).',
'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
9,
],
],
Expand All @@ -46,6 +46,23 @@ public function testCompliantOwnToDtoIsNotFlagged(): void
);
}

public function testCompliantToDtosBulkListIsNotFlagged(): void
{
// ADR-0020 bulk-list convention: the request defines only the plural
// `toDtos(): array` (one request → list<…Data>), no singular toDto().
// The rule must NOT flag it — accepting either method name is the fix
// for the false positive against every bulk-reorder request, and it
// matches the source-of-truth FormRequestsTest arch test, which
// already accepts toDtos().
$this->analyse(
[
__DIR__ . '/../Fixtures/FormRequestToDto/_stubs.php',
__DIR__ . '/../Fixtures/FormRequestToDto/CompliantToDtosRequest.php',
],
[],
);
}

public function testCompliantInheritedToDtoIsNotFlagged(): void
{
// The abstract intermediate declares toDto(); the concrete leaf
Expand Down Expand Up @@ -93,7 +110,7 @@ public function testConcreteLeafExtendingAbstractBaseWithoutToDtoIsFlagged(): vo
],
[
[
'App\Http\Requests\TransitiveViolatorRequest 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).',
'App\Http\Requests\TransitiveViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
13,
],
],
Expand All @@ -119,7 +136,7 @@ public function testRuleResolvesFromExtensionNeonAndFires(): void
],
[
[
'App\Http\Requests\ViolatorRequest 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).',
'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
9,
],
],
Expand Down Expand Up @@ -162,7 +179,7 @@ public function testCustomBaseClassParameterMatchesAlternativeFqcn(): void
[__DIR__ . '/../Fixtures/FormRequestToDto/UnrelatedShortNameCollision.php'],
[
[
'App\Unrelated\UnrelatedShortNameCollision 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).',
'App\Unrelated\UnrelatedShortNameCollision extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
12,
],
],
Expand All @@ -182,7 +199,7 @@ public function testViolatorWithEmptyExemptListIsFlagged(): void
],
[
[
'App\Http\Requests\ViolatorRequest 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).',
'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
9,
],
],
Expand Down Expand Up @@ -223,7 +240,7 @@ public function testExemptionIsPreciseNotGlobalOffSwitch(): void
],
[
[
'App\Http\Requests\SecondViolatorRequest 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).',
'App\Http\Requests\SecondViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
12,
],
],
Expand All @@ -246,7 +263,7 @@ public function testExemptMatchIsExactFqcnNotShortNameOrOtherNamespace(): void
],
[
[
'App\Http\Requests\ViolatorRequest 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).',
'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).',
9,
],
],
Expand Down
Loading