Skip to content

Data Transfer Objects (DTOs)

Overview

Data Transfer Objects (DTOs) are immutable data structures used to transfer data between layers of the application. In the Gold Standard Module, DTOs serve as the contract between the API layer and the application/domain layers, ensuring type safety and clear data boundaries.

DTO Architecture

flowchart LR
    subgraph "External"
        A[API Request]
        B[API Response]
    end

    subgraph "DTOs"
        C[Request DTO]
        D[Response DTO]
    end

    subgraph "Domain"
        E[Entity]
        F[Value Object]
    end

    A --> C
    C --> E
    E --> D
    D --> B

Request DTOs

CreateArticleRequest

namespace Xoops\Modules\GoldStandard\Application\DTOs\Request;

use Xoops\Modules\Xmf\Application\DTO\RequestDTO;

final class CreateArticleRequest implements RequestDTO
{
    public function __construct(
        public readonly string $title,
        public readonly string $content,
        public readonly ?string $excerpt = null,
        public readonly ?string $slug = null,
        public readonly array $categoryIds = [],
        public readonly array $tags = [],
        public readonly ?string $featuredImage = null,
        public readonly ?string $featuredImageAlt = null,
        public readonly ?string $metaTitle = null,
        public readonly ?string $metaDescription = null,
        public readonly string $status = 'draft',
        public readonly bool $featured = false
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            title: $data['title'] ?? throw new \InvalidArgumentException('Title is required'),
            content: $data['content'] ?? throw new \InvalidArgumentException('Content is required'),
            excerpt: $data['excerpt'] ?? null,
            slug: $data['slug'] ?? null,
            categoryIds: $data['category_ids'] ?? [],
            tags: $data['tags'] ?? [],
            featuredImage: $data['featured_image'] ?? null,
            featuredImageAlt: $data['featured_image_alt'] ?? null,
            metaTitle: $data['meta_title'] ?? null,
            metaDescription: $data['meta_description'] ?? null,
            status: $data['status'] ?? 'draft',
            featured: (bool) ($data['featured'] ?? false)
        );
    }

    public function toArray(): array
    {
        return [
            'title' => $this->title,
            'content' => $this->content,
            'excerpt' => $this->excerpt,
            'slug' => $this->slug,
            'category_ids' => $this->categoryIds,
            'tags' => $this->tags,
            'featured_image' => $this->featuredImage,
            'featured_image_alt' => $this->featuredImageAlt,
            'meta_title' => $this->metaTitle,
            'meta_description' => $this->metaDescription,
            'status' => $this->status,
            'featured' => $this->featured
        ];
    }
}

UpdateArticleRequest

namespace Xoops\Modules\GoldStandard\Application\DTOs\Request;

use Xoops\Modules\Xmf\Application\DTO\RequestDTO;

final class UpdateArticleRequest implements RequestDTO
{
    public function __construct(
        public readonly string $id,
        public readonly ?string $title = null,
        public readonly ?string $content = null,
        public readonly ?string $excerpt = null,
        public readonly ?string $slug = null,
        public readonly ?array $categoryIds = null,
        public readonly ?array $tags = null,
        public readonly ?string $featuredImage = null,
        public readonly ?string $featuredImageAlt = null,
        public readonly ?string $metaTitle = null,
        public readonly ?string $metaDescription = null,
        public readonly ?string $status = null,
        public readonly ?bool $featured = null
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            id: $data['id'] ?? throw new \InvalidArgumentException('ID is required'),
            title: $data['title'] ?? null,
            content: $data['content'] ?? null,
            excerpt: $data['excerpt'] ?? null,
            slug: $data['slug'] ?? null,
            categoryIds: $data['category_ids'] ?? null,
            tags: $data['tags'] ?? null,
            featuredImage: $data['featured_image'] ?? null,
            featuredImageAlt: $data['featured_image_alt'] ?? null,
            metaTitle: $data['meta_title'] ?? null,
            metaDescription: $data['meta_description'] ?? null,
            status: $data['status'] ?? null,
            featured: isset($data['featured']) ? (bool) $data['featured'] : null
        );
    }

    /**
     * Get only the fields that were explicitly set (not null)
     */
    public function getChangedFields(): array
    {
        $changes = [];

        if ($this->title !== null) $changes['title'] = $this->title;
        if ($this->content !== null) $changes['content'] = $this->content;
        if ($this->excerpt !== null) $changes['excerpt'] = $this->excerpt;
        if ($this->slug !== null) $changes['slug'] = $this->slug;
        if ($this->categoryIds !== null) $changes['category_ids'] = $this->categoryIds;
        if ($this->tags !== null) $changes['tags'] = $this->tags;
        if ($this->featuredImage !== null) $changes['featured_image'] = $this->featuredImage;
        if ($this->featuredImageAlt !== null) $changes['featured_image_alt'] = $this->featuredImageAlt;
        if ($this->metaTitle !== null) $changes['meta_title'] = $this->metaTitle;
        if ($this->metaDescription !== null) $changes['meta_description'] = $this->metaDescription;
        if ($this->status !== null) $changes['status'] = $this->status;
        if ($this->featured !== null) $changes['featured'] = $this->featured;

        return $changes;
    }
}

