From ffafb043ff0e7e7aeb867bf1066d37402e7c5169 Mon Sep 17 00:00:00 2001 From: Sebastian Fix Date: Tue, 3 Mar 2026 14:38:36 +0100 Subject: [PATCH 1/6] Updated DTO Skill --- resources/boost/skills/dto/SKILL.md | 431 ++++++++++++++++++++++++++-- 1 file changed, 401 insertions(+), 30 deletions(-) diff --git a/resources/boost/skills/dto/SKILL.md b/resources/boost/skills/dto/SKILL.md index 2b0af4f..fac8464 100644 --- a/resources/boost/skills/dto/SKILL.md +++ b/resources/boost/skills/dto/SKILL.md @@ -1,6 +1,6 @@ --- name: dto -description: Readonly data containers with a `fromArray` factory method used to pass structured data between application layers - especially for external API responses and service boundaries. +description: Readonly data containers with typed factory methods (`fromArray`, `fromModel`, `fromCollection`, `fromRequest`) used to pass structured data between application layers — especially for external API responses, Eloquent models, and service boundaries. Use this skill whenever creating, reviewing, or refactoring DTOs, Data Transfer Objects, value objects for inter-layer communication, or mapping payloads from APIs, models, or collections into typed PHP objects. Also trigger when the user mentions spatie/laravel-data alternatives, data mapping, or payload normalization in a Laravel context. compatible_agents: - architect - implement @@ -14,85 +14,456 @@ compatible_agents: - Use when mapping external API payloads into typed objects before service logic runs. - Use when passing structured data between Actions, Services, Jobs, and Resources. -- Use when providers return multiple key formats for the same field (`ID`, `id`, `customer_id`). -- Do not use for single scalar wrappers or data that is used in only one private method. +- Use when hydrating typed objects from Eloquent models, collections, or paginators. +- Use when providers return multiple key formats for the same field (`ID`, `id`, `customer_id`, `customerId`). +- Use when a Form Request, Job, or Event needs a well-defined payload contract. + +Do **not** use for single scalar wrappers, data used in only one private method, or when `spatie/laravel-data` is already installed and covers the use case (see *Alternatives* at the end). ## Preconditions -- The payload shape is known from API docs, fixtures, or tests. +- The payload shape is known from API docs, Eloquent model attributes, fixtures, or tests. - The owning module exists (example: `app/Services/Billing/DataObjects/`). -- Validation rules are defined in the caller (Form Request, Action, or Service). +- Validation rules are defined in the caller (Form Request, Action, or Service), not the DTO. ## Process ### 1. Create the DTO Class -- Add a `readonly class` with promoted, explicitly typed properties. +- Declare a `readonly class` with promoted, explicitly typed constructor properties. - Place it in the owning feature/module `DataObjects/` directory. +- Import `Illuminate\Support\Arr` at the top of the file. + +### 2. Implement Factory Methods + +Add one or more `public static` factory methods depending on the data sources the DTO serves. Every factory method that reads from an associative array **must** use Laravel's `Arr::get()` helper (or the `data_get()` global) instead of raw `$data['key']` bracket access. This provides safe default handling and dot-notation support for nested payloads. + +#### `fromArray(array $data): static` +The primary factory for raw associative arrays (API responses, decoded JSON, validated request data). + +#### `fromModel(Model $model): static` +Accepts an Eloquent model and reads attributes via `$model->getAttribute()` or `$model->{property}`. Use when the DTO is frequently hydrated from a database record. + +#### `fromRequest(FormRequest $request): static` +Reads from a validated Form Request. Prefer `$request->validated()` to ensure only validated fields are passed, then delegate to `fromArray()`. + +#### `fromCollection(Collection $collection): static` *(collection of DTOs)* +Returns a `Collection` (or typed array) of DTO instances. Use `$collection->map(...)` internally. + +Not every DTO needs all four factories — add only those that match real call-sites. At minimum, provide `fromArray()`. + +### 3. Use Laravel Array Helpers Everywhere + +Inside factory methods, **never** access array values with raw bracket syntax (`$data['key'] ?? null`). Instead: + +```php +use Illuminate\Support\Arr; + +// Good — safe access with default +Arr::get($data, 'customer.name', 'Unknown'); + +// Good — global helper, supports dot-notation and wildcards +data_get($data, 'customer.name', 'Unknown'); + +// Bad — raw bracket access, no dot-notation, verbose fallback chains +$data['customer']['name'] ?? $data['Customer']['Name'] ?? 'Unknown'; +``` + +When normalizing multiple key variants for the same field, combine `Arr::get()` calls with a `??` chain on the results — not on raw brackets: + +```php +Arr::get($data, 'ID') ?? Arr::get($data, 'id') ?? Arr::get($data, 'customer_id', ''); +``` + +### 4. Keep Validation Outside the DTO + +- Validate required/format rules **before** calling any factory method. +- The DTO's job is mapping and type-safe access — not enforcing business rules. + +### 5. Write a Dedicated DTO Test -### 2. Implement `fromArray()` Mapping +Every DTO **must** have its own test class. DTO tests are pure unit tests — no HTTP calls, no database, no framework boot required. They verify that every factory method correctly maps, normalizes, and type-casts input data. -- Add `public static function fromArray(array $data): static`. -- Normalize known field variants in one place. -- Keep required fields non-nullable and optional fields nullable. +Place test files alongside the DTO's module path: `tests/Unit/Services/Billing/DataObjects/CustomerDataTest.php`. -### 3. Keep Validation Outside the DTO +A DTO test class should cover: -- Validate required/format rules before calling `fromArray()`. -- Keep DTO code limited to mapping and data access. +- **Happy path** — standard payload maps to correct property values. +- **Key variant normalization** — each known alternate key (`ID` vs `id` vs `customer_id`) resolves correctly. +- **Nullable / optional fields** — missing keys result in `null` (not exceptions). +- **Type casting** — string IDs, float totals, bool flags are cast correctly. +- **Nested data** — dot-notation fields and nested DTOs hydrate properly. +- **fromModel** — model attributes (including casts and accessors) map correctly. +- **fromCollection** — returns a collection of the correct DTO type and count. +- **Edge cases** — empty arrays, missing keys, extra keys are handled gracefully. -### 4. Verify Call Sites +### 6. Verify Call Sites -- Replace ad-hoc array indexing with DTO usage in services/jobs. -- Add a fixture case with at least one alternate key style. +- Replace ad-hoc array indexing in services/jobs with DTO property access. +- Where a DTO is created from a model, ensure eager-loaded relationships are available before mapping. ## Examples +### Basic DTO with `fromArray` + ```php + ['required']])->validate(); +getKey(), + status: $order->getAttribute('status'), + total: (float) $order->getAttribute('total'), + trackingNumber: $order->getAttribute('tracking_number'), + ); + } + + /** + * @param Collection $orders + * @return Collection + */ + public static function fromCollection(Collection $orders): Collection + { + return $orders->map(fn (Order $order): static => static::fromModel($order)); + } +} +``` + +### DTO with `fromRequest` + +```php +public static function fromRequest(StoreOrderRequest $request): static +{ + return static::fromArray($request->validated()); +} +``` + +### Caller-side usage + +```php +// Validation belongs to the caller; the DTO maps only. +$validated = $request->validated(); +$customer = CustomerData::fromArray($apiResponse); echo $customer->name; + +// From an Eloquent model +$orderData = OrderData::fromModel(Order::findOrFail($id)); + +// From a collection / paginator +$orders = OrderData::fromCollection(Order::where('status', 'shipped')->get()); +``` + +### Nested DTO access with `data_get` + +When the source payload has deeply nested data, prefer `data_get()` for dot-notation traversal: + +```php +public static function fromArray(array $data): static +{ + return new static( + city: data_get($data, 'address.city', 'Unknown'), + zipCode: data_get($data, 'address.zip_code') ?? data_get($data, 'address.postalCode'), + ); +} +``` + +### Dedicated DTO Test + +```php + '123', + 'name' => 'Acme Corp', + 'email' => 'billing@acme.test', + ]); + + $this->assertSame('123', $dto->id); + $this->assertSame('Acme Corp', $dto->name); + $this->assertSame('billing@acme.test', $dto->email); + } + + #[Test] + #[DataProvider('alternateKeyProvider')] + public function it_normalizes_alternate_key_formats(array $payload, string $expectedId, ?string $expectedName): void + { + $dto = CustomerData::fromArray($payload); + + $this->assertSame($expectedId, $dto->id); + $this->assertSame($expectedName, $dto->name); + } + + public static function alternateKeyProvider(): array + { + return [ + 'uppercase keys' => [ + ['ID' => '456', 'Name' => 'Globex'], + '456', + 'Globex', + ], + 'customer_name variant' => [ + ['id' => '789', 'customer_name' => 'Initech'], + '789', + 'Initech', + ], + 'ID takes precedence over id' => [ + ['ID' => '100', 'id' => '200', 'name' => 'Test'], + '100', + 'Test', + ], + ]; + } + + #[Test] + public function it_handles_missing_optional_fields(): void + { + $dto = CustomerData::fromArray(['id' => '1']); + + $this->assertSame('1', $dto->id); + $this->assertNull($dto->name); + $this->assertNull($dto->email); + } + + #[Test] + public function it_casts_numeric_id_to_string(): void + { + $dto = CustomerData::fromArray(['id' => 42]); + + $this->assertSame('42', $dto->id); + } + + #[Test] + public function it_defaults_id_to_empty_string_when_missing(): void + { + $dto = CustomerData::fromArray([]); + + $this->assertSame('', $dto->id); + } + + #[Test] + public function it_ignores_extra_keys(): void + { + $dto = CustomerData::fromArray([ + 'id' => '1', + 'name' => 'Test', + 'email' => 'test@example.test', + 'phone' => '555-0100', + 'created_at' => '2025-01-01', + ]); + + $this->assertSame('1', $dto->id); + $this->assertSame('Test', $dto->name); + $this->assertSame('test@example.test', $dto->email); + } +} +``` + +### DTO Test for `fromModel` and `fromCollection` + +```php +forceFill([ + 'id' => 7, + 'status' => 'shipped', + 'total' => 99.95, + 'tracking_number' => 'TRK-001', + ]); + + $dto = OrderData::fromModel($order); + + $this->assertSame(7, $dto->id); + $this->assertSame('shipped', $dto->status); + $this->assertSame(99.95, $dto->total); + $this->assertSame('TRK-001', $dto->trackingNumber); + } + + #[Test] + public function it_handles_null_tracking_number_from_model(): void + { + $order = new Order(); + $order->forceFill([ + 'id' => 8, + 'status' => 'pending', + 'total' => 0, + ]); + + $dto = OrderData::fromModel($order); + + $this->assertNull($dto->trackingNumber); + } + + #[Test] + public function it_creates_collection_from_models(): void + { + $orders = new Collection([ + tap(new Order(), fn ($o) => $o->forceFill(['id' => 1, 'status' => 'pending', 'total' => 10])), + tap(new Order(), fn ($o) => $o->forceFill(['id' => 2, 'status' => 'shipped', 'total' => 20])), + tap(new Order(), fn ($o) => $o->forceFill(['id' => 3, 'status' => 'delivered', 'total' => 30])), + ]); + + $dtos = OrderData::fromCollection($orders); + + $this->assertCount(3, $dtos); + $this->assertContainsOnlyInstancesOf(OrderData::class, $dtos); + $this->assertSame('shipped', $dtos->get(1)->status); + } + + #[Test] + public function it_returns_empty_collection_from_empty_input(): void + { + $dtos = OrderData::fromCollection(new Collection()); + + $this->assertCount(0, $dtos); + } + + #[Test] + public function from_array_normalizes_tracking_number_variants(): void + { + $snakeCase = OrderData::fromArray(['id' => 1, 'status' => 'new', 'total' => 5, 'tracking_number' => 'A']); + $camelCase = OrderData::fromArray(['id' => 2, 'status' => 'new', 'total' => 5, 'trackingNumber' => 'B']); + + $this->assertSame('A', $snakeCase->trackingNumber); + $this->assertSame('B', $camelCase->trackingNumber); + } +} ``` ## Checklists -- [ ] DTO is `readonly` and all properties are typed. -- [ ] `fromArray()` handles all known key variants. -- [ ] Validation/business rules are outside the DTO. -- [ ] File is stored in the owning module `DataObjects/` path. +- [ ] DTO is `readonly` and all properties are explicitly typed. +- [ ] Array access inside factory methods uses `Arr::get()` or `data_get()` — no raw bracket access. +- [ ] `fromArray()` handles all known key variants for the source payload. +- [ ] Additional factory methods (`fromModel`, `fromCollection`, `fromRequest`) exist where needed. +- [ ] Validation and business rules live outside the DTO (Form Request, Action, or Service). +- [ ] File is stored in the owning module's `DataObjects/` directory. +- [ ] Nested DTOs are hydrated via their own factory methods, not inline array parsing. +- [ ] A dedicated test class exists covering happy path, key variants, nullables, type casting, and edge cases. +- [ ] `fromModel` tests use `forceFill()` on unsaved model instances — no database required. +- [ ] `fromCollection` tests assert correct count, instance type, and empty-collection handling. ## Anti-Patterns - Adding setters or mutable state to a DTO. -- Putting business logic, DB calls, or HTTP calls in a DTO. -- Using DTOs as Eloquent model replacements. -- Assuming one naming convention (for example only `customer_id` and ignoring `customerId`). +- Putting business logic, DB queries, or HTTP calls inside a DTO. +- Using DTOs as Eloquent model replacements (use models for persistence, DTOs for transport). +- Accessing arrays with raw bracket syntax (`$data['key']`) instead of `Arr::get()` / `data_get()`. +- Assuming a single naming convention (e.g. only `customer_id`, ignoring `customerId` or `CustomerID`). - Leaving properties untyped or using `mixed` without narrowing. +- Creating a single "god DTO" for create, update, and read — split into separate DTOs per context when validation rules diverge significantly. +- Duplicating model attribute logic inside the DTO — call `$model->getAttribute()` and let Eloquent handle casts and accessors. +- Skipping DTO tests because "it's just a data class" — factory methods contain mapping logic that breaks silently when API payloads change. + +## Alternatives + +### spatie/laravel-data + +If the project already uses (or is open to) `spatie/laravel-data`, consider extending `Spatie\LaravelData\Data` or `Spatie\LaravelData\Dto` instead of writing a plain readonly class. The package provides: + +- Automatic creation from arrays, models, requests, and collections via `::from()` and `::collect()`. +- Built-in validation, casts, transformers, and TypeScript generation. +- Property name mapping with `#[MapInputName]` for snake_case ↔ camelCase. +- Eloquent casting support for storing data objects in JSON columns. + +Use a manual readonly DTO when you need zero dependencies, full control over mapping logic, or when working with external API payloads that require heavy key normalization. ## References +- [Laravel Arr & data_get Helpers](https://laravel.com/docs/12.x/helpers) — `Arr::get()`, `data_get()`, `data_fill()`, `data_set()` - [PHP Readonly Classes](https://www.php.net/manual/en/language.oop5.readonly-properties.php) +- [spatie/laravel-data — Creating a Data Object](https://spatie.be/docs/laravel-data/v4/as-a-data-transfer-object/creating-a-data-object) +- [spatie/laravel-data — From a Model](https://spatie.be/docs/laravel-data/v4/as-a-data-transfer-object/model-to-data-object) +- [spatie/laravel-data — Collections](https://spatie.be/docs/laravel-data/v4/as-a-data-transfer-object/collections) - `resources/boost/skills/services/SKILL.md` (DTO consumers) - `resources/boost/skills/saloon/SKILL.md` (external API mapping) From 93ff6cde6fa6f2d2a9ef52a376f9be0d1b2c79d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20B=C3=BCrgin-Fix?= Date: Wed, 4 Mar 2026 09:19:45 +0100 Subject: [PATCH 2/6] Update resources/boost/skills/dto/SKILL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/boost/skills/dto/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/boost/skills/dto/SKILL.md b/resources/boost/skills/dto/SKILL.md index fac8464..d34d78d 100644 --- a/resources/boost/skills/dto/SKILL.md +++ b/resources/boost/skills/dto/SKILL.md @@ -47,7 +47,7 @@ Accepts an Eloquent model and reads attributes via `$model->getAttribute()` or ` #### `fromRequest(FormRequest $request): static` Reads from a validated Form Request. Prefer `$request->validated()` to ensure only validated fields are passed, then delegate to `fromArray()`. -#### `fromCollection(Collection $collection): static` *(collection of DTOs)* +#### `fromCollection(Collection $collection): Collection` *(collection of DTOs)* Returns a `Collection` (or typed array) of DTO instances. Use `$collection->map(...)` internally. Not every DTO needs all four factories — add only those that match real call-sites. At minimum, provide `fromArray()`. From 5af0880594458bb0d261fe0e33c66bbd407aebb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20B=C3=BCrgin-Fix?= Date: Wed, 4 Mar 2026 09:19:57 +0100 Subject: [PATCH 3/6] Update resources/boost/skills/dto/SKILL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/boost/skills/dto/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/boost/skills/dto/SKILL.md b/resources/boost/skills/dto/SKILL.md index d34d78d..6c3a4d3 100644 --- a/resources/boost/skills/dto/SKILL.md +++ b/resources/boost/skills/dto/SKILL.md @@ -82,7 +82,7 @@ Arr::get($data, 'ID') ?? Arr::get($data, 'id') ?? Arr::get($data, 'customer_id', ### 5. Write a Dedicated DTO Test -Every DTO **must** have its own test class. DTO tests are pure unit tests — no HTTP calls, no database, no framework boot required. They verify that every factory method correctly maps, normalizes, and type-casts input data. +Every DTO **must** have its own test class. DTO tests are pure unit tests — no HTTP calls and no database. They can run without booting the framework, but may extend a lightweight bootstrapped base test case (e.g. `Tests\\TestCase` via Testbench) when DTO factories depend on Eloquent models. They verify that every factory method correctly maps, normalizes, and type-casts input data. Place test files alongside the DTO's module path: `tests/Unit/Services/Billing/DataObjects/CustomerDataTest.php`. From f7922686ee1c090404ba8f0df7b1234370ae30f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20B=C3=BCrgin-Fix?= Date: Wed, 4 Mar 2026 09:20:06 +0100 Subject: [PATCH 4/6] Update resources/boost/skills/dto/SKILL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/boost/skills/dto/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/boost/skills/dto/SKILL.md b/resources/boost/skills/dto/SKILL.md index 6c3a4d3..dd43c7e 100644 --- a/resources/boost/skills/dto/SKILL.md +++ b/resources/boost/skills/dto/SKILL.md @@ -346,7 +346,7 @@ use App\Models\Order; use App\Services\Shipping\DataObjects\OrderData; use Illuminate\Support\Collection; use PHPUnit\Framework\Attributes\Test; -use Tests\TestCase; +use PHPUnit\Framework\TestCase; class OrderDataTest extends TestCase { From 3174125638aeee499a3a17d7f3c03cfa080dbb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20B=C3=BCrgin-Fix?= Date: Wed, 4 Mar 2026 09:20:12 +0100 Subject: [PATCH 5/6] Update resources/boost/skills/dto/SKILL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/boost/skills/dto/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/boost/skills/dto/SKILL.md b/resources/boost/skills/dto/SKILL.md index dd43c7e..31aea18 100644 --- a/resources/boost/skills/dto/SKILL.md +++ b/resources/boost/skills/dto/SKILL.md @@ -460,7 +460,7 @@ Use a manual readonly DTO when you need zero dependencies, full control over map ## References -- [Laravel Arr & data_get Helpers](https://laravel.com/docs/12.x/helpers) — `Arr::get()`, `data_get()`, `data_fill()`, `data_set()` +- [Laravel Arr & data_get Helpers](https://laravel.com/docs/helpers) — `Arr::get()`, `data_get()`, `data_fill()`, `data_set()` - [PHP Readonly Classes](https://www.php.net/manual/en/language.oop5.readonly-properties.php) - [spatie/laravel-data — Creating a Data Object](https://spatie.be/docs/laravel-data/v4/as-a-data-transfer-object/creating-a-data-object) - [spatie/laravel-data — From a Model](https://spatie.be/docs/laravel-data/v4/as-a-data-transfer-object/model-to-data-object) From 51db4247304e11db76ada0ce8e26c129344b0d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20B=C3=BCrgin-Fix?= Date: Wed, 4 Mar 2026 09:20:21 +0100 Subject: [PATCH 6/6] Update resources/boost/skills/dto/SKILL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/boost/skills/dto/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/boost/skills/dto/SKILL.md b/resources/boost/skills/dto/SKILL.md index 31aea18..7f3d3d8 100644 --- a/resources/boost/skills/dto/SKILL.md +++ b/resources/boost/skills/dto/SKILL.md @@ -204,7 +204,7 @@ public static function fromRequest(StoreOrderRequest $request): static ```php // Validation belongs to the caller; the DTO maps only. $validated = $request->validated(); -$customer = CustomerData::fromArray($apiResponse); +$customer = CustomerData::fromArray($validated); echo $customer->name; // From an Eloquent model