📚 Gold Standard Repository Layer¶
Clean data access through the Repository Pattern.
Applicable to Both Versions (with adaptation)
The Repository pattern shown here uses PHP 8.2+ syntax. For XOOPS 2.5.x, you can implement the same pattern by:
- Wrapping your existing
XoopsPersistableObjectHandlerin a Repository class - Using docblock annotations instead of native type declarations
- Manually injecting dependencies (no container needed)
See Repository Pattern Guide for version-agnostic examples.
The repository layer provides a clean abstraction over data persistence, allowing the domain layer to remain independent of database concerns.
Repository Pattern Overview¶
flowchart TB
subgraph Domain["Domain Layer"]
ENT[Article Entity]
INT[ArticleRepository Interface]
end
subgraph Infrastructure["Infrastructure Layer"]
IMPL[XoopsArticleRepository]
MAP[ArticleMapper]
DB[(Database)]
end
ENT --> INT
INT -.->|implements| IMPL
IMPL --> MAP
MAP --> DB Repository Interface¶
Defined in the domain layer, completely agnostic of persistence:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Domain\Repository;
use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\Entity\ArticleStatus;
use Xoops\GoldStandard\Domain\ValueObject\{ArticleId, ArticleSlug, AuthorId, CategoryId};
interface ArticleRepository
{
/**
* Generate a new unique identifier
*/
public function nextIdentity(): ArticleId;
/**
* Find an article by ID
*/
public function find(ArticleId $id): ?Article;
/**
* Find an article by ID or throw exception
*
* @throws ArticleNotFound
*/
public function findOrFail(ArticleId $id): Article;
/**
* Find an article by slug
*/
public function findBySlug(ArticleSlug $slug): ?Article;
/**
* Find all articles by status
*
* @return list<Article>
*/
public function findByStatus(ArticleStatus $status): array;
/**
* Find articles by author
*
* @return list<Article>
*/
public function findByAuthor(AuthorId $authorId): array;
/**
* Find articles by category
*
* @return list<Article>
*/
public function findByCategory(CategoryId $categoryId): array;
/**
* Find published articles with pagination
*/
public function findPublishedPaginated(int $page, int $perPage): Paginator;
/**
* Persist an article (insert or update)
*/
public function save(Article $article): void;
/**
* Remove an article
*/
public function remove(Article $article): void;
/**
* Count all articles
*/
public function count(): int;
/**
* Count articles by status
*/
public function countByStatus(ArticleStatus $status): int;
}
Repository Implementation¶
The concrete implementation in the infrastructure layer:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Infrastructure\Persistence;
use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\Entity\ArticleStatus;
use Xoops\GoldStandard\Domain\Repository\ArticleRepository;
use Xoops\GoldStandard\Domain\ValueObject\{ArticleId, ArticleSlug, AuthorId, CategoryId};
use Xoops\Database\Connection;
final class XoopsArticleRepository implements ArticleRepository
{
private const TABLE = 'goldstandard_articles';
public function __construct(
private readonly Connection $db,
private readonly ArticleMapper $mapper,
private readonly ArticleTagRepository $tagRepository,
) {}
public function nextIdentity(): ArticleId
{
return ArticleId::generate();
}
public function find(ArticleId $id): ?Article
{
$sql = sprintf(
"SELECT * FROM %s WHERE id = :id",
$this->tableName()
);
$row = $this->db->fetchOne($sql, ['id' => $id->toString()]);
if ($row === null) {
return null;
}
$article = $this->mapper->toDomain($row);
$this->loadTags($article);
return $article;
}
public function findOrFail(ArticleId $id): Article
{
$article = $this->find($id);
if ($article === null) {
throw ArticleNotFound::withId($id);
}
return $article;
}
public function findBySlug(ArticleSlug $slug): ?Article
{
$sql = sprintf(
"SELECT * FROM %s WHERE slug = :slug",
$this->tableName()
);
$row = $this->db->fetchOne($sql, ['slug' => $slug->value]);
if ($row === null) {
return null;
}
$article = $this->mapper->toDomain($row);
$this->loadTags($article);
return $article;
}
public function findByStatus(ArticleStatus $status): array
{
$sql = sprintf(
"SELECT * FROM %s WHERE status = :status ORDER BY created_at DESC",
$this->tableName()
);
$rows = $this->db->fetchAll($sql, ['status' => $status->value]);
return array_map(
fn(array $row) => $this->hydrateWithTags($row),
$rows
);
}
public function findByAuthor(AuthorId $authorId): array
{
$sql = sprintf(
"SELECT * FROM %s WHERE author_id = :author_id ORDER BY created_at DESC",
$this->tableName()
);
$rows = $this->db->fetchAll($sql, ['author_id' => $authorId->value]);
return array_map(
fn(array $row) => $this->hydrateWithTags($row),
$rows
);
}
public function findByCategory(CategoryId $categoryId): array
{
$sql = sprintf(
"SELECT * FROM %s WHERE category_id = :category_id ORDER BY created_at DESC",
$this->tableName()
);
$rows = $this->db->fetchAll($sql, ['category_id' => $categoryId->value]);
return array_map(
fn(array $row) => $this->hydrateWithTags($row),
$rows
);
}
public function findPublishedPaginated(int $page, int $perPage): Paginator
{
$offset = ($page - 1) * $perPage;
$sql = sprintf(
"SELECT * FROM %s WHERE status = :status
ORDER BY published_at DESC
LIMIT :limit OFFSET :offset",
$this->tableName()
);
$rows = $this->db->fetchAll($sql, [
'status' => ArticleStatus::Published->value,
'limit' => $perPage,
'offset' => $offset,
]);
$total = $this->countByStatus(ArticleStatus::Published);
$items = array_map(fn($row) => $this->hydrateWithTags($row), $rows);
return new Paginator($items, $total, $page, $perPage);
}
public function save(Article $article): void
{
$data = $this->mapper->toPersistence($article);
$exists = $this->exists($article->id);
if ($exists) {
$this->update($article->id, $data);
} else {
$this->insert($data);
}
// Sync tags
$this->tagRepository->sync($article->id, $article->getTags());
}
public function remove(Article $article): void
{
// Remove tags first
$this->tagRepository->removeAll($article->id);
// Remove article
$sql = sprintf(
"DELETE FROM %s WHERE id = :id",
$this->tableName()
);
$this->db->execute($sql, ['id' => $article->id->toString()]);
}
public function count(): int
{
$sql = sprintf("SELECT COUNT(*) FROM %s", $this->tableName());
return (int) $this->db->fetchColumn($sql);
}
public function countByStatus(ArticleStatus $status): int
{
$sql = sprintf(
"SELECT COUNT(*) FROM %s WHERE status = :status",
$this->tableName()
);
return (int) $this->db->fetchColumn($sql, ['status' => $status->value]);
}
private function tableName(): string
{
return $this->db->prefix(self::TABLE);
}
private function exists(ArticleId $id): bool
{
$sql = sprintf(
"SELECT 1 FROM %s WHERE id = :id",
$this->tableName()
);
return $this->db->fetchColumn($sql, ['id' => $id->toString()]) !== false;
}
private function insert(array $data): void
{
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
$this->tableName(),
$columns,
$placeholders
);
$this->db->execute($sql, $data);
}
private function update(ArticleId $id, array $data): void
{
unset($data['id'], $data['created_at']);
$sets = array_map(
fn(string $col) => "{$col} = :{$col}",
array_keys($data)
);
$sql = sprintf(
"UPDATE %s SET %s WHERE id = :id",
$this->tableName(),
implode(', ', $sets)
);
$data['id'] = $id->toString();
$this->db->execute($sql, $data);
}
private function hydrateWithTags(array $row): Article
{
$article = $this->mapper->toDomain($row);
$this->loadTags($article);
return $article;
}
private function loadTags(Article $article): void
{
$tags = $this->tagRepository->findByArticle($article->id);
foreach ($tags as $tag) {
$article->addTag($tag);
}
}
}
Data Mapper¶
Converts between domain entities and database records:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Infrastructure\Persistence;
use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\Entity\ArticleStatus;
use Xoops\GoldStandard\Domain\ValueObject\{
ArticleId,
ArticleTitle,
ArticleContent,
ArticleSlug,
AuthorId,
CategoryId
};
final class ArticleMapper
{
/**
* Convert database row to domain entity
*/
public function toDomain(array $row): Article
{
return Article::reconstitute(
id: ArticleId::fromString($row['id']),
title: ArticleTitle::fromString($row['title']),
slug: ArticleSlug::fromString($row['slug']),
content: ArticleContent::fromString($row['content'], $row['content_format']),
status: ArticleStatus::from($row['status']),
authorId: AuthorId::fromInt((int) $row['author_id']),
categoryId: CategoryId::fromInt((int) $row['category_id']),
createdAt: new \DateTimeImmutable($row['created_at']),
updatedAt: $row['updated_at']
? new \DateTimeImmutable($row['updated_at'])
: null,
publishedAt: $row['published_at']
? new \DateTimeImmutable($row['published_at'])
: null,
);
}
/**
* Convert domain entity to database row
*/
public function toPersistence(Article $article): array
{
return [
'id' => $article->id->toString(),
'title' => $article->getTitle()->value,
'slug' => $article->getSlug()->value,
'content' => $article->getContent()->value,
'content_format' => $article->getContent()->format,
'status' => $article->getStatus()->value,
'author_id' => $article->getAuthorId()->value,
'category_id' => $article->getCategoryId()->value,
'created_at' => $article->getCreatedAt()->format('Y-m-d H:i:s'),
'updated_at' => $article->getUpdatedAt()?->format('Y-m-d H:i:s'),
'published_at' => $article->getPublishedAt()?->format('Y-m-d H:i:s'),
];
}
}
Repository Patterns¶
Specification Pattern¶
For complex queries:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Domain\Specification;
interface Specification
{
public function toSql(): string;
public function toParameters(): array;
}
final class PublishedArticles implements Specification
{
public function toSql(): string
{
return "status = :status";
}
public function toParameters(): array
{
return ['status' => ArticleStatus::Published->value];
}
}
final class ArticlesByAuthor implements Specification
{
public function __construct(
private readonly AuthorId $authorId,
) {}
public function toSql(): string
{
return "author_id = :author_id";
}
public function toParameters(): array
{
return ['author_id' => $this->authorId->value];
}
}
final class AndSpecification implements Specification
{
public function __construct(
private readonly Specification $left,
private readonly Specification $right,
) {}
public function toSql(): string
{
return sprintf(
"(%s) AND (%s)",
$this->left->toSql(),
$this->right->toSql()
);
}
public function toParameters(): array
{
return array_merge(
$this->left->toParameters(),
$this->right->toParameters()
);
}
}
Using Specifications¶
<?php
// In repository
public function findBySpecification(Specification $spec): array
{
$sql = sprintf(
"SELECT * FROM %s WHERE %s",
$this->tableName(),
$spec->toSql()
);
$rows = $this->db->fetchAll($sql, $spec->toParameters());
return array_map(fn($row) => $this->mapper->toDomain($row), $rows);
}
// Usage
$spec = new AndSpecification(
new PublishedArticles(),
new ArticlesByAuthor($authorId)
);
$articles = $repository->findBySpecification($spec);
Query Flow¶
sequenceDiagram
participant S as Service
participant R as Repository
participant M as Mapper
participant DB as Database
S->>R: find(ArticleId)
R->>DB: SELECT * WHERE id = ?
DB-->>R: Row data
R->>M: toDomain(row)
M-->>R: Article entity
R->>R: loadTags(article)
R-->>S: Article
S->>R: save(Article)
R->>M: toPersistence(article)
M-->>R: Array data
R->>DB: INSERT/UPDATE
R->>R: syncTags()
R-->>S: void Testing Repositories¶
In-Memory Repository¶
Uses XMF ULID for identifier generation:
<?php
declare(strict_types=1);
namespace Tests\Infrastructure;
use Xmf\Ulid;
final class InMemoryArticleRepository implements ArticleRepository
{
/** @var array<string, Article> */
private array $articles = [];
public function nextIdentity(): ArticleId
{
// XMF ULID generates time-ordered, unique identifiers
return ArticleId::generate();
}
public function find(ArticleId $id): ?Article
{
return $this->articles[$id->toString()] ?? null;
}
/**
* Find articles, sorted by creation time (ULIDs are naturally sortable)
*/
public function findAllSortedByCreation(): array
{
$articles = $this->articles;
// ULIDs can be sorted lexicographically for chronological order
uksort($articles, fn($a, $b) => strcmp($a, $b));
return array_values($articles);
}
public function save(Article $article): void
{
$this->articles[$article->id->toString()] = $article;
}
public function remove(Article $article): void
{
unset($this->articles[$article->id->toString()]);
}
// ... other methods
}
Note: Unlike UUIDs, ULIDs are lexicographically sortable, which means a simple string comparison sorts them chronologically. This is a significant advantage for database indexing and range queries.
Integration Tests¶
<?php
declare(strict_types=1);
namespace Tests\Integration;
final class XoopsArticleRepositoryTest extends DatabaseTestCase
{
private ArticleRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = new XoopsArticleRepository(
$this->getConnection(),
new ArticleMapper(),
new ArticleTagRepository($this->getConnection()),
);
}
public function testSaveAndFind(): void
{
$article = Article::create(
id: $this->repository->nextIdentity(),
title: ArticleTitle::fromString('Test Article'),
content: ArticleContent::fromString(str_repeat('Content ', 20)),
authorId: AuthorId::fromInt(1),
categoryId: CategoryId::fromInt(1),
);
$this->repository->save($article);
$found = $this->repository->find($article->id);
$this->assertNotNull($found);
$this->assertTrue($article->id->equals($found->id));
$this->assertEquals('Test Article', $found->getTitle()->value);
}
}