ArticleListRequest (Query Parameters)

namespace Xoops\Modules\GoldStandard\Application\DTOs\Request;

use Xoops\Modules\Xmf\Application\DTO\RequestDTO;

final class ArticleListRequest implements RequestDTO
{
    public const SORT_DATE = 'date';
    public const SORT_TITLE = 'title';
    public const SORT_VIEWS = 'views';
    public const SORT_RATING = 'rating';

    public const ORDER_ASC = 'asc';
    public const ORDER_DESC = 'desc';

    public function __construct(
        public readonly int $page = 1,
        public readonly int $perPage = 20,
        public readonly ?string $categoryId = null,
        public readonly ?string $authorId = null,
        public readonly ?string $status = null,
        public readonly ?string $search = null,
        public readonly ?array $tags = null,
        public readonly string $sort = self::SORT_DATE,
        public readonly string $order = self::ORDER_DESC,
        public readonly ?string $dateFrom = null,
        public readonly ?string $dateTo = null,
        public readonly ?bool $featured = null
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        if ($this->page < 1) {
            throw new \InvalidArgumentException('Page must be at least 1');
        }

        if ($this->perPage < 1 || $this->perPage > 100) {
            throw new \InvalidArgumentException('Per page must be between 1 and 100');
        }

        $validSorts = [self::SORT_DATE, self::SORT_TITLE, self::SORT_VIEWS, self::SORT_RATING];
        if (!in_array($this->sort, $validSorts, true)) {
            throw new \InvalidArgumentException('Invalid sort field');
        }

        $validOrders = [self::ORDER_ASC, self::ORDER_DESC];
        if (!in_array($this->order, $validOrders, true)) {
            throw new \InvalidArgumentException('Invalid sort order');
        }
    }

    public static function fromQueryParams(array $params): self
    {
        return new self(
            page: (int) ($params['page'] ?? 1),
            perPage: (int) ($params['per_page'] ?? 20),
            categoryId: $params['category_id'] ?? null,
            authorId: $params['author_id'] ?? null,
            status: $params['status'] ?? null,
            search: $params['search'] ?? null,
            tags: isset($params['tags']) ? explode(',', $params['tags']) : null,
            sort: $params['sort'] ?? self::SORT_DATE,
            order: $params['order'] ?? self::ORDER_DESC,
            dateFrom: $params['date_from'] ?? null,
            dateTo: $params['date_to'] ?? null,
            featured: isset($params['featured']) ? filter_var($params['featured'], FILTER_VALIDATE_BOOLEAN) : null
        );
    }

    public function getOffset(): int
    {
        return ($this->page - 1) * $this->perPage;
    }
}

CreateCommentRequest

namespace Xoops\Modules\GoldStandard\Application\DTOs\Request;

use Xoops\Modules\Xmf\Application\DTO\RequestDTO;

