# Data Transfer Objects (DTOs) in SuiteX

## Table of Contents

1. [Introduction](#introduction)
2. [Architecture Decisions](#architecture-decisions)
3. [Handling Tenant-Specific Flexible Columns](#handling-tenant-specific-flexible-columns)
4. [Concrete Examples](#concrete-examples)
5. [Integration Patterns](#integration-patterns)
6. [Decision Tree: When to Create DTOs](#decision-tree-when-to-create-dtos)
7. [Creating New DTOs: Step-by-Step](#creating-new-dtos-step-by-step)
8. [File Structure](#file-structure)
9. [Validation Strategy](#validation-strategy)
10. [Migration Strategy](#migration-strategy)
11. [Testing DTOs](#testing-dtos)
12. [Best Practices & Patterns](#best-practices--patterns)
13. [Common Patterns Reference](#common-patterns-reference)
14. [Troubleshooting Guide](#troubleshooting-guide)

---

## Introduction

### Why DTOs in SuiteX?

Data Transfer Objects provide a structured, type-safe approach to handling data throughout the SuiteX application. PHP's dynamic typing can make it difficult to catch errors at development time, leading to runtime bugs that are harder to diagnose and fix.

**Key Benefits:**

- **Type Safety**: Leverage PHP 8.2's type system to catch errors at development time rather than in production
- **Improved Testing**: Mock and test data structures with confidence
- **Better IDE Support**: Enhanced autocomplete and refactoring capabilities
- **Clear Contracts**: Explicit data structures make service boundaries clear
- **Reduced Bugs**: Type hints prevent many common data-related errors
- **Documentation**: Self-documenting code through typed properties

### Package Selection

We use **[spatie/laravel-data](https://spatie.be/docs/laravel-data/v4/introduction)** (version 4.x) for our DTO implementation.

**Why spatie/laravel-data?**

- ✅ Actively maintained by Spatie
- ✅ Native Laravel integration
- ✅ Built-in validation support
- ✅ Automatic casting and transformation
- ✅ Collection support
- ✅ Excellent documentation

**Not using:** `spatie/data-transfer-object` (archived November 2022, repository recommends migrating to `laravel-data`)

### Primary Use Cases

DTOs in SuiteX serve multiple purposes, ordered by priority:

1. **Service Layer Contracts** (Highest Priority)
   - Define clear interfaces between services
   - Replace loosely-typed arrays with typed objects
   - Example: Batch upsert services, data transformation pipelines

2. **API Responses**
   - Standardize API response structures
   - Ensure consistent output formatting
   - Type-safe JSON serialization

3. **Domain Action Parameters**
   - Actions with >3 parameters should use DTOs
   - Improves method signatures
   - Makes parameter passing explicit

4. **Internal Component Communication**
   - Pass data between Livewire components
   - Share data between controllers and services
   - Type-safe event payloads

### Relationship with Form Requests

**DTOs complement (not replace) Laravel Form Requests:**

- **Form Requests**: HTTP-layer validation, authorization, input sanitization
- **DTOs**: Internal type safety, service layer contracts, data transformation

**Workflow:**
```
HTTP Request → Form Request (validate) → DTO (type-safe) → Service Layer → DTO → Response
```

DTOs provide type safety beyond HTTP boundaries, ensuring data integrity throughout the application lifecycle.

---

## Architecture Decisions

### Directory Organization

Based on SuiteX's domain-driven design structure, DTOs are organized alongside their related domain components.

#### Domain-Specific DTOs

**Location:** `src/Domain/{DomainName}/DataTransferObjects/`

Place DTOs here when they are specific to a single domain and only used within that domain's context.

**Examples:**
```
src/Domain/Projects/DataTransferObjects/
├── ProjectData.php
├── CreateProjectData.php
├── UpdateProjectData.php
└── ProjectResponseData.php

src/Domain/Customers/DataTransferObjects/
├── CustomerData.php
├── CreateCustomerData.php
└── CustomerResponseData.php

src/Domain/Items/DataTransferObjects/
└── ItemData.php
```

#### Cross-Domain DTOs

**Location:** `src/Domain/Shared/DataTransferObjects/`

Place DTOs here when they are used across multiple domains or represent shared concepts.

**Examples:**
```
src/Domain/Shared/DataTransferObjects/
├── AddressData.php
├── EntityData.php
├── BatchUpsertResultData.php
└── RelationshipData.php
```

#### API-Specific DTOs

**Location:** `src/App/Http/DataTransferObjects/`

Place DTOs here for API request/response structures that don't map 1:1 to domain models.

**Examples:**
```
src/App/Http/DataTransferObjects/
├── PaginatedResponseData.php
├── ErrorResponseData.php
├── ApiResourceData.php
└── SearchRequestData.php
```

### Naming Conventions

Consistent naming makes DTOs discoverable and their purpose clear:

| Pattern | Purpose | Example |
|---------|---------|---------|
| `{Model}Data` | General purpose DTO for a model | `ProjectData`, `CustomerData` |
| `Create{Model}Data` | DTO for creating new records | `CreateProjectData` |
| `Update{Model}Data` | DTO for updating existing records | `UpdateProjectData` |
| `{Model}ResponseData` | DTO for API responses | `ProjectResponseData` |
| `{Context}{Model}Data` | Context-specific DTOs | `NetSuiteProjectData` |
| `{Parent}{Child}Data` | Nested/related DTOs | `ProjectTaskData` |

**Rules:**
- Always suffix with `Data`
- Use singular form (not plural)
- Be explicit about purpose when needed
- Match the model name when representing a model

---

## Handling Tenant-Specific Flexible Columns

### The Challenge

SuiteX is a multi-tenant application where each tenant's database shares the same initial structure, but can be extended with tenant-specific custom columns. This presents a unique challenge for DTOs:

- **Base Structure**: All tenants have the same core columns (e.g., `id`, `title`, `customer`, `projectmanager`)
- **Custom Fields**: Tenants can add unique columns at any time (e.g., `custfield_priority`, `custfield_department`)
- **NetSuite Integration**: Custom fields from NetSuite must be preserved during imports
- **Type Safety**: We want strict typing for base fields while remaining flexible for custom fields

### Strategy: Strict by Default with Explicit Flexibility

**Approach:**
1. Define all known base columns with strict types
2. Use a dedicated property for tenant-specific custom fields
3. Validate base structure rigorously
4. Pass through custom fields without validation
5. Leverage existing `fields` JSON column in models

### Implementation Pattern

```php
<?php

namespace Domain\Projects\DataTransferObjects;

use Domain\Projects\Models\Project;
use Spatie\LaravelData\Data;

class ProjectData extends Data
{
    public function __construct(
        // Base structure - strictly typed, type-safe
        public ?int $id,
        public string $title,
        public ?int $customer,
        public ?int $projectmanager,
        public ?int $subsidiary,
        public ?string $status,
        public ?string $refid,
        public ?string $external_id,
        public ?bool $synced,
        public ?string $created_at,
        public ?string $updated_at,

        // Flexible tenant-specific fields
        // Stored in the 'fields' JSON column
        public array $customFields = [],
    ) {}

    /**
     * Create DTO from Eloquent model
     */
    public static function fromModel(Project $project): self
    {
        return new self(
            id: $project->id,
            title: $project->title,
            customer: $project->customer,
            projectmanager: $project->projectmanager,
            subsidiary: $project->subsidiary,
            status: $project->status,
            refid: $project->refid,
            external_id: $project->external_id,
            synced: $project->synced,
            created_at: $project->created_at?->toISOString(),
            updated_at: $project->updated_at?->toISOString(),
            customFields: $project->fields ?? [],
        );
    }

    /**
     * Create DTO from NetSuite record array
     */
    public static function fromNetSuiteRecord(array $record): self
    {
        // Separate base fields from custom fields
        $baseFields = [
            'id', 'title', 'customer', 'projectmanager',
            'subsidiary', 'status', 'refid', 'external_id'
        ];

        $customFields = [];
        foreach ($record as $key => $value) {
            if (str_starts_with($key, 'custfield_') ||
                str_starts_with($key, 'custbody_')) {
                $customFields[$key] = $value;
            }
        }

        return new self(
            id: $record['id'] ?? null,
            title: $record['title'] ?? $record['companyname'] ?? '',
            customer: $record['customer'] ?? null,
            projectmanager: $record['projectmanager'] ?? null,
            subsidiary: $record['subsidiary'] ?? null,
            status: $record['status'] ?? null,
            refid: $record['refid'] ?? $record['id'] ?? null,
            external_id: $record['external_id'] ?? null,
            synced: true, // Always true for NetSuite imports
            created_at: null,
            updated_at: null,
            customFields: $customFields,
        );
    }

    /**
     * Convert to array for database insertion
     */
    public function toModelAttributes(): array
    {
        $attributes = [
            'title' => $this->title,
            'customer' => $this->customer,
            'projectmanager' => $this->projectmanager,
            'subsidiary' => $this->subsidiary,
            'status' => $this->status,
            'refid' => $this->refid,
            'external_id' => $this->external_id,
            'synced' => $this->synced,
            'fields' => $this->customFields,
        ];

        if ($this->id) {
            $attributes['id'] = $this->id;
        }

        return array_filter($attributes, fn($value) => $value !== null);
    }
}
```

### Key Design Principles

#### 1. Separate Known from Unknown

Always distinguish between:
- **Base fields**: Type-safe, validated, required for business logic
- **Custom fields**: Flexible, passed through, stored in JSON column

#### 2. Custom Field Storage

Use the existing `fields` JSON column that most SuiteX models already have:

```php
// In model migration
$table->json('fields')->nullable();

// In model
protected $casts = [
    'fields' => 'array',
];
```

#### 3. Custom Field Naming Convention

Custom fields from NetSuite follow predictable patterns:
- `custfield_*` - Custom record fields
- `custbody_*` - Custom transaction body fields
- `custcol_*` - Custom transaction column fields

Filter these into `customFields` array during DTO creation.

#### 4. Validation Strategy

```php
// Validate base structure only
public static function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'customer' => ['nullable', 'integer'],
        'projectmanager' => ['nullable', 'integer'],
        // NO validation for customFields.*
        // Custom fields are tenant-specific and can't be pre-validated
    ];
}
```

### Handling Custom Fields in Batch Operations

When processing batch operations (like NetSuite imports), preserve all custom fields:

```php
// In batch upsert service
public function handle(array $netsuiteRecords, string $jobId): BatchUpsertResultData
{
    $dtos = collect($netsuiteRecords)
        ->map(fn($record) => ProjectData::fromNetSuiteRecord($record))
        ->all();

    // All custom fields are preserved in $dto->customFields
    foreach ($dtos as $dto) {
        $attributes = $dto->toModelAttributes();
        // $attributes['fields'] contains all custom fields
        Project::updateOrCreate(
            ['refid' => $dto->refid],
            $attributes
        );
    }
}
```

### Retrieving Custom Fields

Custom fields are accessible through the DTO:

```php
$project = Project::find(1);
$dto = ProjectData::fromModel($project);

// Access base fields (type-safe)
echo $dto->title; // string
echo $dto->customer; // ?int

// Access custom fields (dynamic)
$priority = $dto->customFields['custfield_priority'] ?? null;
$department = $dto->customFields['custfield_department'] ?? null;
```

### Trade-offs and Considerations

**Benefits:**
- ✅ Type safety for all base fields
- ✅ Flexibility for tenant-specific extensions
- ✅ No schema migrations needed for custom fields
- ✅ NetSuite custom fields preserved automatically
- ✅ Clear separation of concerns

**Limitations:**
- ⚠️ Custom fields are not type-checked
- ⚠️ Autocomplete won't work for custom fields
- ⚠️ Must manually access custom field arrays
- ⚠️ Custom field validation must be done separately (if needed)

**When to use this pattern:**
- ✅ Models with `fields` JSON column
- ✅ NetSuite integration models
- ✅ Any model that supports tenant customization

**When NOT to use:**
- ❌ Static, non-customizable models
- ❌ Core system tables without custom fields
- ❌ Models where all fields are always known

---

## Concrete Examples

### Example 1: Customer Domain DTO

A simpler example showing optional fields and batch upsert integration:

```php
<?php

namespace Domain\Customers\DataTransferObjects;

use Domain\Customers\Models\Customer;
use Spatie\LaravelData\Data;

class CustomerData extends Data
{
    public function __construct(
        public ?int $id,
        public string $title,
        public ?int $subsidiary,
        public ?string $email,
        public ?string $phone,
        public ?string $status,
        public ?string $refid,
        public ?string $external_id,
        public ?bool $synced,
        public array $customFields = [],
    ) {}

    public static function fromModel(Customer $customer): self
    {
        return new self(
            id: $customer->id,
            title: $customer->title,
            subsidiary: $customer->subsidiary,
            email: $customer->email,
            phone: $customer->phone,
            status: $customer->status,
            refid: $customer->refid,
            external_id: $customer->external_id,
            synced: $customer->synced,
            customFields: $customer->fields ?? [],
        );
    }

    public static function fromNetSuiteRecord(array $record): self
    {
        $customFields = [];
        foreach ($record as $key => $value) {
            if (str_starts_with($key, 'custfield_') ||
                str_starts_with($key, 'custentity_')) {
                $customFields[$key] = $value;
            }
        }

        return new self(
            id: null,
            title: $record['companyname'] ?? $record['entityid'] ?? '',
            subsidiary: $record['subsidiary'] ?? null,
            email: $record['email'] ?? null,
            phone: $record['phone'] ?? null,
            status: $record['entitystatus'] ?? null,
            refid: $record['id'] ?? null,
            external_id: $record['externalid'] ?? null,
            synced: true,
            customFields: $customFields,
        );
    }

    public function toModelAttributes(): array
    {
        return array_filter([
            'title' => $this->title,
            'subsidiary' => $this->subsidiary,
            'email' => $this->email,
            'phone' => $this->phone,
            'status' => $this->status,
            'refid' => $this->refid,
            'external_id' => $this->external_id,
            'synced' => $this->synced,
            'fields' => $this->customFields,
        ], fn($value) => $value !== null);
    }
}
```

**Usage in Batch Upsert Service:**

```php
// src/Domain/Customers/Services/CustomerBatchUpsertService.php
public function handle(array $netsuiteRecords, string $jobId, int $batchNumber): array
{
    $dtos = collect($netsuiteRecords)
        ->map(fn($record) => CustomerData::fromNetSuiteRecord($record))
        ->all();

    foreach ($dtos as $dto) {
        Customer::updateOrCreate(
            ['refid' => $dto->refid],
            $dto->toModelAttributes()
        );
    }

    return ['success' => true, 'processed' => count($dtos)];
}
```

### Example 2: Nested DTOs for Complex Operations

For operations involving related data, use nested DTOs:

```php
<?php

namespace Domain\ProjectTasks\DataTransferObjects;

use Domain\ProjectTasks\Models\ProjectTask;
use Spatie\LaravelData\Data;

class ProjectTaskData extends Data
{
    public function __construct(
        public ?int $id,
        public string $title,
        public int $project, // FK to project
        public ?int $parent, // Self-referential FK
        public ?string $status,
        public ?string $startdate,
        public ?string $enddate,
        public ?int $assignee,
        public ?int $predecessor,
        public array $customFields = [],
    ) {}

    public static function fromModel(ProjectTask $task): self
    {
        return new self(
            id: $task->id,
            title: $task->title,
            project: $task->project,
            parent: $task->parent,
            status: $task->status,
            startdate: $task->startdate,
            enddate: $task->enddate,
            assignee: $task->assignee,
            predecessor: $task->predecessor,
            customFields: $task->fields ?? [],
        );
    }
}

class CreateProjectTaskData extends Data
{
    public function __construct(
        public string $title,
        public int $projectId,
        public ?int $parentTaskId,
        public string $startDate,
        public string $endDate,
        public ?int $assigneeId,
        public array $predecessors = [],
        public array $customAttributes = [],
    ) {}

    /**
     * Validate the DTO
     */
    public static function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'projectId' => ['required', 'integer', 'exists:projects,id'],
            'parentTaskId' => ['nullable', 'integer', 'exists:projecttasks,id'],
            'startDate' => ['required', 'date'],
            'endDate' => ['required', 'date', 'after:startDate'],
            'assigneeId' => ['nullable', 'integer', 'exists:employees,id'],
            'predecessors' => ['array'],
            'predecessors.*' => ['integer', 'exists:projecttasks,id'],
        ];
    }
}
```

### Example 3: Batch Upsert Result DTO

Shared DTO for standardizing batch operation results:

```php
<?php

namespace Domain\Shared\DataTransferObjects;

use Spatie\LaravelData\Data;

class BatchUpsertResultData extends Data
{
    public function __construct(
        public bool $success,
        public int $totalRecords,
        public int $processedRecords,
        public int $createdRecords,
        public int $updatedRecords,
        public int $failedRecords,
        public array $errors = [],
        public ?float $duration = null,
    ) {}

    public static function fromArray(array $result): self
    {
        return new self(
            success: $result['success'] ?? false,
            totalRecords: $result['total'] ?? 0,
            processedRecords: $result['processed'] ?? 0,
            createdRecords: $result['created'] ?? 0,
            updatedRecords: $result['updated'] ?? 0,
            failedRecords: $result['failed'] ?? 0,
            errors: $result['errors'] ?? [],
            duration: $result['duration'] ?? null,
        );
    }
}
```

---

## Integration Patterns

### Pattern 1: Service Layer (Primary Use Case)

The service layer is the primary use case for DTOs in SuiteX. DTOs provide type-safe contracts between services.

**Before (Current - Array-Based):**

```php
// src/Domain/Projects/Services/ProjectBatchUpsertService.php
public function handle(array $netsuiteRecords, string $jobId, int $batchNumber): array
{
    $mappedRecords = $this->bulkFieldMapping($netsuiteRecords);
    $validatedRecords = $this->bulkValidation($mappedRecords, $netsuiteRecords);

    // Working with arrays - no type safety
    foreach ($validatedRecords as $record) {
        // What fields exist? IDE doesn't know.
        $title = $record['title'] ?? null;
        $customer = $record['customer'] ?? null;
        // Risk of typos, missing fields, wrong types
    }

    return ['success' => true, 'processed' => count($validatedRecords)];
}
```

**After (With DTOs - Type-Safe):**

```php
// src/Domain/Projects/Services/ProjectBatchUpsertService.php
use Domain\Projects\DataTransferObjects\ProjectData;
use Domain\Shared\DataTransferObjects\BatchUpsertResultData;

public function handle(array $netsuiteRecords, string $jobId, int $batchNumber): BatchUpsertResultData
{
    // Convert arrays to type-safe DTOs
    $projectDTOs = collect($netsuiteRecords)
        ->map(fn($record) => ProjectData::fromNetSuiteRecord($record))
        ->all();

    $created = 0;
    $updated = 0;
    $errors = [];

    foreach ($projectDTOs as $dto) {
        try {
            // Type-safe access - IDE knows all properties
            $project = Project::updateOrCreate(
                ['refid' => $dto->refid],
                $dto->toModelAttributes()
            );

            $project->wasRecentlyCreated ? $created++ : $updated++;

        } catch (\Exception $e) {
            $errors[] = [
                'refid' => $dto->refid,
                'error' => $e->getMessage()
            ];
        }
    }

    // Return type-safe result
    return new BatchUpsertResultData(
        success: empty($errors),
        totalRecords: count($netsuiteRecords),
        processedRecords: $created + $updated,
        createdRecords: $created,
        updatedRecords: $updated,
        failedRecords: count($errors),
        errors: $errors,
        duration: microtime(true) - $startTime,
    );
}
```

**Benefits:**
- Type safety throughout the method
- IDE autocomplete for all properties
- Clear return type
- Easier to test
- Self-documenting code

### Pattern 2: API Responses

Use DTOs to standardize API response structures:

```php
<?php

namespace App\Http\Controllers\Api;

use Domain\Projects\Models\Project;
use Domain\Projects\DataTransferObjects\ProjectData;
use Illuminate\Http\JsonResponse;

class ProjectController extends ApiController
{
    /**
     * Display the specified project
     */
    public function show(int $id): JsonResponse
    {
        if (!$this->checkPermission('can_read', $this->recordType)) {
            return $this->forbiddenResponse();
        }

        try {
            $project = Project::with(['customer', 'projectmanager', 'subsidiary'])
                ->findOrFail($id);

            // Convert to DTO for consistent response structure
            $projectData = ProjectData::fromModel($project);

            return $this->successResponse($projectData->toArray());

        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            return $this->notFoundResponse();
        }
    }

    /**
     * List projects with pagination
     */
    public function index(): JsonResponse
    {
        $projects = Project::paginate(50);

        $data = [
            'data' => $projects->map(fn($p) => ProjectData::fromModel($p)),
            'meta' => [
                'current_page' => $projects->currentPage(),
                'total' => $projects->total(),
                'per_page' => $projects->perPage(),
            ],
        ];

        return $this->successResponse($data);
    }
}
```

### Pattern 3: Domain Actions

Use DTOs as parameters for domain actions:

```php
<?php

namespace Domain\Projects\Actions;

use Domain\Projects\Models\Project;
use Domain\Projects\DataTransferObjects\CreateProjectData;
use Domain\Projects\DataTransferObjects\ProjectData;

class CreateProject
{
    /**
     * Create a new project
     */
    public function create(CreateProjectData $data): ProjectData
    {
        // Type-safe input
        $project = Project::create([
            'title' => $data->title,
            'customer' => $data->customerId,
            'projectmanager' => $data->projectManagerId,
            'subsidiary' => $data->subsidiaryId,
            'status' => $data->status,
            'fields' => $data->customAttributes,
        ]);

        // Create related entity
        $project->entity()->create([
            'refid' => $project->id,
            'entitiable_id' => $project->id,
            'entitiable_type' => Project::class,
            'is_project' => true,
            'title' => $project->title,
        ]);

        // Type-safe output
        return ProjectData::fromModel($project->fresh());
    }
}
```

**Usage in Controller:**

```php
public function store(Request $request, CreateProject $createAction)
{
    // Form Request handles HTTP validation
    $validated = $request->validate([
        'title' => 'required|string',
        'customer_id' => 'required|integer|exists:customers,id',
        // ... other validation rules
    ]);

    // Convert to DTO for type safety
    $createData = CreateProjectData::from($validated);

    // Action handles business logic
    $projectData = $createAction->create($createData);

    return response()->json($projectData, 201);
}
```

### Pattern 4: Livewire Components

DTOs can simplify Livewire component state management:

```php
<?php

namespace App\Http\Livewire\Projects;

use Domain\Projects\Models\Project;
use Domain\Projects\DataTransferObjects\UpdateProjectData;
use Domain\Projects\Actions\UpdateProject;
use Livewire\Component;

class EditProjectForm extends Component
{
    public UpdateProjectData $projectData;
    public Project $project;

    public function mount(Project $project): void
    {
        $this->project = $project;
        $this->projectData = UpdateProjectData::fromModel($project);
    }

    public function save(): void
    {
        // Validation happens on the DTO
        $this->validate();

        // Use action with type-safe DTO
        app(UpdateProject::class)->update($this->project, $this->projectData);

        session()->flash('message', 'Project updated successfully.');
        $this->redirect(route('projects.show', $this->project));
    }

    public function render()
    {
        return view('livewire.projects.edit-form');
    }
}
```

---

## Decision Tree: When to Create DTOs

### Create a DTO When

✅ **Passing data between service layer components**
- Example: Batch upsert services receiving data from importers
- Example: Service A calls Service B with complex data

✅ **Defining API response structures**
- Example: REST API endpoints returning consistent JSON
- Example: Standardizing response formats across controllers

✅ **Complex domain actions with multiple parameters (>3 parameters)**
- Example: CreateProjectData instead of 7 separate parameters
- Example: UpdateCustomerData bundling all updatable fields

✅ **Batch operations requiring consistent data structure**
- Example: NetSuite imports processing hundreds of records
- Example: Bulk updates across multiple records

✅ **Data transformation pipelines**
- Example: Converting NetSuite records to database format
- Example: Transforming external API data to internal structure

### Consider Skipping DTOs For

❌ **Single-property updates**
- Example: `updateStatus(Project $project, string $status)`
- Simple enough without DTO overhead

❌ **Simple CRUD where Form Request + Model suffices**
- Example: Basic create/update with < 3 fields
- Form Request provides sufficient validation

❌ **Internal helper methods with 1-2 parameters**
- Example: `calculateTotal(float $subtotal, float $tax)`
- DTOs would add unnecessary complexity

❌ **Temporary/throwaway operations**
- Example: One-time data migration scripts
- Example: Quick prototypes or experiments

### Decision Flowchart

```
START: Need to pass data?
│
├─→ Is data passed between services/layers?
│   └─→ YES: Create DTO ✅
│
├─→ NO: Does operation have >3 parameters?
│   ├─→ YES: Create DTO ✅
│   └─→ NO: Continue...
│
├─→ Is this an API endpoint?
│   ├─→ YES: Create DTO for response ✅
│   └─→ NO: Continue...
│
├─→ Is this a batch operation?
│   ├─→ YES: Create DTO ✅
│   └─→ NO: Continue...
│
└─→ Is this a data transformation?
    ├─→ YES: Create DTO ✅
    └─→ NO: Skip DTO, use Form Request or simple types ❌
```

### Real-World Examples

**✅ Good Use Cases:**

```php
// Complex service interaction
public function syncProject(ProjectData $projectData, array $relatedTasks): void

// API response
public function index(): JsonResponse
{
    return response()->json(
        $projects->map(fn($p) => ProjectData::fromModel($p))
    );
}

// Batch operation
public function processBatch(array $records): BatchUpsertResultData
{
    $dtos = collect($records)->map(fn($r) => CustomerData::from($r));
    // ...
}
```

**❌ Avoid DTOs For:**

```php
// Simple updates - no DTO needed
public function updateStatus(Project $project, string $status): void
{
    $project->update(['status' => $status]);
}

// Few parameters - simple types work fine
public function calculateDiscount(float $amount, float $rate): float
{
    return $amount * ($rate / 100);
}
```

---

## Creating New DTOs: Step-by-Step

Follow this process when creating a new DTO to ensure consistency and maintainability.

### Step 1: Determine DTO Location

**Decision Matrix:**

| Scope | Location | Example |
|-------|----------|---------|
| Single domain use | `src/Domain/{Domain}/DataTransferObjects/` | ProjectData in Projects domain |
| Cross-domain shared | `src/Domain/Shared/DataTransferObjects/` | BatchUpsertResultData |
| API-specific | `src/App/Http/DataTransferObjects/` | PaginatedResponseData |

**Review Reference:**
- Consult `docs/AI/ai_rules.md` for Service Placement Decision Matrix
- Place DTO in same domain as the model it represents
- If used by multiple domains, place in `Domain/Shared`

### Step 2: Review Database Schema

**⚠️ CRITICAL: Schema-First Approach**

Per the [Service Validation Process Rules](../AI/ai_rules.md#service-validation-process-rules), always start with schema analysis:

```bash
# View table structure
php artisan db:table projects --connection=tenant_connection
```

**Document:**
- ✓ Required vs nullable fields
- ✓ Data types (int, string, text, json, etc.)
- ✓ Length constraints (varchar 255, etc.)
- ✓ Foreign key relationships
- ✓ Unique constraints
- ✓ Default values
- ✓ Custom/flexible columns (custfield_*, fields json)

**Example Schema Notes:**

```
Table: projects
- id: bigint unsigned, primary key, auto-increment
- title: varchar(255), NOT NULL
- customer: int, nullable, FK to customers.refid
- projectmanager: int, nullable, FK to employees.refid
- subsidiary: int, nullable, FK to subsidiaries.refid
- status: varchar(255), nullable
- refid: varchar(255), nullable, unique
- external_id: varchar(255), nullable
- synced: boolean, nullable, default false
- fields: json, nullable (CUSTOM FIELDS)
- created_at: timestamp, nullable
- updated_at: timestamp, nullable
```

### Step 3: Define DTO Class

Create the DTO file in the appropriate location:

```php
<?php

namespace Domain\Projects\DataTransferObjects;

use Spatie\LaravelData\Data;

class ProjectData extends Data
{
    public function __construct(
        // Map all base fields from schema
        public ?int $id,
        public string $title,          // NOT NULL in schema
        public ?int $customer,         // Nullable FK
        public ?int $projectmanager,   // Nullable FK
        public ?int $subsidiary,       // Nullable FK
        public ?string $status,
        public ?string $refid,
        public ?string $external_id,
        public ?bool $synced,
        public ?string $created_at,
        public ?string $updated_at,

        // Flexible fields for tenant customization
        public array $customFields = [],
    ) {}
}
```

**Key Points:**
- Match PHP types to database types
- Use nullable (`?`) for nullable database columns
- Required fields should not be nullable (unless business logic requires it)
- Add `customFields` array for models with `fields` JSON column
- Use PHP 8.2 constructor property promotion

### Step 4: Add Static Constructors

Add methods to create DTOs from different sources:

```php
/**
 * Create DTO from Eloquent model
 */
public static function fromModel(Project $project): self
{
    return new self(
        id: $project->id,
        title: $project->title,
        customer: $project->customer,
        projectmanager: $project->projectmanager,
        subsidiary: $project->subsidiary,
        status: $project->status,
        refid: $project->refid,
        external_id: $project->external_id,
        synced: $project->synced,
        created_at: $project->created_at?->toISOString(),
        updated_at: $project->updated_at?->toISOString(),
        customFields: $project->fields ?? [],
    );
}

/**
 * Create DTO from NetSuite record array
 */
public static function fromNetSuiteRecord(array $record): self
{
    // Extract custom fields
    $customFields = [];
    foreach ($record as $key => $value) {
        if (str_starts_with($key, 'custfield_') ||
            str_starts_with($key, 'custbody_')) {
            $customFields[$key] = $value;
        }
    }

    return new self(
        id: null,
        title: $record['title'] ?? $record['companyname'] ?? '',
        customer: $record['customer'] ?? null,
        projectmanager: $record['projectmanager'] ?? null,
        subsidiary: $record['subsidiary'] ?? null,
        status: $record['status'] ?? null,
        refid: $record['refid'] ?? $record['id'] ?? null,
        external_id: $record['externalid'] ?? null,
        synced: true, // Mark as synced from NetSuite
        created_at: null,
        updated_at: null,
        customFields: $customFields,
    );
}

/**
 * Create DTO from validated HTTP request data
 */
public static function fromRequest(array $validated): self
{
    return new self(
        id: null,
        title: $validated['title'],
        customer: $validated['customer_id'] ?? null,
        projectmanager: $validated['project_manager_id'] ?? null,
        subsidiary: $validated['subsidiary_id'] ?? null,
        status: $validated['status'] ?? 'draft',
        refid: null,
        external_id: null,
        synced: false,
        created_at: null,
        updated_at: null,
        customFields: $validated['custom_fields'] ?? [],
    );
}
```

### Step 5: Add Transformation Methods

Add methods to convert DTOs to other formats:

```php
/**
 * Convert to array for database insertion
 */
public function toModelAttributes(): array
{
    $attributes = [
        'title' => $this->title,
        'customer' => $this->customer,
        'projectmanager' => $this->projectmanager,
        'subsidiary' => $this->subsidiary,
        'status' => $this->status,
        'refid' => $this->refid,
        'external_id' => $this->external_id,
        'synced' => $this->synced,
        'fields' => $this->customFields,
    ];

    // Only include ID if it exists (for updates)
    if ($this->id) {
        $attributes['id'] = $this->id;
    }

    // Filter out null values
    return array_filter($attributes, fn($value) => $value !== null);
}

/**
 * Convert to JSON-friendly array (for API responses)
 */
public function toJsonArray(): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'customer_id' => $this->customer,
        'project_manager_id' => $this->projectmanager,
        'subsidiary_id' => $this->subsidiary,
        'status' => $this->status,
        'reference_id' => $this->refid,
        'external_id' => $this->external_id,
        'synced' => $this->synced,
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
        'custom_fields' => $this->customFields,
    ];
}
```

### Step 6: Add Validation Rules (Optional)

If needed, add validation rules to the DTO:

```php
/**
 * Validation rules for this DTO
 */
public static function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'customer' => ['nullable', 'integer', 'exists:customers,id'],
        'projectmanager' => ['nullable', 'integer', 'exists:employees,id'],
        'subsidiary' => ['nullable', 'integer', 'exists:subsidiaries,id'],
        'status' => ['nullable', 'string', 'max:255'],
        // NO validation for customFields - tenant-specific
    ];
}
```

### Step 7: Document and Test

Add PHPDoc blocks and create tests:

```php
/**
 * Data Transfer Object for Project model
 *
 * Represents a project with both base fields and tenant-specific custom fields.
 *
 * @package Domain\Projects\DataTransferObjects
 */
class ProjectData extends Data
{
    /**
     * Create a new ProjectData instance
     *
     * @param int|null $id Primary key
     * @param string $title Project title (required)
     * @param int|null $customer Foreign key to customer
     * @param int|null $projectmanager Foreign key to employee
     * @param int|null $subsidiary Foreign key to subsidiary
     * @param string|null $status Project status
     * @param string|null $refid NetSuite reference ID
     * @param string|null $external_id External system reference
     * @param bool|null $synced Whether synced from NetSuite
     * @param string|null $created_at ISO 8601 timestamp
     * @param string|null $updated_at ISO 8601 timestamp
     * @param array $customFields Tenant-specific custom fields
     */
    public function __construct(
        // ... properties
    ) {}
}
```

**Testing (covered in detail in [Testing DTOs](#testing-dtos) section):**
- Test `fromModel()` accuracy
- Test `fromNetSuiteRecord()` with various inputs
- Test custom field pass-through
- Test validation rules

---

## File Structure

### Complete Directory Layout

```
src/Domain/Projects/
├── Actions/
│   ├── CreateProject.php
│   └── UpdateProject.php
├── DataTransferObjects/
│   ├── ProjectData.php              # General purpose DTO
│   ├── CreateProjectData.php        # For creation operations
│   ├── UpdateProjectData.php        # For update operations
│   └── ProjectResponseData.php      # For API responses
├── Models/
│   └── Project.php
└── Services/
    └── ProjectBatchUpsertService.php

src/Domain/Customers/
├── DataTransferObjects/
│   ├── CustomerData.php
│   ├── CreateCustomerData.php
│   └── CustomerResponseData.php
├── Models/
│   └── Customer.php
└── Services/
    └── CustomerBatchUpsertService.php

src/Domain/Shared/
├── DataTransferObjects/
│   ├── AddressData.php              # Shared across domains
│   ├── EntityData.php
│   ├── BatchUpsertResultData.php
│   └── RelationshipData.php
└── Services/
    └── RecordUpsertService.php

src/App/Http/
├── Controllers/
│   └── Api/
│       ├── ProjectController.php
│       └── CustomerController.php
└── DataTransferObjects/
    ├── PaginatedResponseData.php    # API-specific
    ├── ErrorResponseData.php
    └── SearchRequestData.php
```

### Naming Pattern Examples

| File | Purpose |
|------|---------|
| `ProjectData.php` | General purpose DTO, can be used for reads, creates, updates |
| `CreateProjectData.php` | Specialized for creation, may have different required fields |
| `UpdateProjectData.php` | Specialized for updates, may make all fields optional |
| `ProjectResponseData.php` | Specialized for API responses, may include computed fields |
| `NetSuiteProjectData.php` | Context-specific, handles NetSuite format |
| `ProjectTaskData.php` | Nested/related entity |
| `BatchUpsertResultData.php` | Operation result DTO |

---

## Validation Strategy

### Built-in Validation with spatie/laravel-data

The `spatie/laravel-data` package provides multiple ways to validate DTOs:

#### 1. Validation Attributes

Use PHP 8 attributes for inline validation:

```php
<?php

namespace Domain\Projects\DataTransferObjects;

use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Exists;

class CreateProjectData extends Data
{
    public function __construct(
        #[Required, Max(255)]
        public string $title,

        #[Exists('customers', 'id')]
        public ?int $customerId,

        #[Exists('employees', 'id')]
        public ?int $projectManagerId,

        public array $customFields = [],
    ) {}
}
```

#### 2. Rules Method

Define validation rules in a static method:

```php
public static function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'customerId' => ['nullable', 'integer', 'exists:customers,id'],
        'projectManagerId' => ['nullable', 'integer', 'exists:employees,id'],
        // Do NOT validate customFields - tenant-specific
    ];
}
```

#### 3. Custom Validation Attributes

Create reusable validation attributes for business rules:

```php
<?php

namespace Domain\Shared\Attributes\Validation;

use Attribute;
use Spatie\LaravelData\Attributes\Validation\ValidationAttribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class NetSuiteRefId extends ValidationAttribute
{
    public function getRules(): array
    {
        return ['regex:/^[0-9]+$/'];
    }
}

// Usage
class ProjectData extends Data
{
    public function __construct(
        #[NetSuiteRefId]
        public ?string $refid,
        // ... other properties
    ) {}
}
```

### Validation for Tenant-Specific Fields

**Important:** Do NOT validate custom fields in the DTO itself, as they are tenant-specific and unpredictable.

```php
public static function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'customer' => ['nullable', 'integer'],
        // ❌ DON'T DO THIS:
        // 'customFields.custfield_priority' => ['required'],
        // ✅ Custom fields validated separately if needed
    ];
}
```

**If you need to validate custom fields**, do it separately based on tenant configuration:

```php
// In service layer, after DTO creation
$dto = ProjectData::fromNetSuiteRecord($record);

// Validate custom fields based on tenant configuration
$tenantFieldRules = TenantFieldValidator::getRulesFor('projects');
$validator = Validator::make($dto->customFields, $tenantFieldRules);

if ($validator->fails()) {
    // Handle custom field validation errors
}
```

### Automatic Validation

DTOs can be automatically validated when created from arrays:

```php
// This will throw ValidationException if validation fails
$projectData = CreateProjectData::from($request->validated());

// Use validateAndCreate for explicit validation
try {
    $projectData = CreateProjectData::validateAndCreate($inputArray);
} catch (ValidationException $e) {
    // Handle validation errors
    $errors = $e->errors();
}
```

### Conditional Validation

For different validation rules based on context:

```php
class UpdateProjectData extends Data
{
    public function __construct(
        public ?string $title,
        public ?int $customerId,
        public ?string $status,
    ) {}

    public static function rules(): array
    {
        return [
            'title' => ['sometimes', 'string', 'max:255'],
            'customerId' => ['sometimes', 'integer', 'exists:customers,id'],
            'status' => ['sometimes', 'string', 'in:active,inactive,completed'],
        ];
    }
}
```

---

## Migration Strategy

### Phase 1: New Development (Start Immediately)

**Goal:** All new code uses DTOs from day one.

**Actions:**
1. ✅ Install `spatie/laravel-data` package (completed)
2. ✅ Create DTO architecture documentation (this document)
3. Create first DTOs for high-value areas:
   - `BatchUpsertResultData` (shared across all batch operations)
   - `ProjectData` (high-volume NetSuite imports)
   - `CustomerData` (high-volume NetSuite imports)

**Rules for New Code:**
- All new batch upsert services MUST use DTOs
- All new API endpoints SHOULD use DTOs for responses
- All new actions with >3 parameters MUST use DTOs
- All new domain actions SHOULD use DTOs

**Timeline:** Weeks 1-2

### Phase 2: Incremental Refactoring (Ongoing)

**Goal:** Gradually convert existing code to DTOs as you touch it.

**Approach:**
- **Boy Scout Rule:** Leave code better than you found it
- When modifying a service, convert it to use DTOs
- Start with one domain at a time
- Maintain backward compatibility during transition

**Priority Order:**
1. **Batch Upsert Services** (highest impact)
   - `ProjectBatchUpsertService`
   - `CustomerBatchUpsertService`
   - `InvoiceBatchUpsertService`
   - `VendorBatchUpsertService`

2. **Domain Actions**
   - `CreateProject`, `UpdateProject`
   - `CreateCustomer`, `UpdateCustomer`

3. **API Controllers**
   - `ProjectController`
   - `CustomerController`

4. **Shared Services**
   - `RecordUpsertService` (update to accept DTOs)

**Example Refactoring:**

```php
// Before
public function handle(array $netsuiteRecords, string $jobId): array
{
    $mappedRecords = $this->bulkFieldMapping($netsuiteRecords);
    // ... array manipulation
    return ['success' => true, 'count' => count($mappedRecords)];
}

// After
use Domain\Projects\DataTransferObjects\ProjectData;
use Domain\Shared\DataTransferObjects\BatchUpsertResultData;

public function handle(array $netsuiteRecords, string $jobId): BatchUpsertResultData
{
    $dtos = collect($netsuiteRecords)
        ->map(fn($record) => ProjectData::fromNetSuiteRecord($record))
        ->all();

    // ... type-safe processing

    return new BatchUpsertResultData(
        success: true,
        totalRecords: count($netsuiteRecords),
        processedRecords: $processed,
        // ... other fields
    );
}
```

**Timeline:** Ongoing over 3-6 months

### Phase 3: Full Adoption (Long-term Goal)

**Goal:** All service layer code uses DTOs consistently.

**Actions:**
1. Update all batch upsert services to use DTOs
2. Update all domain actions to use DTOs
3. Update all API controllers to use DTOs for responses
4. Remove array-based method signatures where possible
5. Update integration tests to use DTOs
6. Update this documentation with learned patterns

**Success Criteria:**
- ✅ All batch upsert services use DTOs
- ✅ All API responses use DTOs
- ✅ PHPStan level 5 passes for all DTO code
- ✅ Test coverage > 80% for DTO transformations
- ✅ Zero array-based service signatures in new code

**Timeline:** 6-12 months

### Backward Compatibility Strategy

During migration, maintain backward compatibility:

```php
// Support both array and DTO inputs during transition
public function processProject(ProjectData|array $data): void
{
    // Convert array to DTO if needed
    if (is_array($data)) {
        $data = ProjectData::from($data);
    }

    // Now work with type-safe DTO
    $this->validateProject($data);
    // ...
}
```

### Measuring Progress

Track DTO adoption with metrics:

```bash
# Count DTO files created
find src/Domain -name "*Data.php" | wc -l

# Find services still using arrays
grep -r "public function handle(array" src/Domain/*/Services/

# Find actions using DTOs
grep -r "Data \$" src/Domain/*/Actions/
```

---

## Testing DTOs

### Unit Tests for DTOs

DTOs should have comprehensive unit tests covering all transformation methods.

**Test Structure:**

```php
<?php

namespace Tests\Unit\Domain\Projects\DataTransferObjects;

use Domain\Projects\Models\Project;
use Domain\Projects\DataTransferObjects\ProjectData;
use Tests\TestCase;

class ProjectDataTest extends TestCase
{
    /** @test */
    public function it_creates_from_model_with_all_fields()
    {
        $project = Project::factory()->create([
            'title' => 'Test Project',
            'customer' => 123,
            'projectmanager' => 456,
            'status' => 'active',
            'fields' => ['custfield_priority' => 'high']
        ]);

        $dto = ProjectData::fromModel($project);

        expect($dto->id)->toBe($project->id)
            ->and($dto->title)->toBe('Test Project')
            ->and($dto->customer)->toBe(123)
            ->and($dto->projectmanager)->toBe(456)
            ->and($dto->status)->toBe('active')
            ->and($dto->customFields)->toBe(['custfield_priority' => 'high']);
    }

    /** @test */
    public function it_creates_from_netsuite_record()
    {
        $netsuiteRecord = [
            'id' => '12345',
            'title' => 'NetSuite Project',
            'customer' => '789',
            'projectmanager' => '101',
            'custfield_priority' => 'high',
            'custfield_department' => 'Engineering',
        ];

        $dto = ProjectData::fromNetSuiteRecord($netsuiteRecord);

        expect($dto->refid)->toBe('12345')
            ->and($dto->title)->toBe('NetSuite Project')
            ->and($dto->synced)->toBeTrue()
            ->and($dto->customFields)->toHaveKey('custfield_priority')
            ->and($dto->customFields['custfield_priority'])->toBe('high')
            ->and($dto->customFields)->toHaveKey('custfield_department');
    }

    /** @test */
    public function it_handles_missing_custom_fields()
    {
        $project = Project::factory()->create([
            'title' => 'Project Without Custom Fields',
            'fields' => null,
        ]);

        $dto = ProjectData::fromModel($project);

        expect($dto->customFields)->toBe([]);
    }

    /** @test */
    public function it_converts_to_model_attributes()
    {
        $dto = new ProjectData(
            id: null,
            title: 'New Project',
            customer: 123,
            projectmanager: 456,
            subsidiary: 789,
            status: 'active',
            refid: '12345',
            external_id: 'EXT-123',
            synced: true,
            created_at: null,
            updated_at: null,
            customFields: ['custfield_priority' => 'high'],
        );

        $attributes = $dto->toModelAttributes();

        expect($attributes)->toHaveKey('title')
            ->and($attributes['title'])->toBe('New Project')
            ->and($attributes)->toHaveKey('fields')
            ->and($attributes['fields'])->toBe(['custfield_priority' => 'high'])
            ->and($attributes)->not->toHaveKey('id'); // Null values filtered out
    }

    /** @test */
    public function it_passes_through_all_custom_fields()
    {
        $netsuiteRecord = [
            'id' => '123',
            'title' => 'Project',
            'custfield_one' => 'value1',
            'custfield_two' => 'value2',
            'custfield_three' => 'value3',
            'custbody_special' => 'special',
        ];

        $dto = ProjectData::fromNetSuiteRecord($netsuiteRecord);

        expect($dto->customFields)->toHaveCount(4)
            ->and($dto->customFields)->toHaveKeys([
                'custfield_one',
                'custfield_two',
                'custfield_three',
                'custbody_special'
            ]);
    }
}
```

### Integration Tests

Test DTOs in the context of actual services:

```php
<?php

namespace Tests\Feature\Domain\Projects\Services;

use Domain\Projects\Models\Project;
use Domain\Projects\Services\ProjectBatchUpsertService;
use Domain\Projects\DataTransferObjects\ProjectData;
use Tests\TestCase;

class ProjectBatchUpsertServiceTest extends TestCase
{
    /** @test */
    public function it_processes_netsuite_records_with_custom_fields()
    {
        $netsuiteRecords = [
            [
                'id' => '123',
                'title' => 'Project 1',
                'customer' => '456',
                'custfield_priority' => 'high',
                'custfield_region' => 'West',
            ],
            [
                'id' => '124',
                'title' => 'Project 2',
                'customer' => '457',
                'custfield_priority' => 'low',
            ],
        ];

        $service = new ProjectBatchUpsertService();
        $result = $service->handle($netsuiteRecords, 'job-123', 1);

        expect($result->success)->toBeTrue()
            ->and($result->processedRecords)->toBe(2);

        // Verify custom fields were preserved
        $project1 = Project::where('refid', '123')->first();
        expect($project1->fields)->toHaveKey('custfield_priority')
            ->and($project1->fields['custfield_priority'])->toBe('high')
            ->and($project1->fields)->toHaveKey('custfield_region');
    }
}
```

### Testing Best Practices

1. **Test all transformation methods**
   - `fromModel()` with various model states
   - `fromNetSuiteRecord()` with complete and partial records
   - `toModelAttributes()` with different field combinations

2. **Test custom field handling**
   - Custom fields are extracted correctly
   - Custom fields are preserved through transformations
   - Missing custom fields don't cause errors

3. **Test validation rules**
   - Valid data passes
   - Invalid data fails with correct errors
   - Custom fields are not validated

4. **Test edge cases**
   - Null values
   - Empty strings
   - Missing required fields
   - Unknown fields (should be ignored or captured in customFields)

5. **Use factories for test data**
   ```php
   $project = Project::factory()
       ->withCustomFields(['custfield_priority' => 'high'])
       ->create();
   ```

---

## Best Practices & Patterns

### Immutability

DTOs should be immutable by design. The `spatie/laravel-data` package provides immutability by default.

```php
// ✅ Good - Create new DTO with changes
$original = new ProjectData(/* ... */);
$updated = new ProjectData(
    ...$original->toArray(),
    status: 'completed'
);

// ❌ Bad - Trying to mutate (will fail in spatie/laravel-data)
$dto->title = 'New Title'; // Properties are readonly
```

### Single Responsibility

Each DTO should have one clear purpose. Create specialized DTOs rather than multi-purpose ones.

```php
// ✅ Good - Specialized DTOs
class CreateProjectData extends Data { /* ... */ }
class UpdateProjectData extends Data { /* ... */ }
class ProjectResponseData extends Data { /* ... */ }

// ❌ Avoid - One DTO trying to do everything
class ProjectData extends Data {
    public function __construct(
        public ?int $id,
        public bool $isForCreate = false,
        public bool $isForUpdate = false,
        public bool $isForApi = false,
        // ... confusion ensues
    ) {}
}
```

### Composition Over Inheritance

Prefer nesting DTOs over complex inheritance hierarchies.

```php
// ✅ Good - Composition
class ProjectWithRelationsData extends Data
{
    public function __construct(
        public ProjectData $project,
        public ?CustomerData $customer,
        public ?EmployeeData $projectManager,
        public array $tasks = [],
    ) {}
}

// ❌ Avoid - Deep inheritance
class BaseProjectData extends Data { /* ... */ }
class ExtendedProjectData extends BaseProjectData { /* ... */ }
class SuperExtendedProjectData extends ExtendedProjectData { /* ... */ }
```

### Explicit Over Implicit

Be explicit about nullable fields and default values.

```php
// ✅ Good - Explicit nullability
public function __construct(
    public string $title,          // Required
    public ?int $customer,         // Explicitly optional
    public array $customFields = [], // Default value explicit
) {}

// ❌ Avoid - Ambiguous
public function __construct(
    public $title,     // What type? Required?
    public $customer,  // Nullable? Optional?
    public $fields,    // Array? Null? Empty?
) {}
```

### Document Custom Fields Strategy

Always document how custom fields are handled for tenant-specific DTOs.

```php
/**
 * Data Transfer Object for Project model
 *
 * This DTO handles both base fields (type-safe) and tenant-specific
 * custom fields (flexible). Custom fields from NetSuite are extracted
 * into the $customFields array and stored in the model's 'fields' JSON column.
 *
 * Base fields are validated, custom fields are not.
 *
 * @package Domain\Projects\DataTransferObjects
 */
class ProjectData extends Data
{
    // ...
}
```

### Type Safety First

Leverage PHP 8.2 types fully for maximum safety.

```php
// ✅ Good - Full type safety
public function __construct(
    public ?int $id,
    public string $title,
    public ?int $customer,
    public bool $synced = false,
    public array $customFields = [],
) {}

// ❌ Avoid - Missing types
public function __construct(
    public $id,
    public $title,
    $customer = null,
    $synced,
) {}
```

### Use Named Arguments

When creating DTOs, use named arguments for clarity.

```php
// ✅ Good - Clear and self-documenting
$project = new ProjectData(
    id: null,
    title: 'New Project',
    customer: 123,
    projectmanager: 456,
    customFields: ['priority' => 'high'],
);

// ❌ Avoid - Positional (error-prone)
$project = new ProjectData(null, 'New Project', 123, 456, ...);
```

### Keep Transformation Logic Simple

Static constructor methods should be straightforward data transformations only.

```php
// ✅ Good - Simple transformation
public static function fromModel(Project $project): self
{
    return new self(
        id: $project->id,
        title: $project->title,
        customFields: $project->fields ?? [],
    );
}

// ❌ Avoid - Complex business logic in transformation
public static function fromModel(Project $project): self
{
    // Don't do this in DTO transformation
    $status = $project->calculateComplexStatus();
    $metrics = app(MetricsService::class)->calculate($project);

    return new self(/* ... */);
}
```

### Separate Concerns: Read vs Write DTOs

For complex operations, separate read and write concerns.

```php
// Read DTO - includes computed fields, relationships
class ProjectResponseData extends Data
{
    public function __construct(
        public int $id,
        public string $title,
        public ?CustomerData $customer,      // Loaded relationship
        public int $taskCount,               // Computed field
        public string $completionPercentage, // Computed field
    ) {}
}

// Write DTO - only updateable fields
class UpdateProjectData extends Data
{
    public function __construct(
        public ?string $title,
        public ?int $customerId,
        public ?string $status,
    ) {}
}
```

---

## Common Patterns Reference

### Pattern: Creating DTOs from NetSuite Records

Standard pattern for extracting custom fields:

```php
public static function fromNetSuiteRecord(array $record): self
{
    // Extract custom fields (predictable NetSuite prefixes)
    $customFields = [];
    foreach ($record as $key => $value) {
        if (str_starts_with($key, 'custfield_') ||
            str_starts_with($key, 'custbody_') ||
            str_starts_with($key, 'custcol_') ||
            str_starts_with($key, 'custentity_')) {
            $customFields[$key] = $value;
        }
    }

    return new self(
        id: null,
        title: $record['title'] ?? $record['companyname'] ?? '',
        // Map NetSuite field names to local field names
        refid: $record['refid'] ?? $record['id'] ?? null,
        external_id: $record['externalid'] ?? null,
        synced: true, // Always true for imports
        customFields: $customFields,
    );
}
```

### Pattern: Handling Missing/Null Fields Gracefully

Use null coalescing and provide sensible defaults:

```php
public static function fromArray(array $data): self
{
    return new self(
        id: $data['id'] ?? null,
        title: $data['title'] ?? '',
        customer: isset($data['customer']) && $data['customer'] !== ''
            ? (int)$data['customer']
            : null,
        status: $data['status'] ?? 'draft',
        customFields: $data['fields'] ?? $data['customFields'] ?? [],
    );
}
```

### Pattern: Nested Relationship DTOs

Handle related data through nested DTOs:

```php
class ProjectWithCustomerData extends Data
{
    public function __construct(
        public ProjectData $project,
        public ?CustomerData $customer,
    ) {}

    public static function fromModel(Project $project): self
    {
        return new self(
            project: ProjectData::fromModel($project),
            customer: $project->customer
                ? CustomerData::fromModel($project->customer)
                : null,
        );
    }
}
```

### Pattern: Collection DTOs (Arrays of DTOs)

Use Laravel collections for transforming arrays:

```php
public static function collection(array $models): array
{
    return collect($models)
        ->map(fn($model) => self::fromModel($model))
        ->all();
}

// Usage
$projectDTOs = ProjectData::collection($projects);
```

### Pattern: Partial Update DTOs

Allow partial updates with all optional fields:

```php
class UpdateProjectData extends Data
{
    public function __construct(
        public ?string $title = null,
        public ?int $customerId = null,
        public ?string $status = null,
        public ?array $customFields = null,
    ) {}

    public function toModelAttributes(): array
    {
        // Only include non-null values
        return array_filter([
            'title' => $this->title,
            'customer' => $this->customerId,
            'status' => $this->status,
            'fields' => $this->customFields,
        ], fn($value) => $value !== null);
    }
}

// Usage
$updates = new UpdateProjectData(title: 'New Title');
$project->update($updates->toModelAttributes());
// Only updates 'title', leaves other fields unchanged
```

### Pattern: DTO with Default Factory Method

Provide convenient factory methods for common cases:

```php
class ProjectData extends Data
{
    // ... constructor

    public static function empty(): self
    {
        return new self(
            id: null,
            title: '',
            customer: null,
            projectmanager: null,
            customFields: [],
        );
    }

    public static function draft(string $title): self
    {
        return new self(
            id: null,
            title: $title,
            status: 'draft',
            synced: false,
            customFields: [],
        );
    }
}

// Usage
$draft = ProjectData::draft('My New Project');
```

---

## Troubleshooting Guide

### Issue: Custom Fields Not Passing Through

**Symptom:** Custom fields from NetSuite are missing after DTO transformation.

**Solution:**
1. Verify custom fields are being extracted in `fromNetSuiteRecord()`:
   ```php
   // Add debugging
   $customFields = [];
   foreach ($record as $key => $value) {
       if (str_starts_with($key, 'custfield_')) {
           \Log::debug("Found custom field: $key = $value");
           $customFields[$key] = $value;
       }
   }
   ```

2. Check that `customFields` is included in `toModelAttributes()`:
   ```php
   return [
       // ... other fields
       'fields' => $this->customFields, // Must map to model's JSON column
   ];
   ```

3. Verify model has `fields` cast:
   ```php
   protected $casts = [
       'fields' => 'array',
   ];
   ```

### Issue: Type Coercion Errors

**Symptom:** Type errors when creating DTOs from arrays: "Expected int, got string"

**Solution:**
Explicitly cast values in static constructors:

```php
public static function fromNetSuiteRecord(array $record): self
{
    return new self(
        id: isset($record['id']) ? (int)$record['id'] : null,
        customer: isset($record['customer']) && $record['customer'] !== ''
            ? (int)$record['customer']
            : null,
        synced: isset($record['synced'])
            ? filter_var($record['synced'], FILTER_VALIDATE_BOOLEAN)
            : null,
    );
}
```

### Issue: Null vs Empty String Handling

**Symptom:** Database errors or unexpected behavior with empty strings vs null.

**Solution:**
Normalize empty strings to null in transformation:

```php
public function toModelAttributes(): array
{
    $attributes = [
        'title' => $this->title,
        'customer' => $this->customer,
        'status' => $this->status,
    ];

    // Convert empty strings to null for nullable fields
    foreach ($attributes as $key => $value) {
        if ($value === '') {
            $attributes[$key] = null;
        }
    }

    return array_filter($attributes, fn($value) => $value !== null);
}
```

### Issue: Circular Dependency with Models

**Symptom:** "Class not found" or circular dependency errors when DTOs import models.

**Solution:**
1. Use fully qualified class names in doc blocks:
   ```php
   /**
    * @param \Domain\Projects\Models\Project $project
    */
   public static function fromModel(Project $project): self
   ```

2. Import at method level if needed:
   ```php
   public static function fromModel($project): self
   {
       // Type hint in parameter instead of using class
   }
   ```

### Issue: PHPStan Errors with DTOs

**Symptom:** PHPStan reports property access errors or type mismatches.

**Common Errors and Solutions:**

1. **"Property does not exist"**
   ```php
   // ❌ PHPStan doesn't recognize magic properties
   $dto->nonExistentField;

   // ✅ Use defined properties only
   $dto->title;
   ```

2. **"Method toArray() has no return type"**
   ```php
   // ✅ Add explicit return type
   public function toModelAttributes(): array
   {
       return [...];
   }
   ```

3. **"Unable to resolve type"**
   ```php
   // ❌ Ambiguous type
   public function __construct(
       public $customFields,
   ) {}

   // ✅ Explicit type
   public function __construct(
       public array $customFields = [],
   ) {}
   ```

### Issue: Validation Not Triggering

**Symptom:** Invalid data passes through DTO creation without errors.

**Solution:**
Explicitly call validation:

```php
// ❌ May not validate automatically
$dto = ProjectData::from($data);

// ✅ Explicitly validate
$dto = ProjectData::validateAndCreate($data);

// Or use Form Request first
$validated = $request->validated();
$dto = ProjectData::from($validated);
```

### Issue: Performance Degradation with Large Collections

**Symptom:** Slow performance when converting large arrays to DTOs.

**Solution:**
1. Use lazy collections for large datasets:
   ```php
   use Illuminate\Support\LazyCollection;

   LazyCollection::make($records)
       ->map(fn($record) => ProjectData::fromNetSuiteRecord($record))
       ->chunk(100)
       ->each(function ($chunk) {
           // Process in batches
       });
   ```

2. Profile DTO creation:
   ```php
   $start = microtime(true);
   $dtos = collect($records)->map(fn($r) => ProjectData::from($r));
   $duration = microtime(true) - $start;
   \Log::info("DTO creation took {$duration}s for " . count($records) . " records");
   ```

### Issue: Custom Fields Validation Needed

**Symptom:** Need to validate tenant-specific custom fields but can't add to DTO rules.

**Solution:**
Validate custom fields separately in service layer:

```php
$dto = ProjectData::fromNetSuiteRecord($record);

// Get tenant-specific validation rules
$tenantRules = app(TenantFieldValidator::class)
    ->getRulesForModel('projects');

// Validate only custom fields
$validator = Validator::make($dto->customFields, $tenantRules);

if ($validator->fails()) {
    throw new ValidationException($validator);
}

// Proceed with validated DTO
$project = Project::create($dto->toModelAttributes());
```

---

## Additional Resources

### Related Documentation

- [AI Rules](../AI/ai_rules.md) - Service placement and schema-first validation rules
- [Form Validation Policy](../security/form-validation-policy.md) - HTTP layer validation requirements
- [API Structure](api/api-structure.md) - API controller patterns and standards

### External Resources

- [Spatie Laravel Data Documentation](https://spatie.be/docs/laravel-data/v4/introduction)
- [PHP 8.2 Type System](https://www.php.net/manual/en/language.types.php)
- [Laravel Collections](https://laravel.com/docs/10.x/collections)

### Package Information

- **Package:** `spatie/laravel-data`
- **Version:** ^4.18
- **License:** MIT
- **Repository:** https://github.com/spatie/laravel-data

---

## Document Changelog

| Date | Version | Changes |
|------|---------|---------|
| 2025-11-07 | 1.0 | Initial DTO architecture documentation created |

---

**Questions or Improvements?**

This documentation is a living document. If you encounter patterns not covered here, or have suggestions for improvements, please update this document with your findings.

