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¶
- Immutability: DTOs should be immutable - use
readonlyproperties - Validation: Validate in constructors or static factory methods
- Type Safety: Use strict typing for all properties
- Naming: Use clear, descriptive names that indicate purpose
- Separation: Keep request and response DTOs separate
- Documentation: Document expected formats in OpenAPI/Swagger