final class CreateCommentRequest implements RequestDTO
{
    public function __construct(
        public readonly string $articleId,
        public readonly string $content,
        public readonly ?string $parentId = null,
        public readonly ?string $authorName = null,
        public readonly ?string $authorEmail = null
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            articleId: $data['article_id'] ?? throw new \InvalidArgumentException('Article ID is required'),
            content: $data['content'] ?? throw new \InvalidArgumentException('Content is required'),
            parentId: $data['parent_id'] ?? null,
            authorName: $data['author_name'] ?? null,
            authorEmail: $data['author_email'] ?? null
        );
    }
}

Response DTOs

ArticleResponse

namespace Xoops\Modules\GoldStandard\Application\DTOs\Response;

use Xoops\Modules\GoldStandard\Domain\Article;
use Xoops\Modules\Xmf\Application\DTO\ResponseDTO;

final class ArticleResponse implements ResponseDTO
{
    public function __construct(
        public readonly string $id,
        public readonly string $title,
        public readonly string $slug,
        public readonly string $content,
        public readonly string $excerpt,
        public readonly AuthorResponse $author,
        public readonly array $categories,
        public readonly array $tags,
        public readonly string $status,
        public readonly bool $featured,
        public readonly ?string $featuredImage,
        public readonly ?string $featuredImageAlt,
        public readonly ?string $metaTitle,
        public readonly ?string $metaDescription,
        public readonly int $viewCount,
        public readonly int $commentCount,
        public readonly ?float $rating,
        public readonly string $createdAt,
        public readonly ?string $publishedAt,
        public readonly ?string $updatedAt,
        public readonly string $url,
        public readonly array $links
    ) {}

    public static function fromEntity(Article $article): self
    {
        return new self(
            id: $article->getId(),
            title: $article->getTitle(),
            slug: $article->getSlug(),
            content: $article->getContent(),
            excerpt: $article->getExcerpt(),
            author: AuthorResponse::fromUser($article->getAuthor()),
            categories: array_map(
                fn($cat) => CategoryResponse::fromEntity($cat),
                $article->getCategories()
            ),
            tags: array_map(
                fn($tag) => ['id' => $tag->getId(), 'name' => $tag->getName()],
                $article->getTags()
            ),
            status: $article->getStatus()->value,
            featured: $article->isFeatured(),
            featuredImage: $article->getFeaturedImage(),
            featuredImageAlt: $article->getFeaturedImageAlt(),
            metaTitle: $article->getMetaTitle(),
            metaDescription: $article->getMetaDescription(),
            viewCount: $article->getViewCount(),
            commentCount: $article->getCommentCount(),
            rating: $article->getRating(),
            createdAt: $article->getCreatedAt()->format(\DateTimeInterface::ATOM),
            publishedAt: $article->getPublishedAt()?->format(\DateTimeInterface::ATOM),
            updatedAt: $article->getUpdatedAt()?->format(\DateTimeInterface::ATOM),
            url: $article->getUrl(),
            links: self::buildLinks($article)
        );
    }

    private static function buildLinks(Article $article): array
    {
        $baseUrl = XOOPS_URL . '/modules/goldstandard/api/v1';

        return [
            'self' => ['href' => "{$baseUrl}/articles/{$article->getId()}"],
            'author' => ['href' => "{$baseUrl}/users/{$article->getAuthorId()}"],
            'comments' => ['href' => "{$baseUrl}/articles/{$article->getId()}/comments"],
            'categories' => ['href' => "{$baseUrl}/articles/{$article->getId()}/categories"]
        ];
    }

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'type' => 'article',
            'attributes' => [
                'title' => $this->title,
                'slug' => $this->slug,
                'content' => $this->content,
                'excerpt' => $this->excerpt,
                'status' => $this->status,
                'featured' => $this->featured,
                'featured_image' => $this->featuredImage,
                'featured_image_alt' => $this->featuredImageAlt,
                'meta_title' => $this->metaTitle,
                'meta_description' => $this->metaDescription,
                'view_count' => $this->viewCount,
                'comment_count' => $this->commentCount,
                'rating' => $this->rating,
                'created_at' => $this->createdAt,
                'published_at' => $this->publishedAt,
                'updated_at' => $this->updatedAt,
                'url' => $this->url
            ],
            'relationships' => [
                'author' => ['data' => $this->author->toArray()],
                'categories' => ['data' => array_map(fn($c) => $c->toArray(), $this->categories)],
                'tags' => ['data' => $this->tags]
            ],
            'links' => $this->links
        ];
    }
}

