@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.
Files changed (100) hide show
  1. package/README.md +82 -0
  2. package/agents/design-pattern-reviewer.md +40 -0
  3. package/agents/modern-typescript-reviewer.md +24 -0
  4. package/agents/testing-pattern-reviewer.md +23 -0
  5. package/bin/ai-engineering-gate.mjs +6 -0
  6. package/contracts/axi/axi-standard.md +273 -0
  7. package/contracts/axi/pin.json +36 -0
  8. package/contracts/members.json +58 -0
  9. package/contracts/schemas/arbitration.schema.json +45 -0
  10. package/contracts/schemas/decision-envelope.schema.json +215 -0
  11. package/contracts/schemas/declaration.schema.json +190 -0
  12. package/contracts/schemas/dispute.schema.json +67 -0
  13. package/contracts/schemas/evidence-append.schema.json +72 -0
  14. package/contracts/schemas/gate-output.schema.json +296 -0
  15. package/contracts/schemas/marker.schema.json +75 -0
  16. package/contracts/schemas/review-envelope.schema.json +378 -0
  17. package/contracts/schemas/verdict-record.schema.json +191 -0
  18. package/contracts/suite-contract.md +270 -0
  19. package/dist/ai-engineering-gate.mjs +2762 -0
  20. package/package.json +47 -0
  21. package/skills/engineering/review-design-patterns/LICENSE +6 -0
  22. package/skills/engineering/review-design-patterns/SKILL.md +196 -0
  23. package/skills/engineering/review-design-patterns/agents/openai.yaml +3 -0
  24. package/skills/engineering/review-design-patterns/references/smell-signatures.md +117 -0
  25. package/skills/engineering/review-design-patterns/review-design-patterns.yaml +25 -0
  26. package/skills/engineering/review-modern-typescript/LICENSE +21 -0
  27. package/skills/engineering/review-modern-typescript/SKILL.md +88 -0
  28. package/skills/engineering/review-modern-typescript/agents/openai.yaml +3 -0
  29. package/skills/engineering/review-modern-typescript/references/smell-signatures.md +36 -0
  30. package/skills/engineering/review-modern-typescript/review-modern-typescript.yaml +22 -0
  31. package/skills/engineering/review-testing-patterns/SKILL.md +108 -0
  32. package/skills/engineering/review-testing-patterns/agents/openai.yaml +4 -0
  33. package/skills/engineering/review-testing-patterns/references/compiler-profile.mjs +103 -0
  34. package/skills/engineering/review-testing-patterns/references/smell-signatures.md +30 -0
  35. package/skills/engineering/review-testing-patterns/review-testing-patterns.yaml +23 -0
  36. package/skills/engineering/transpose-design-patterns/LICENSE +6 -0
  37. package/skills/engineering/transpose-design-patterns/SKILL.md +231 -0
  38. package/skills/engineering/transpose-design-patterns/agents/openai.yaml +3 -0
  39. package/skills/engineering/transpose-design-patterns/references/arbitration.schema.json +45 -0
  40. package/skills/engineering/transpose-design-patterns/references/decision-envelope.schema.json +215 -0
  41. package/skills/engineering/transpose-design-patterns/references/declaration.schema.json +190 -0
  42. package/skills/engineering/transpose-design-patterns/references/design-decision-record.schema.json +731 -0
  43. package/skills/engineering/transpose-design-patterns/references/dispute.schema.json +67 -0
  44. package/skills/engineering/transpose-design-patterns/references/evidence-append.schema.json +72 -0
  45. package/skills/engineering/transpose-design-patterns/references/marker.schema.json +75 -0
  46. package/skills/engineering/transpose-design-patterns/references/pattern-catalog.md +305 -0
  47. package/skills/engineering/transpose-design-patterns/references/record.example.json +120 -0
  48. package/skills/engineering/transpose-design-patterns/references/review-envelope.schema.json +378 -0
  49. package/skills/engineering/transpose-design-patterns/references/suite-contract.md +270 -0
  50. package/skills/engineering/transpose-design-patterns/references/transpose-angular.md +224 -0
  51. package/skills/engineering/transpose-design-patterns/references/transpose-php.md +561 -0
  52. package/skills/engineering/transpose-design-patterns/references/transpose-quarkus.md +283 -0
  53. package/skills/engineering/transpose-design-patterns/references/transpose-react.md +256 -0
  54. package/skills/engineering/transpose-design-patterns/references/transpose-vanilla.md +204 -0
  55. package/skills/engineering/transpose-design-patterns/references/transpose-vue.md +234 -0
  56. package/skills/engineering/transpose-design-patterns/transpose-design-patterns.yaml +24 -0
  57. package/skills/engineering/transpose-modern-typescript/LICENSE +21 -0
  58. package/skills/engineering/transpose-modern-typescript/SKILL.md +90 -0
  59. package/skills/engineering/transpose-modern-typescript/agents/openai.yaml +3 -0
  60. package/skills/engineering/transpose-modern-typescript/references/arbitration.schema.json +45 -0
  61. package/skills/engineering/transpose-modern-typescript/references/catalog.md +38 -0
  62. package/skills/engineering/transpose-modern-typescript/references/collections.md +50 -0
  63. package/skills/engineering/transpose-modern-typescript/references/compatibility.md +61 -0
  64. package/skills/engineering/transpose-modern-typescript/references/decision-envelope.schema.json +215 -0
  65. package/skills/engineering/transpose-modern-typescript/references/decision-record.schema.json +830 -0
  66. package/skills/engineering/transpose-modern-typescript/references/declaration.schema.json +190 -0
  67. package/skills/engineering/transpose-modern-typescript/references/dispute.schema.json +67 -0
  68. package/skills/engineering/transpose-modern-typescript/references/evidence-append.schema.json +72 -0
  69. package/skills/engineering/transpose-modern-typescript/references/idioms.md +74 -0
  70. package/skills/engineering/transpose-modern-typescript/references/marker.schema.json +75 -0
  71. package/skills/engineering/transpose-modern-typescript/references/platform.md +147 -0
  72. package/skills/engineering/transpose-modern-typescript/references/record.example.json +172 -0
  73. package/skills/engineering/transpose-modern-typescript/references/record.md +37 -0
  74. package/skills/engineering/transpose-modern-typescript/references/review-envelope.schema.json +378 -0
  75. package/skills/engineering/transpose-modern-typescript/references/suite-contract.md +270 -0
  76. package/skills/engineering/transpose-modern-typescript/references/types.md +97 -0
  77. package/skills/engineering/transpose-modern-typescript/transpose-modern-typescript.yaml +22 -0
  78. package/skills/engineering/transpose-testing-patterns/SKILL.md +96 -0
  79. package/skills/engineering/transpose-testing-patterns/agents/openai.yaml +4 -0
  80. package/skills/engineering/transpose-testing-patterns/references/arbitration.schema.json +45 -0
  81. package/skills/engineering/transpose-testing-patterns/references/catalog.md +218 -0
  82. package/skills/engineering/transpose-testing-patterns/references/decision-envelope.schema.json +215 -0
  83. package/skills/engineering/transpose-testing-patterns/references/decision-record.schema.json +857 -0
  84. package/skills/engineering/transpose-testing-patterns/references/declaration.schema.json +190 -0
  85. package/skills/engineering/transpose-testing-patterns/references/detect-adapter.mjs +98 -0
  86. package/skills/engineering/transpose-testing-patterns/references/dispute.schema.json +67 -0
  87. package/skills/engineering/transpose-testing-patterns/references/doubles-data.md +45 -0
  88. package/skills/engineering/transpose-testing-patterns/references/evidence-append.schema.json +72 -0
  89. package/skills/engineering/transpose-testing-patterns/references/journal-event.schema.json +115 -0
  90. package/skills/engineering/transpose-testing-patterns/references/marker.schema.json +75 -0
  91. package/skills/engineering/transpose-testing-patterns/references/record.example.json +230 -0
  92. package/skills/engineering/transpose-testing-patterns/references/record.md +25 -0
  93. package/skills/engineering/transpose-testing-patterns/references/review-envelope.schema.json +378 -0
  94. package/skills/engineering/transpose-testing-patterns/references/suite-contract.md +270 -0
  95. package/skills/engineering/transpose-testing-patterns/references/tdd.md +62 -0
  96. package/skills/engineering/transpose-testing-patterns/references/transpose-codeception.md +84 -0
  97. package/skills/engineering/transpose-testing-patterns/references/transpose-karma-jasmine-angular.md +73 -0
  98. package/skills/engineering/transpose-testing-patterns/references/transpose-vitest.md +111 -0
  99. package/skills/engineering/transpose-testing-patterns/references/typescript.md +42 -0
  100. 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