@hellraisercenobit/ai-engineering-gate 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/agents/design-pattern-reviewer.md +40 -0
- package/agents/modern-typescript-reviewer.md +24 -0
- package/agents/testing-pattern-reviewer.md +23 -0
- package/bin/ai-engineering-gate.mjs +6 -0
- package/contracts/axi/axi-standard.md +273 -0
- package/contracts/axi/pin.json +36 -0
- package/contracts/members.json +58 -0
- package/contracts/schemas/arbitration.schema.json +45 -0
- package/contracts/schemas/decision-envelope.schema.json +215 -0
- package/contracts/schemas/declaration.schema.json +190 -0
- package/contracts/schemas/dispute.schema.json +67 -0
- package/contracts/schemas/evidence-append.schema.json +72 -0
- package/contracts/schemas/gate-output.schema.json +296 -0
- package/contracts/schemas/marker.schema.json +75 -0
- package/contracts/schemas/review-envelope.schema.json +378 -0
- package/contracts/schemas/verdict-record.schema.json +191 -0
- package/contracts/suite-contract.md +270 -0
- package/dist/ai-engineering-gate.mjs +2762 -0
- package/package.json +47 -0
- package/skills/engineering/review-design-patterns/LICENSE +6 -0
- package/skills/engineering/review-design-patterns/SKILL.md +196 -0
- package/skills/engineering/review-design-patterns/agents/openai.yaml +3 -0
- package/skills/engineering/review-design-patterns/references/smell-signatures.md +117 -0
- package/skills/engineering/review-design-patterns/review-design-patterns.yaml +25 -0
- package/skills/engineering/review-modern-typescript/LICENSE +21 -0
- package/skills/engineering/review-modern-typescript/SKILL.md +88 -0
- package/skills/engineering/review-modern-typescript/agents/openai.yaml +3 -0
- package/skills/engineering/review-modern-typescript/references/smell-signatures.md +36 -0
- package/skills/engineering/review-modern-typescript/review-modern-typescript.yaml +22 -0
- package/skills/engineering/review-testing-patterns/SKILL.md +108 -0
- package/skills/engineering/review-testing-patterns/agents/openai.yaml +4 -0
- package/skills/engineering/review-testing-patterns/references/compiler-profile.mjs +103 -0
- package/skills/engineering/review-testing-patterns/references/smell-signatures.md +30 -0
- package/skills/engineering/review-testing-patterns/review-testing-patterns.yaml +23 -0
- package/skills/engineering/transpose-design-patterns/LICENSE +6 -0
- package/skills/engineering/transpose-design-patterns/SKILL.md +231 -0
- package/skills/engineering/transpose-design-patterns/agents/openai.yaml +3 -0
- package/skills/engineering/transpose-design-patterns/references/arbitration.schema.json +45 -0
- package/skills/engineering/transpose-design-patterns/references/decision-envelope.schema.json +215 -0
- package/skills/engineering/transpose-design-patterns/references/declaration.schema.json +190 -0
- package/skills/engineering/transpose-design-patterns/references/design-decision-record.schema.json +731 -0
- package/skills/engineering/transpose-design-patterns/references/dispute.schema.json +67 -0
- package/skills/engineering/transpose-design-patterns/references/evidence-append.schema.json +72 -0
- package/skills/engineering/transpose-design-patterns/references/marker.schema.json +75 -0
- package/skills/engineering/transpose-design-patterns/references/pattern-catalog.md +305 -0
- package/skills/engineering/transpose-design-patterns/references/record.example.json +120 -0
- package/skills/engineering/transpose-design-patterns/references/review-envelope.schema.json +378 -0
- package/skills/engineering/transpose-design-patterns/references/suite-contract.md +270 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-angular.md +224 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-php.md +561 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-quarkus.md +283 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-react.md +256 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-vanilla.md +204 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-vue.md +234 -0
- package/skills/engineering/transpose-design-patterns/transpose-design-patterns.yaml +24 -0
- package/skills/engineering/transpose-modern-typescript/LICENSE +21 -0
- package/skills/engineering/transpose-modern-typescript/SKILL.md +90 -0
- package/skills/engineering/transpose-modern-typescript/agents/openai.yaml +3 -0
- package/skills/engineering/transpose-modern-typescript/references/arbitration.schema.json +45 -0
- package/skills/engineering/transpose-modern-typescript/references/catalog.md +38 -0
- package/skills/engineering/transpose-modern-typescript/references/collections.md +50 -0
- package/skills/engineering/transpose-modern-typescript/references/compatibility.md +61 -0
- package/skills/engineering/transpose-modern-typescript/references/decision-envelope.schema.json +215 -0
- package/skills/engineering/transpose-modern-typescript/references/decision-record.schema.json +830 -0
- package/skills/engineering/transpose-modern-typescript/references/declaration.schema.json +190 -0
- package/skills/engineering/transpose-modern-typescript/references/dispute.schema.json +67 -0
- package/skills/engineering/transpose-modern-typescript/references/evidence-append.schema.json +72 -0
- package/skills/engineering/transpose-modern-typescript/references/idioms.md +74 -0
- package/skills/engineering/transpose-modern-typescript/references/marker.schema.json +75 -0
- package/skills/engineering/transpose-modern-typescript/references/platform.md +147 -0
- package/skills/engineering/transpose-modern-typescript/references/record.example.json +172 -0
- package/skills/engineering/transpose-modern-typescript/references/record.md +37 -0
- package/skills/engineering/transpose-modern-typescript/references/review-envelope.schema.json +378 -0
- package/skills/engineering/transpose-modern-typescript/references/suite-contract.md +270 -0
- package/skills/engineering/transpose-modern-typescript/references/types.md +97 -0
- package/skills/engineering/transpose-modern-typescript/transpose-modern-typescript.yaml +22 -0
- package/skills/engineering/transpose-testing-patterns/SKILL.md +96 -0
- package/skills/engineering/transpose-testing-patterns/agents/openai.yaml +4 -0
- package/skills/engineering/transpose-testing-patterns/references/arbitration.schema.json +45 -0
- package/skills/engineering/transpose-testing-patterns/references/catalog.md +218 -0
- package/skills/engineering/transpose-testing-patterns/references/decision-envelope.schema.json +215 -0
- package/skills/engineering/transpose-testing-patterns/references/decision-record.schema.json +857 -0
- package/skills/engineering/transpose-testing-patterns/references/declaration.schema.json +190 -0
- package/skills/engineering/transpose-testing-patterns/references/detect-adapter.mjs +98 -0
- package/skills/engineering/transpose-testing-patterns/references/dispute.schema.json +67 -0
- package/skills/engineering/transpose-testing-patterns/references/doubles-data.md +45 -0
- package/skills/engineering/transpose-testing-patterns/references/evidence-append.schema.json +72 -0
- package/skills/engineering/transpose-testing-patterns/references/journal-event.schema.json +115 -0
- package/skills/engineering/transpose-testing-patterns/references/marker.schema.json +75 -0
- package/skills/engineering/transpose-testing-patterns/references/record.example.json +230 -0
- package/skills/engineering/transpose-testing-patterns/references/record.md +25 -0
- package/skills/engineering/transpose-testing-patterns/references/review-envelope.schema.json +378 -0
- package/skills/engineering/transpose-testing-patterns/references/suite-contract.md +270 -0
- package/skills/engineering/transpose-testing-patterns/references/tdd.md +62 -0
- package/skills/engineering/transpose-testing-patterns/references/transpose-codeception.md +84 -0
- package/skills/engineering/transpose-testing-patterns/references/transpose-karma-jasmine-angular.md +73 -0
- package/skills/engineering/transpose-testing-patterns/references/transpose-vitest.md +111 -0
- package/skills/engineering/transpose-testing-patterns/references/typescript.md +42 -0
- package/skills/engineering/transpose-testing-patterns/transpose-testing-patterns.yaml +23 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# PHP Transposition - Design Patterns (PHP 8.2+, Symfony 6.4+)
|
|
2
|
+
|
|
3
|
+
Generic, portable PHP wiring for the patterns in [`pattern-catalog.md`](pattern-catalog.md).
|
|
4
|
+
Framework-specific only - keep business logic framework-agnostic in the catalog. Examples use
|
|
5
|
+
throwaway domains (user, cart, payment, order); substitute your own types. This guide states **common**
|
|
6
|
+
PHP practice at the **PHP 8.2 floor** with **Symfony 6.4** as the container - the current LTS pair - not
|
|
7
|
+
any single project's house rules. Without Symfony the class shapes stay the same and the wiring moves to a
|
|
8
|
+
composition root (any PSR-11 container); Laravel maps one-to-one (service-provider bindings, tagged
|
|
9
|
+
services) and is not covered here. _Beyond PHP 8.2_ at the end lists what changes when the floor moves.
|
|
10
|
+
|
|
11
|
+
## PHP building blocks
|
|
12
|
+
|
|
13
|
+
The transpositions below lean on a few modern-PHP defaults:
|
|
14
|
+
|
|
15
|
+
- **`declare(strict_types=1)`** in every file - scalar arguments are checked, not coerced, so a wrong type
|
|
16
|
+
fails at the call site instead of deep inside.
|
|
17
|
+
- **`final` and `readonly` by default** - `final readonly class` for DTOs, commands, value objects and
|
|
18
|
+
stateless services; **constructor property promotion**; `new` in initializers for default collaborators.
|
|
19
|
+
Dynamic properties are deprecated since 8.2: declare every property. Open a class (`abstract`,
|
|
20
|
+
non-final) only for a proven extension point.
|
|
21
|
+
- **Backed enums** for closed sets; **`match` with no `default`** over an enum is the exhaustive dispatch
|
|
22
|
+
(`UnhandledMatchError` at runtime, a PHPStan error at analysis time when a case is missing). Enums carry
|
|
23
|
+
methods, constants and interfaces, so a closed Strategy set can live on the enum itself.
|
|
24
|
+
- **Interfaces are the ports**; intersection types (`Countable&Traversable`) require two ports, DNF types
|
|
25
|
+
(8.2, `(A&B)|null`) make them nullable.
|
|
26
|
+
- **First-class callable syntax** (`UserDto::fromEntity(...)`) and **named arguments** for mapping and
|
|
27
|
+
DTO construction; `never` for helpers that always throw; standalone `null` / `true` / `false` types (8.2).
|
|
28
|
+
- **Static analysis is the other half of the type system** - PHPStan (at the project's level, `max` for
|
|
29
|
+
new code) with generics and shapes (`@template T`, `list<T>`, `array<string, T>`, `array{id: int}`),
|
|
30
|
+
**Deptrac** for layer rules, **php-cs-fixer** on `@PER-CS` (the successor of PSR-12) or `@Symfony`,
|
|
31
|
+
**Rector** for upgrades. `mixed` and bare `array` across a layer boundary are a finding, not a type.
|
|
32
|
+
- **Symfony DI, compiled and attribute-driven** - `services.yaml` keeps only `_defaults` (`autowire`,
|
|
33
|
+
`autoconfigure`) and the `App\` resource; per-service wiring goes on the class with attributes
|
|
34
|
+
(`#[Autowire]`, `#[AutoconfigureTag]`, `#[AutowireIterator]`, `#[AutowireLocator]`, `#[AsAlias]`,
|
|
35
|
+
`#[AsDecorator]`, `#[When]`, `#[Target]`). **Constructor injection only**; every service is shared
|
|
36
|
+
(one instance per container) and **stateless**.
|
|
37
|
+
- **Shared-nothing runtime** - PHP-FPM builds the container per request and drops it after. Worker
|
|
38
|
+
runtimes (Messenger consumers, FrankenPHP worker mode, RoadRunner, Swoole) keep it alive, so state kept
|
|
39
|
+
on a service leaks across requests there. Design for the worker case: stateless services, or
|
|
40
|
+
`ResetInterface`.
|
|
41
|
+
|
|
42
|
+
```php
|
|
43
|
+
<?php
|
|
44
|
+
|
|
45
|
+
declare(strict_types=1);
|
|
46
|
+
|
|
47
|
+
namespace App\Checkout;
|
|
48
|
+
|
|
49
|
+
use Symfony\Component\Clock\ClockInterface;
|
|
50
|
+
|
|
51
|
+
final readonly class CheckoutService
|
|
52
|
+
{
|
|
53
|
+
public function __construct(
|
|
54
|
+
private OrderRepository $orders,
|
|
55
|
+
private ClockInterface $clock,
|
|
56
|
+
) {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Pattern → PHP wiring
|
|
62
|
+
|
|
63
|
+
### Strategy → tagged services collected by `#[AutowireIterator]` (no `switch`)
|
|
64
|
+
|
|
65
|
+
Each strategy is a `final readonly class` implementing the interface and exposing `supports()`. The
|
|
66
|
+
**interface carries `#[AutoconfigureTag]`**, so implementing it _is_ registering it - no YAML entry, no
|
|
67
|
+
central list. The consumer injects the tag with `#[AutowireIterator]` and picks by predicate - DI replaces
|
|
68
|
+
the `switch`. Order with `#[AsTaggedItem(priority: …)]` on an implementation when the first match must win.
|
|
69
|
+
|
|
70
|
+
```php
|
|
71
|
+
#[AutoconfigureTag('app.payment_strategy')]
|
|
72
|
+
interface PaymentStrategy
|
|
73
|
+
{
|
|
74
|
+
public function supports(PaymentMethod $method): bool;
|
|
75
|
+
|
|
76
|
+
public function pay(Money $amount): Receipt;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
final readonly class PaymentService
|
|
80
|
+
{
|
|
81
|
+
/** @param iterable<PaymentStrategy> $strategies */
|
|
82
|
+
public function __construct(
|
|
83
|
+
#[AutowireIterator('app.payment_strategy')]
|
|
84
|
+
private iterable $strategies,
|
|
85
|
+
) {
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public function pay(PaymentMethod $method, Money $amount): Receipt
|
|
89
|
+
{
|
|
90
|
+
foreach ($this->strategies as $strategy) {
|
|
91
|
+
if ($strategy->supports($method)) {
|
|
92
|
+
return $strategy->pay($amount);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw UnsupportedPaymentMethod::for($method);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
For a **closed** set known at compile time, the typed alternative is a backed enum whose method dispatches
|
|
102
|
+
with an exhaustive `match` - no `default`, so a new case is a PHPStan error until every `match` handles it:
|
|
103
|
+
|
|
104
|
+
```php
|
|
105
|
+
enum PaymentMethod: string
|
|
106
|
+
{
|
|
107
|
+
case Card = 'card';
|
|
108
|
+
case Sepa = 'sepa';
|
|
109
|
+
|
|
110
|
+
public function feeRate(): float
|
|
111
|
+
{
|
|
112
|
+
return match ($this) {
|
|
113
|
+
self::Card => 0.029,
|
|
114
|
+
self::Sepa => 0.004,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A `switch` (or an `if` chain) on a **string** type tag is the smell in both shapes.
|
|
121
|
+
|
|
122
|
+
### Registry → `#[AutowireLocator]` indexed by the tag (lazy), or an indexed `#[AutowireIterator]`
|
|
123
|
+
|
|
124
|
+
DI _is_ the registry. Index the tagged services by a key each implementation exposes - a
|
|
125
|
+
`public static function` named by `defaultIndexMethod`, or `#[AsTaggedItem(index: 'pdf')]` on the class -
|
|
126
|
+
and inject either:
|
|
127
|
+
|
|
128
|
+
- **`#[AutowireLocator]`** → a `ServiceLocator` (`has()` / `get()`), **lazy**: a plugin is instantiated
|
|
129
|
+
only when asked for. The default for registries with many or heavy entries.
|
|
130
|
+
- **`#[AutowireIterator]`** with the same index → an eager keyed iterable, when you must walk all
|
|
131
|
+
entries (fail-fast duplicate checks, listing the available keys).
|
|
132
|
+
|
|
133
|
+
```php
|
|
134
|
+
#[AutoconfigureTag('app.exporter')]
|
|
135
|
+
interface Exporter
|
|
136
|
+
{
|
|
137
|
+
public static function format(): string;
|
|
138
|
+
|
|
139
|
+
public function export(Report $report): string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
final readonly class ExporterRegistry
|
|
143
|
+
{
|
|
144
|
+
/** @param ServiceProviderInterface<Exporter> $exporters */
|
|
145
|
+
public function __construct(
|
|
146
|
+
#[AutowireLocator('app.exporter', defaultIndexMethod: 'format')]
|
|
147
|
+
private ServiceProviderInterface $exporters,
|
|
148
|
+
) {
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public function forFormat(string $format): Exporter
|
|
152
|
+
{
|
|
153
|
+
if (!$this->exporters->has($format)) {
|
|
154
|
+
throw UnknownExportFormat::for($format);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return $this->exporters->get($format);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
New implementations are discovered automatically - no central `switch`, no hand-maintained map. Two
|
|
163
|
+
services declaring the same index: the container keeps the last one silently, so when a duplicate key is
|
|
164
|
+
a bug, build the map yourself from the iterator and throw in the constructor.
|
|
165
|
+
|
|
166
|
+
A **scoped** locator injected for one explicit tag is a typed registry, **not** the service-locator
|
|
167
|
+
anti-pattern. The anti-pattern is injecting the whole container (the application `ContainerInterface`, a
|
|
168
|
+
static `Container::get()` facade, `$this->container->get()` in a service) and fetching anything by string.
|
|
169
|
+
|
|
170
|
+
### Factory → a factory service (`factory:`), `#[When]` + `#[AsAlias]` for env choice, static named constructors
|
|
171
|
+
|
|
172
|
+
Isolate creation logic in a dedicated factory when it is non-trivial, config-driven, or builds a
|
|
173
|
+
third-party type you cannot annotate; return the abstraction. Symfony has no `#[Factory]` attribute: the
|
|
174
|
+
**product** is declared in `config/services.yaml` with `factory:`, pointing at the factory service.
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
App\Payment\PaymentGateway:
|
|
178
|
+
factory: ['@App\Payment\GatewayFactory', 'create']
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```php
|
|
182
|
+
final readonly class GatewayFactory
|
|
183
|
+
{
|
|
184
|
+
public function __construct(
|
|
185
|
+
private HttpClientInterface $http,
|
|
186
|
+
#[Autowire(env: 'PAYMENT_GATEWAY_URL')]
|
|
187
|
+
private string $url,
|
|
188
|
+
#[\SensitiveParameter, Autowire(env: 'PAYMENT_GATEWAY_TOKEN')]
|
|
189
|
+
private string $token,
|
|
190
|
+
) {
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
public function create(): PaymentGateway
|
|
194
|
+
{
|
|
195
|
+
return new HttpPaymentGateway($this->http, $this->url, $this->token);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
When the "creation logic" is only **which implementation** for **which environment**, skip the factory:
|
|
201
|
+
`#[When(env:)]` registers a class in one environment only and `#[AsAlias]` binds it to the interface.
|
|
202
|
+
|
|
203
|
+
```php
|
|
204
|
+
#[When(env: 'prod')]
|
|
205
|
+
#[AsAlias(PaymentGateway::class)]
|
|
206
|
+
final readonly class HttpPaymentGateway implements PaymentGateway { /* ... */ }
|
|
207
|
+
|
|
208
|
+
#[When(env: 'dev')]
|
|
209
|
+
#[When(env: 'test')]
|
|
210
|
+
#[AsAlias(PaymentGateway::class)]
|
|
211
|
+
final readonly class FakePaymentGateway implements PaymentGateway { /* ... */ }
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
For **value objects**, creation logic lives in a static named constructor (`Money::fromCents()`,
|
|
215
|
+
`ForgotPassword::create()`): the constructor stays total, the name says which rule applies. A factory
|
|
216
|
+
whose `create()` only calls `new` with no decision is ceremony - delete it and let autowiring construct
|
|
217
|
+
the class.
|
|
218
|
+
|
|
219
|
+
### Command → a `readonly` command object + an invokable handler the controller (or the bus) delegates to
|
|
220
|
+
|
|
221
|
+
Encapsulate one action as a **command** (a `final readonly class` with public properties and validation
|
|
222
|
+
constraints) handled by a **handler** (`final readonly class` with `__invoke`). The controller binds the
|
|
223
|
+
request into the command with `#[MapRequestPayload]` and delegates; it never holds the logic. Put the
|
|
224
|
+
**transaction** around the handler (the ORM's transaction wrapper, or Messenger's
|
|
225
|
+
`DoctrineTransactionMiddleware`), never in the controller or the entity.
|
|
226
|
+
|
|
227
|
+
```php
|
|
228
|
+
final readonly class PlaceOrder
|
|
229
|
+
{
|
|
230
|
+
public function __construct(
|
|
231
|
+
#[Assert\Uuid]
|
|
232
|
+
public string $cartId,
|
|
233
|
+
#[Assert\Positive]
|
|
234
|
+
public int $amountCents,
|
|
235
|
+
) {
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[AsMessageHandler]
|
|
240
|
+
final readonly class PlaceOrderHandler
|
|
241
|
+
{
|
|
242
|
+
public function __construct(private OrderRepository $orders)
|
|
243
|
+
{
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
public function __invoke(PlaceOrder $command): OrderId
|
|
247
|
+
{
|
|
248
|
+
return $this->orders->save(Order::place($command));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
final class OrderController extends AbstractController
|
|
253
|
+
{
|
|
254
|
+
#[Route('/orders', methods: ['POST'])]
|
|
255
|
+
public function create(#[MapRequestPayload] PlaceOrder $command, PlaceOrderHandler $handler): JsonResponse
|
|
256
|
+
{
|
|
257
|
+
return $this->json(['id' => $handler($command)->value], Response::HTTP_CREATED);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The same handler runs synchronously (inject and call it) or through **Messenger** (`#[AsMessageHandler]`,
|
|
263
|
+
dispatch the command on `MessageBusInterface`, route it to a transport for async) - the caller changes,
|
|
264
|
+
the Command does not. Drop the attribute when Messenger is not installed.
|
|
265
|
+
|
|
266
|
+
A controller action, a repository method or an ORM model method is **not automatically** the Command -
|
|
267
|
+
when the action must be reusable, composable, or unit-testable in isolation, keep it a distinct handler
|
|
268
|
+
the controller / model **delegates** to. Full rule + layering: catalog → _Command_.
|
|
269
|
+
|
|
270
|
+
### Adapter / DTO Mapping → `final readonly` DTOs with static `from*` constructors at the boundary
|
|
271
|
+
|
|
272
|
+
Map DTO ↔ domain / entity at the HTTP boundary; **never serialize an entity or ORM model**
|
|
273
|
+
(`$this->json($entity)`, `$model->toArray()`): it leaks columns, triggers lazy relations and couples the
|
|
274
|
+
API to the schema. The catalog's canonical mapper is TypeScript; the PHP equivalent is a `readonly` class
|
|
275
|
+
with a static named constructor, built with named arguments. Lists map with a first-class callable:
|
|
276
|
+
`array_map(UserDto::fromEntity(...), $users)`.
|
|
277
|
+
|
|
278
|
+
```php
|
|
279
|
+
final readonly class UserDto
|
|
280
|
+
{
|
|
281
|
+
public function __construct(
|
|
282
|
+
public string $id,
|
|
283
|
+
public \DateTimeImmutable $createdAt,
|
|
284
|
+
) {
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
public static function fromEntity(User $user): self
|
|
288
|
+
{
|
|
289
|
+
return new self(id: $user->id()->toString(), createdAt: $user->createdAt());
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Inbound, `#[MapRequestPayload]` / `#[MapQueryString]` deserialize and validate the request into a DTO
|
|
295
|
+
(422 on a violation) - the controller never reads `$request->request->get()`. Outbound, `$this->json($dto)`
|
|
296
|
+
normalizes public properties. Keep DTOs and mappers in the application / presentation layer; the domain
|
|
297
|
+
must not import them.
|
|
298
|
+
|
|
299
|
+
### Composition → constructor injection + decorators + events (not inheritance)
|
|
300
|
+
|
|
301
|
+
Compose behavior through injected interfaces, **decorators** for cross-cutting concerns
|
|
302
|
+
(`#[AsDecorator]` on the wrapper, `#[AutowireDecorated]` on the inner argument), and **events** to decouple
|
|
303
|
+
producers from consumers (`EventDispatcherInterface` + `#[AsEventListener]`, the event inferred from the
|
|
304
|
+
`__invoke` parameter type). Messenger middleware plays the same role on the bus. Share behavior with
|
|
305
|
+
`final` classes and interfaces, not with `abstract` base classes; a trait is acceptable only for a small,
|
|
306
|
+
stateless, dependency-free helper.
|
|
307
|
+
|
|
308
|
+
```php
|
|
309
|
+
#[AsDecorator(decorates: PaymentGateway::class)]
|
|
310
|
+
final readonly class LoggingPaymentGateway implements PaymentGateway
|
|
311
|
+
{
|
|
312
|
+
public function __construct(
|
|
313
|
+
#[AutowireDecorated]
|
|
314
|
+
private PaymentGateway $inner,
|
|
315
|
+
private LoggerInterface $logger,
|
|
316
|
+
) {
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
public function charge(Money $amount): Receipt
|
|
320
|
+
{
|
|
321
|
+
$this->logger->info('payment.charge', ['cents' => $amount->cents]);
|
|
322
|
+
|
|
323
|
+
return $this->inner->charge($amount);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
final readonly class OrderPlaced
|
|
328
|
+
{
|
|
329
|
+
public function __construct(public OrderId $id)
|
|
330
|
+
{
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[AsEventListener]
|
|
335
|
+
final readonly class SendOrderConfirmation
|
|
336
|
+
{
|
|
337
|
+
public function __invoke(OrderPlaced $event): void { /* ... */ }
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Singleton / shared state → a stateless shared service; explicit stores for cross-request state
|
|
342
|
+
|
|
343
|
+
Every Symfony service is already one instance per container (`shared: true`): a `static $instance`
|
|
344
|
+
Singleton adds nothing but a hidden global. The **real** question in PHP is _which lifetime_ the state
|
|
345
|
+
needs:
|
|
346
|
+
|
|
347
|
+
- **Per request** - keep it in the request (`RequestStack`) or pass it as an argument, not on a service.
|
|
348
|
+
- **Across requests** - it must leave the process: **Cache contracts** (`CacheInterface::get()` with a
|
|
349
|
+
callback), **Lock** (`LockFactory`) for mutual exclusion, the database. A property on a service is _not_
|
|
350
|
+
shared across requests under PHP-FPM, and _is_ under a worker runtime - both are bugs.
|
|
351
|
+
- **A memo that must exist** (a per-run cache) - the service implements **`ResetInterface`**; the
|
|
352
|
+
framework autoconfigures it on `kernel.reset` and worker runtimes clear it between requests / messages.
|
|
353
|
+
- **Inside one process that walks several tenants** (a cron, a batch command, an export that loops over
|
|
354
|
+
tenants) - `ResetInterface` alone does **not** reset a service between iterations of your own loop.
|
|
355
|
+
The container reset lifecycle is separate from that loop. The lifetime that matters is the
|
|
356
|
+
**unit of work**, and here that is the iteration, not the request. Give the cache to the caller - a
|
|
357
|
+
small object built per unit and passed in - so its lifetime is visible at the call site instead of
|
|
358
|
+
being a property nobody scopes.
|
|
359
|
+
An existing explicit reset at every iteration boundary can also satisfy this lifetime. Verify error
|
|
360
|
+
paths and two tenants in one process. See Symfony's [service reset lifecycle](https://symfony.com/doc/6.4/messenger.html#stateless-worker).
|
|
361
|
+
|
|
362
|
+
```php
|
|
363
|
+
final class FeatureFlags implements ResetInterface
|
|
364
|
+
{
|
|
365
|
+
/** @var array<string, bool> */
|
|
366
|
+
private array $memo = [];
|
|
367
|
+
|
|
368
|
+
public function __construct(
|
|
369
|
+
private readonly CacheInterface $cache,
|
|
370
|
+
private readonly FlagStore $store,
|
|
371
|
+
) {
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
public function isEnabled(string $flag): bool
|
|
375
|
+
{
|
|
376
|
+
return $this->memo[$flag] ??= $this->cache->get("flag.$flag", function (ItemInterface $item) use ($flag): bool {
|
|
377
|
+
$item->expiresAfter(300);
|
|
378
|
+
|
|
379
|
+
return $this->store->isEnabled($flag);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
public function reset(): void
|
|
384
|
+
{
|
|
385
|
+
$this->memo = [];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Expose a read-only surface: no public setters on a shared service, no `static` properties, no `$GLOBALS`.
|
|
391
|
+
|
|
392
|
+
## Cross-cutting PHP practice
|
|
393
|
+
|
|
394
|
+
### Keep controllers thin
|
|
395
|
+
|
|
396
|
+
A controller binds input (`#[MapRequestPayload]`, `#[MapQueryString]`, `#[MapQueryParameter]`), delegates to
|
|
397
|
+
a handler or service, and returns a DTO or a `Response`. No business rule, no ORM query, no mapping in
|
|
398
|
+
the controller. `final class`, one action per method, `#[Route]` on the method.
|
|
399
|
+
|
|
400
|
+
### Validate at the boundary, enforce invariants in the constructor
|
|
401
|
+
|
|
402
|
+
Constraints (`#[Assert\*]`) live on command / DTO properties and run when the request is mapped; a violation
|
|
403
|
+
is a 422 before the handler runs. Domain invariants (a `Money` is never negative) are enforced in the
|
|
404
|
+
value object's constructor by throwing a domain exception, so an invalid object cannot exist. Inject
|
|
405
|
+
`ValidatorInterface` only for programmatic checks.
|
|
406
|
+
|
|
407
|
+
### Types are the contract
|
|
408
|
+
|
|
409
|
+
Native types on every property, parameter and return; PHPStan generics and array shapes where PHP has no
|
|
410
|
+
syntax; `never` on always-throwing helpers; `iterable<T>` / `list<T>` in docblocks. A public method that
|
|
411
|
+
accepts or returns `array` without a shape, or `mixed`, is an untyped seam - give it a DTO or a shape.
|
|
412
|
+
|
|
413
|
+
### Enums over constants
|
|
414
|
+
|
|
415
|
+
Backed enums for values that are stored or serialized (`from()` / `tryFrom()` at the boundary), pure enums
|
|
416
|
+
for internal states. Attach behavior with methods and an exhaustive `match`; keep IO out of enums. Class
|
|
417
|
+
constants remain for genuine constants (limits, keys), not for sets.
|
|
418
|
+
|
|
419
|
+
### Immutable by default
|
|
420
|
+
|
|
421
|
+
`readonly class` for anything that is data; `\DateTimeImmutable`, never `\DateTime`; a change returns a
|
|
422
|
+
new object through a wither (`withStatus(): self` returning `new self(...)` - a `readonly` property cannot
|
|
423
|
+
be re-assigned in `__clone` before 8.3). Inject `ClockInterface` (`symfony/clock`, PSR-20) instead of
|
|
424
|
+
calling `new \DateTimeImmutable()` inside a service, so tests control time with `MockClock`.
|
|
425
|
+
|
|
426
|
+
### Exceptions carry meaning
|
|
427
|
+
|
|
428
|
+
One `final` exception class per failure, extending `\DomainException` / `\RuntimeException` and a marker
|
|
429
|
+
interface per module (`CheckoutException`), with a static named constructor (`UnknownExportFormat::for($f)`).
|
|
430
|
+
Map to HTTP once, with `#[WithHttpStatus]` on the exception or a kernel exception listener - never in
|
|
431
|
+
every controller. Never catch `\Throwable` / `\Exception` to continue; never use exceptions for control
|
|
432
|
+
flow.
|
|
433
|
+
|
|
434
|
+
### Transactions at the handler boundary
|
|
435
|
+
|
|
436
|
+
The unit of work wraps the handler: `$em->wrapInTransaction()` / Messenger's `DoctrineTransactionMiddleware`
|
|
437
|
+
on Doctrine, `DB::transaction()` on Eloquent. Not in the controller, not in the entity, not around two
|
|
438
|
+
handlers.
|
|
439
|
+
|
|
440
|
+
### Reads under an ambient scope
|
|
441
|
+
|
|
442
|
+
An ORM that scopes reads to the current tenant or user - a Doctrine filter, a global scope, a
|
|
443
|
+
restriction flag on a legacy base model - can hide an existing row. A fallback can conceal that the
|
|
444
|
+
lookup ran under the wrong scope. Verify the actual query and identity-map behavior.
|
|
445
|
+
|
|
446
|
+
Two obligations, and the second is the one reviews catch:
|
|
447
|
+
|
|
448
|
+
- A read that **must** cross the ambient scope says so at the call site (the ORM's documented opt-out),
|
|
449
|
+
and carries a test built on an entity **outside** the scope. A test whose fixture helper re-stamps the
|
|
450
|
+
current tenant proves nothing - check the row the helper actually wrote.
|
|
451
|
+
- Lifting the scope **widens** a read, so name what still bounds it. If the identifier came from the
|
|
452
|
+
request, authorize the target explicitly. A server-side identifier also needs evidence that its
|
|
453
|
+
relationship to an authorized parent permits this access; provenance alone is not authorization.
|
|
454
|
+
Record that constraint beside the opt-out. Restore temporarily lifted filters in `finally`, including
|
|
455
|
+
on exceptions; test the next scoped read. See [Doctrine's filter lifecycle](https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/filters.html).
|
|
456
|
+
|
|
457
|
+
### Reaching a modern service from legacy code
|
|
458
|
+
|
|
459
|
+
Concrete `final` services are fine when callers need no substitution seam. When a legacy caller
|
|
460
|
+
reaches a service through a locator (`Container::create(X::class)`, a static facade, a framework registry),
|
|
461
|
+
check whether tests can replace that collaborator through the actual lookup path.
|
|
462
|
+
|
|
463
|
+
If substitution is needed and the legacy boundary cannot yet use injection, expose an interface
|
|
464
|
+
**and make the bridge resolve that interface**. In Symfony, bind it with `#[AsAlias]` or service config;
|
|
465
|
+
direct access through the application container needs a public alias. Other legacy containers need
|
|
466
|
+
their own binding mechanism. Keep this bridge narrow, prove replacement at the caller in a test,
|
|
467
|
+
and use injection in owned code. See [Symfony aliases and visibility](https://symfony.com/doc/6.4/service_container/alias_private.html).
|
|
468
|
+
|
|
469
|
+
### Declaration order, because a docblock binds forward
|
|
470
|
+
|
|
471
|
+
A PHP docblock belongs to its following structural element. Inserting a property or a constant between
|
|
472
|
+
a method's docblock and declaration can reattach it to the wrong element. Keep the block with its
|
|
473
|
+
declaration; do not rely on formatter or analyzer configuration to catch the move. See [PHPDoc association](https://docs.phpdoc.org/guide/guides/docblocks.html).
|
|
474
|
+
|
|
475
|
+
Follow the project's member-order convention, and insert new declarations outside existing docblocks
|
|
476
|
+
and their declarations. A field before the complete docblock/method pair is valid.
|
|
477
|
+
|
|
478
|
+
### Layers enforced by tooling, not by convention
|
|
479
|
+
|
|
480
|
+
Domain ← application ← infrastructure / presentation, declared in `deptrac.yaml` and failed in CI;
|
|
481
|
+
PHPStan at the project level with `phpstan-symfony`; php-cs-fixer on `@PER-CS` or `@Symfony`; Rector sets
|
|
482
|
+
for the PHP / Symfony floor. A rule only humans check is a rule that drifts.
|
|
483
|
+
|
|
484
|
+
### Design for worker runtimes
|
|
485
|
+
|
|
486
|
+
Messenger consumers and FrankenPHP / RoadRunner workers reuse the container across requests. Stateless
|
|
487
|
+
services, or `ResetInterface`; no `static` caches; bounded workers (`--limit`, `--memory-limit`,
|
|
488
|
+
`--time-limit`) so a leak is recycled, not accumulated.
|
|
489
|
+
|
|
490
|
+
### Testing
|
|
491
|
+
|
|
492
|
+
Handlers, mappers, value objects and strategies are plain objects: unit-test them with the project's runner
|
|
493
|
+
(PHPUnit, Codeception, atoum) and **mock the interface, never the `final` class** - that is why every
|
|
494
|
+
collaborator has one. `KernelTestCase` + `static::getContainer()->set(Port::class, $fake)` swaps one
|
|
495
|
+
service in a booted container; `WebTestCase` covers the HTTP boundary through `#[MapRequestPayload]` and
|
|
496
|
+
the serializer; `MockClock` fixes time.
|
|
497
|
+
|
|
498
|
+
Use independently specified expected values for derived results; copying the production algorithm into
|
|
499
|
+
the assertion can reproduce the same defect. A direct field-mapping assertion against an input object
|
|
500
|
+
can be valid, but fixed literal fixtures make fallback and tenant distinctions easier to inspect. Test a
|
|
501
|
+
pure function without booting the kernel or opening a transaction; keep integration tests for real
|
|
502
|
+
container and persistence seams. Test strategy and the TDD loop belong to `transpose-testing-patterns`; check that a targeted mutation breaks the assertion.
|
|
503
|
+
|
|
504
|
+
### Beyond PHP 8.2 (when the project floor moves)
|
|
505
|
+
|
|
506
|
+
- **8.3** - typed class constants, `#[\Override]` on overriding methods, `readonly` properties
|
|
507
|
+
re-initialized in `__clone` (withers can use `clone`), `json_validate()`.
|
|
508
|
+
- **8.4** - property hooks and asymmetric visibility (`public private(set)`) replace getter boilerplate
|
|
509
|
+
on DTOs; `new` without parentheses in chains; native lazy objects (Symfony 7.3 lazy services use them).
|
|
510
|
+
- **Symfony 7.1+** - `#[TaggedIterator]` / `#[TaggedLocator]` are deprecated in favor of the
|
|
511
|
+
`#[AutowireIterator]` / `#[AutowireLocator]` used above; nothing else in this guide changes.
|
|
512
|
+
|
|
513
|
+
## PHP Decision Matrix
|
|
514
|
+
|
|
515
|
+
| Problem | Recommended Solution |
|
|
516
|
+
| -------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
517
|
+
| Shared service | stateless `final readonly class`; the container already makes it one instance |
|
|
518
|
+
| State across requests | Cache contracts (`CacheInterface`) / Lock (`LockFactory`) / database - never a property |
|
|
519
|
+
| Per-run memo on a service | `ResetInterface` (autoconfigured on `kernel.reset`) |
|
|
520
|
+
| Memo in a loop over tenants | match the iteration lifetime: caller-owned cache or a tested reset at every boundary |
|
|
521
|
+
| Read crossing the tenant scope | the ORM's documented opt-out + a test on an out-of-scope row |
|
|
522
|
+
| Legacy locator needs substitution | bridge resolves an interface; bind it in the actual container, with required visibility |
|
|
523
|
+
| Depend on an abstraction | constructor injection of the interface; `#[AsAlias]` / `#[Target]` to bind |
|
|
524
|
+
| Interchangeable behaviors | Strategy (`#[AutoconfigureTag]` on the interface + `#[AutowireIterator]` + `supports()`) |
|
|
525
|
+
| Closed, finite variant set | backed enum + exhaustive `match` (no `default`) |
|
|
526
|
+
| Plugin / extension system | Registry (`#[AutowireLocator]` indexed by `defaultIndexMethod` / `#[AsTaggedItem]`) |
|
|
527
|
+
| Object creation with logic | Factory (`factory:` service; static named constructor for value objects) |
|
|
528
|
+
| Implementation per environment | `#[When(env:)]` + `#[AsAlias]` |
|
|
529
|
+
| Reusable action | Command (`readonly` command + invokable handler; `#[AsMessageHandler]` for the bus) |
|
|
530
|
+
| API ↔ domain shape mismatch | `final readonly` DTO + static `from*` outbound / `#[MapRequestPayload]` inbound |
|
|
531
|
+
| Cross-cutting behavior | Composition (`#[AsDecorator]` + `#[AutowireDecorated]`; `#[AsEventListener]`; middleware) |
|
|
532
|
+
| Config value | `#[Autowire(env:)]` / `#[Autowire(param:)]` into a typed `readonly` config class |
|
|
533
|
+
| Secret in a constructor | `#[\SensitiveParameter]` |
|
|
534
|
+
| Runtime validation | `#[Assert\*]` on the DTO + `#[MapRequestPayload]` (422) |
|
|
535
|
+
| Domain invariant | value-object constructor throws a domain exception |
|
|
536
|
+
| Unit of work / atomicity | transaction wrapper around the handler |
|
|
537
|
+
| Time | `ClockInterface` (`MockClock` in tests) |
|
|
538
|
+
| Error → HTTP status | `#[WithHttpStatus]` on the exception, or one kernel exception listener |
|
|
539
|
+
| Types PHP cannot express | PHPStan generics / array shapes (`list<T>`, `array{...}`) |
|
|
540
|
+
| Layer boundaries | Deptrac |
|
|
541
|
+
| Coding style | php-cs-fixer `@PER-CS` (or `@Symfony`) |
|
|
542
|
+
|
|
543
|
+
## Anti-Patterns to Avoid
|
|
544
|
+
|
|
545
|
+
- `switch` / `if` chain on a string type tag to choose behavior - tagged services, or an enum + exhaustive `match`
|
|
546
|
+
- injecting the application container, a static `Container::get()` facade, or `$this->container->get()` in a service - the service-locator anti-pattern (a scoped `#[AutowireLocator]` is not)
|
|
547
|
+
- business logic in controllers, ORM models or Twig templates - delegate to a handler / service
|
|
548
|
+
- serializing an entity or model (`$this->json($entity)`, `$model->toArray()`) - map to a `readonly` DTO at the boundary
|
|
549
|
+
- `static` properties, `$GLOBALS`, or a stateful service without `ResetInterface` - leaks under worker runtimes
|
|
550
|
+
- `new \DateTime()` / `new \DateTimeImmutable()` inside a service - inject `ClockInterface`
|
|
551
|
+
- `abstract` base classes and traits to share behavior - compose with injected interfaces and decorators
|
|
552
|
+
- per-service YAML wiring when `autowire` + attributes suffice; `public: true` services for convenience
|
|
553
|
+
- bare `array` / `mixed` payloads across layers; a file without `declare(strict_types=1)`; a `@var` cast where a type belongs
|
|
554
|
+
- catching `\Throwable` / `\Exception` broadly; exceptions for control flow; mapping exceptions to HTTP in every controller
|
|
555
|
+
- setters and mutable DTOs; `\DateTime`; `clone`-based withers on `readonly` before 8.3
|
|
556
|
+
- a Command merged into a controller action or a model method when it must be reusable - keep it a distinct handler the caller delegates to
|
|
557
|
+
- a factory whose `create()` only calls `new` - delete it, autowiring constructs the class
|
|
558
|
+
- a cached entry that survives its tenant boundary in a command loop without an intentional cross-tenant contract
|
|
559
|
+
- a read that returns `null` under an ambient tenant filter with a fallback that makes it look like it worked
|
|
560
|
+
- a declaration inserted between a docblock and its method - the contract attaches to the wrong element
|
|
561
|
+
- an assertion that copies the production derivation, or a kernel boot for a pure function with no integration seam
|