ArticleListResponse

namespace Xoops\Modules\GoldStandard\Application\DTOs\Response;

use Xoops\Modules\Xmf\Application\DTO\ResponseDTO;

final class ArticleListResponse implements ResponseDTO
{
    public function __construct(
        public readonly array $articles,
        public readonly PaginationMeta $pagination,
        public readonly array $links
    ) {}

    public static function create(
        array $articles,
        int $page,
        int $perPage,
        int $total,
        string $baseUrl
    ): self {
        $articleResponses = array_map(
            fn($article) => ArticleResponse::fromEntity($article),
            $articles
        );

        $totalPages = (int) ceil($total / $perPage);

        $pagination = new PaginationMeta(
            currentPage: $page,
            perPage: $perPage,
            totalItems: $total,
            totalPages: $totalPages
        );

        $links = [
            'self' => ['href' => "{$baseUrl}?page={$page}&per_page={$perPage}"],
            'first' => ['href' => "{$baseUrl}?page=1&per_page={$perPage}"],
            'last' => ['href' => "{$baseUrl}?page={$totalPages}&per_page={$perPage}"]
        ];

        if ($page > 1) {
            $links['prev'] = ['href' => "{$baseUrl}?page=" . ($page - 1) . "&per_page={$perPage}"];
        }

        if ($page < $totalPages) {
            $links['next'] = ['href' => "{$baseUrl}?page=" . ($page + 1) . "&per_page={$perPage}"];
        }

        return new self(
            articles: $articleResponses,
            pagination: $pagination,
            links: $links
        );
    }

    public function toArray(): array
    {
        return [
            'data' => array_map(fn($a) => $a->toArray(), $this->articles),
            'meta' => [
                'pagination' => $this->pagination->toArray()
            ],
            'links' => $this->links
        ];
    }
}

CategoryResponse

namespace Xoops\Modules\GoldStandard\Application\DTOs\Response;

use Xoops\Modules\GoldStandard\Domain\Category;
use Xoops\Modules\Xmf\Application\DTO\ResponseDTO;

final class CategoryResponse implements ResponseDTO
{
    public function __construct(
        public readonly string $id,
        public readonly string $name,
        public readonly string $slug,
        public readonly ?string $description,
        public readonly ?string $parentId,
        public readonly int $articleCount,
        public readonly ?string $image,
        public readonly int $order,
        public readonly array $links
    ) {}

    public static function fromEntity(Category $category): self
    {
        $baseUrl = XOOPS_URL . '/modules/goldstandard/api/v1';

        return new self(
            id: $category->getId(),
            name: $category->getName(),
            slug: $category->getSlug(),
            description: $category->getDescription(),
            parentId: $category->getParentId(),
            articleCount: $category->getArticleCount(),
            image: $category->getImage(),
            order: $category->getOrder(),
            links: [
                'self' => ['href' => "{$baseUrl}/categories/{$category->getId()}"],
                'articles' => ['href' => "{$baseUrl}/categories/{$category->getId()}/articles"],
                'parent' => $category->getParentId()
                    ? ['href' => "{$baseUrl}/categories/{$category->getParentId()}"]
                    : null
            ]
        );
    }

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'type' => 'category',
            'attributes' => [
                'name' => $this->name,
                'slug' => $this->slug,
                'description' => $this->description,
                'parent_id' => $this->parentId,
                'article_count' => $this->articleCount,
                'image' => $this->image,
                'order' => $this->order
            ],
            'links' => array_filter($this->links)
        ];
    }
}

AuthorResponse

namespace Xoops\Modules\GoldStandard\Application\DTOs\Response;

use Xoops\Modules\Xmf\Application\DTO\ResponseDTO;
use XoopsUser;

final class AuthorResponse implements ResponseDTO
{
    public function __construct(
        public readonly int $id,
        public readonly string $username,
        public readonly string $displayName,
        public readonly ?string $avatar,
        public readonly ?string $bio,
        public readonly int $articleCount,
        public readonly array $links
    ) {}

