Skip to content

📚 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:

  1. Wrapping your existing XoopsPersistableObjectHandler in a Repository class
  2. Using docblock annotations instead of native type declarations
  3. 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);
    }
}


repository #data-access #persistence #patterns #goldstandard