Model to DTO - Grazulex/laravel-arc GitHub Wiki

Model to DTO Conversion

Laravel Arc provides a powerful trait DTOFromModelTrait that allows easy conversion of Eloquent models to DTOs. This feature is particularly useful when you need to transform your database models into DTOs for API responses or service layer operations.

Basic Usage

To use the Model to DTO conversion, add the DTOFromModelTrait to your DTO class:

use Grazulex\Arc\LaravelArcDTO;
use Grazulex\Arc\Traits\DTOFromModelTrait;
use Grazulex\Arc\Attributes\Property;

class UserDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'int', required: false)]
    public ?int $id;

    #[Property(type: 'string', required: true)]
    public string $name;

    #[Property(type: 'string', required: true, validation: 'email')]
    public string $email;
}

Once the trait is included, you can use several methods to convert your models to DTOs:

Single Model Conversion

// Basic conversion
$user = User::find(1);
$userDTO = UserDTO::fromModel($user);

// With specific relations
$user = User::with('profile')->find(1);
$userDTO = UserDTO::fromModel($user, ['profile']);

Collection Conversion

// Convert multiple models
$users = User::all();
$userDTOs = UserDTO::fromModels($users);

// With relations
$users = User::with('profile')->get();
$userDTOs = UserDTO::fromModels($users, ['profile']);

Automatic Relation Detection

// Automatically include all loaded relations
$user = User::with(['profile', 'posts'])->find(1);
$userDTO = UserDTO::fromModelWithLoadedRelations($user);

// For collections
$users = User::with(['profile', 'posts'])->get();
$userDTOs = UserDTO::fromModelsWithLoadedRelations($users);

Available Methods

fromModel()

Creates a single DTO instance from an Eloquent model.

public static function fromModel(Model $model, array $relations = []): static
  • $model: The Eloquent model to convert
  • $relations: Optional array of relation names to include (must be loaded on the model)

fromModels()

Creates multiple DTO instances from a collection of models.

public static function fromModels(Collection $models, array $relations = []): array
  • $models: Collection of Eloquent models
  • $relations: Optional array of relation names to include

fromModelWithLoadedRelations()

Creates a DTO instance with automatic inclusion of all loaded relations.

public static function fromModelWithLoadedRelations(Model $model): static
  • $model: The Eloquent model to convert (with any preloaded relations)

fromModelsWithLoadedRelations()

Creates multiple DTO instances with automatic inclusion of all loaded relations.

public static function fromModelsWithLoadedRelations(Collection $models): array
  • $models: Collection of Eloquent models (with any preloaded relations)

Best Practices

  1. Explicit Relations: When you know which relations you need, use fromModel() with explicit relations rather than fromModelWithLoadedRelations().

  2. Eager Loading: Always eager load relations before conversion to avoid N+1 queries:

    // Good
    $users = User::with('profile')->get();
    $dtos = UserDTO::fromModels($users, ['profile']);
    
    // Bad - Will cause N+1 queries
    $users = User::all();
    $dtos = UserDTO::fromModels($users, ['profile']);
    
  3. Type Safety: Define proper property types and use validation rules:

    #[Property(type: 'int', required: false)]
    public ?int $id;
    
    #[Property(type: 'string', required: true, validation: 'email')]
    public string $email;
    
  4. Nested DTOs: For related models, create separate DTOs:

    #[Property(type: 'nested', class: ProfileDTO::class, required: false)]
    public ?ProfileDTO $profile;
    
    #[Property(type: 'collection', class: PostDTO::class, required: false)]
    public array $posts;
    

Handling Relations

When working with Eloquent model relations, there are several approaches to handle them in your DTOs:

Basic Relations

class CategoryDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'int', required: true)]
    public int $id;

    #[Property(type: 'string', required: true)]
    public string $name;

    #[Property(type: 'string', required: false)]
    public ?string $slug;

    #[Property(type: 'date', required: true)]
    public CarbonImmutable $created_at;

    #[Property(type: 'date', required: false)]
    public ?CarbonImmutable $updated_at;
}

class UserDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'int', required: true)]
    public int $id;

    #[Property(type: 'string', required: true)]
    public string $name;

    #[Property(type: 'string', required: true)]
    public string $email;

    #[Property(type: 'date', required: true)]
    public CarbonImmutable $created_at;

    #[Property(type: 'date', required: false)]
    public ?CarbonImmutable $updated_at;

    #[Property(type: 'nested', required: false, class: CategoryDTO::class)]
    public ?array $categories;
}

Avoiding Infinite Loops

When dealing with bi-directional relationships (like User has many Categories and Category belongs to User), you need to be careful to avoid infinite loops. Here are two approaches:

  1. Use Different DTOs for Different Contexts
// Full DTO with all relations
class UserDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'nested', required: false, class: CategoryDTO::class)]
    public ?array $categories;
}

// Simplified DTO for relation context
class UserSimpleDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'int', required: true)]
    public int $id;

    #[Property(type: 'string', required: true)]
    public string $name;

    // No categories relation here
}

class CategoryDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'nested', required: false, class: UserSimpleDTO::class)]
    public ?UserSimpleDTO $user; // Use simplified DTO to avoid loops
}
  1. Choose One Side to Handle Relations
class UserDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'nested', required: false, class: CategoryDTO::class)]
    public ?array $categories; // Handle relations here
}

class CategoryDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    // Don't include user relation here
}

Usage in controller:

class TestController extends Controller
{
    public function __invoke()
    {
        $user = User::where('id', 1)
            ->with('categories')
            ->first();

        // Create DTO with loaded relations
        $userDTO = UserDTO::fromModel($user, ['categories']);
        // or
        $userDTO = UserDTO::fromModelWithLoadedRelations($user);

        return view('test', [
            'user' => $user,
            'userDTO' => $userDTO
        ]);
    }
}

Example with Nested Relations

class UserDTO extends LaravelArcDTO
{
    use DTOFromModelTrait;

    #[Property(type: 'int', required: false)]
    public ?int $id;

    #[Property(type: 'string', required: true)]
    public string $name;

    #[Property(type: 'nested', class: ProfileDTO::class, required: false)]
    public ?ProfileDTO $profile;

    #[Property(type: 'collection', class: PostDTO::class, required: false)]
    public array $posts;
}

// Usage in controller
class UserController extends Controller
{
    public function show(User $user)
    {
        $user->load(['profile', 'posts']);
        $dto = UserDTO::fromModel($user, ['profile', 'posts']);
        
        return response()->json($dto);
    }

    public function index()
    {
        $users = User::with(['profile', 'posts'])
            ->paginate(10);

        $dtos = UserDTO::fromModels($users->getCollection(), ['profile', 'posts']);

        return response()->json([
            'data' => $dtos,
            'pagination' => $users->toArray()
        ]);
    }
}

Common Patterns

Service Layer Pattern

class UserService
{
    public function getUserWithProfile(int $userId): UserDTO
    {
        $user = User::with('profile')->findOrFail($userId);
        return UserDTO::fromModel($user, ['profile']);
    }

    public function getActiveUsersWithPosts(): array
    {
        $users = User::with('posts')
            ->where('is_active', true)
            ->get();

        return UserDTO::fromModels($users, ['posts']);
    }
}

Repository Pattern

class UserRepository
{
    public function findWithDTO(int $id): ?UserDTO
    {
        $user = User::with(['profile', 'settings'])->find($id);
        
        return $user ? UserDTO::fromModel($user, ['profile', 'settings']) : null;
    }

    public function getAllActiveWithDTO(): array
    {
        $users = User::with('profile')
            ->where('is_active', true)
            ->get();

        return UserDTO::fromModels($users, ['profile']);
    }
}