    public static function fromUser(XoopsUser $user): self
    {
        $baseUrl = XOOPS_URL . '/modules/goldstandard/api/v1';

        return new self(
            id: $user->getVar('uid'),
            username: $user->getVar('uname'),
            displayName: $user->getVar('name') ?: $user->getVar('uname'),
            avatar: $user->getVar('user_avatar')
                ? XOOPS_UPLOAD_URL . '/' . $user->getVar('user_avatar')
                : null,
            bio: $user->getVar('bio'),
            articleCount: self::getArticleCount($user->getVar('uid')),
            links: [
                'self' => ['href' => "{$baseUrl}/users/{$user->getVar('uid')}"],
                'articles' => ['href' => "{$baseUrl}/users/{$user->getVar('uid')}/articles"]
            ]
        );
    }

    private static function getArticleCount(int $userId): int
    {
        // Implementation would query repository
        return 0;
    }

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'type' => 'user',
            'attributes' => [
                'username' => $this->username,
                'display_name' => $this->displayName,
                'avatar' => $this->avatar,
                'bio' => $this->bio,
                'article_count' => $this->articleCount
            ],
            'links' => $this->links
        ];
    }
}

PaginationMeta

namespace Xoops\Modules\GoldStandard\Application\DTOs\Response;

final class PaginationMeta
{
    public function __construct(
        public readonly int $currentPage,
        public readonly int $perPage,
        public readonly int $totalItems,
        public readonly int $totalPages
    ) {}

    public function toArray(): array
    {
        return [
            'current_page' => $this->currentPage,
            'per_page' => $this->perPage,
            'total_items' => $this->totalItems,
            'total_pages' => $this->totalPages,
            'has_previous' => $this->currentPage > 1,
            'has_next' => $this->currentPage < $this->totalPages
        ];
    }
}

ErrorResponse

namespace Xoops\Modules\GoldStandard\Application\DTOs\Response;

use Xoops\Modules\Xmf\Application\DTO\ResponseDTO;

final class ErrorResponse implements ResponseDTO
{
    public function __construct(
        public readonly string $code,
        public readonly string $message,
        public readonly ?array $details = null,
        public readonly ?string $source = null
    ) {}

    public static function notFound(string $resource, string $id): self
    {
        return new self(
            code: 'NOT_FOUND',
            message: "{$resource} with ID '{$id}' was not found",
            source: "/data/{$resource}/{$id}"
        );
    }

    public static function validationError(array $errors): self
    {
        return new self(
            code: 'VALIDATION_ERROR',
            message: 'The request data failed validation',
            details: $errors
        );
    }

    public static function unauthorized(): self
    {
        return new self(
            code: 'UNAUTHORIZED',
            message: 'Authentication required'
        );
    }

    public static function forbidden(string $action): self
    {
        return new self(
            code: 'FORBIDDEN',
            message: "You do not have permission to {$action}"
        );
    }

    public function toArray(): array
    {
        $error = [
            'code' => $this->code,
            'message' => $this->message
        ];

        if ($this->details !== null) {
            $error['details'] = $this->details;
        }

        if ($this->source !== null) {
            $error['source'] = $this->source;
        }

        return ['error' => $error];
    }
}

DTO Factory

namespace Xoops\Modules\GoldStandard\Application\DTOs;

class DTOFactory
{
    public function createArticleResponse(Article $article): ArticleResponse
    {
        return ArticleResponse::fromEntity($article);
    }

    public function createArticleListResponse(
        array $articles,
        int $page,
        int $perPage,
        int $total,
        string $baseUrl
    ): ArticleListResponse {
        return ArticleListResponse::create(
            $articles,
            $page,
            $perPage,
            $total,
            $baseUrl
        );
    }

    public function createCategoryResponse(Category $category): CategoryResponse
    {
        return CategoryResponse::fromEntity($category);
    }
}

Best Practices

  1. Immutability: DTOs should be immutable - use readonly properties
  2. Validation: Validate in constructors or static factory methods
  3. Type Safety: Use strict typing for all properties
  4. Naming: Use clear, descriptive names that indicate purpose
  5. Separation: Keep request and response DTOs separate
  6. Documentation: Document expected formats in OpenAPI/Swagger