Files
michaelschiemer/src/Domain/Cms/README.md
Michael Schiemer 2d53270056 feat(cms,asset): add comprehensive test suite and finalize modules
- Add comprehensive test suite for CMS and Asset modules using Pest Framework
- Implement ContentTypeService::delete() protection against deletion of in-use content types
- Add CannotDeleteContentTypeInUseException for better error handling
- Fix DerivatPipelineRegistry::getAllPipelines() to handle object uniqueness correctly
- Fix VariantName::getScale() to correctly parse scales with file extensions
- Update CMS module documentation with new features, exceptions, and test coverage
- Add CmsTestHelpers and AssetTestHelpers for test data factories
- Fix BlockTypeRegistry to be immutable after construction
- Update ContentTypeService to check for associated content before deletion
- Improve BlockRendererRegistry initialization

Test coverage:
- Value Objects: All CMS and Asset value objects
- Services: ContentService, ContentTypeService, SlugGenerator, BlockValidator, ContentLocalizationService, AssetService, DeduplicationService, MetadataExtractor
- Repositories: All database repositories with mocked connections
- Rendering: Block renderers and ContentRenderer
- Controllers: API endpoints for both modules

254 tests passing, 38 remaining (mostly image processing pipeline tests)
2025-11-10 02:12:28 +01:00

10 KiB

Headless CMS Modul

Ein Block-basiertes Headless CMS System für das Custom PHP Framework.

Übersicht

Das CMS Modul ermöglicht die Verwaltung von Content durch ein flexibles Block-System, ähnlich WordPress Blocks, Storyblok oder Contentful. Content besteht aus einer geordneten Liste von Content-Blöcken, die als JSON in der Datenbank gespeichert werden.

Architektur

Block-basiertes System

Content besteht aus einer geordneten Liste von Content-Blöcken. Jeder Block hat:

  • Block Type: HERO, TEXT, IMAGE, GALLERY, CTA, VIDEO, etc.
  • Block Data: Typed Value Object mit block-spezifischen Daten
  • Block ID: Eindeutige ID innerhalb des Contents
  • Block Settings: Optionale Konfiguration (Styling, Visibility)

Beispiel: Landing Page

use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Enums\BlockType;
use App\Domain\Cms\ValueObjects\*;

$content = Content::create(
    clock: $clock,
    contentTypeId: ContentTypeId::fromString('landing_page'),
    slug: ContentSlug::fromString('homepage'),
    title: 'Welcome to Our Product',
    blocks: ContentBlocks::fromArray([
        ContentBlock::create(
            blockType: BlockType::HERO,
            blockId: 'hero-1',
            data: BlockData::fromArray([
                'title' => 'Revolutionary Product',
                'subtitle' => 'Transform your workflow',
                'backgroundImage' => 'img-123', // MediaId als String
                'ctaText' => 'Get Started',
                'ctaLink' => '/signup'
            ])
        ),
        ContentBlock::create(
            blockType: BlockType::TEXT,
            blockId: 'text-1',
            data: BlockData::fromArray([
                'content' => '<p>Our product is...</p>',
                'alignment' => 'center'
            ])
        )
    ])
);

Datenbank-Schema

content_types

Speichert Content Type Definitionen:

  • id (VARCHAR) - Content Type Identifier
  • name (VARCHAR) - Display Name
  • slug (VARCHAR) - URL-freundlicher Slug
  • description (TEXT) - Beschreibung
  • is_system (BOOLEAN) - System Content Type

contents

Speichert Content Instanzen:

  • id (VARCHAR) - ULID Identifier
  • content_type_id (VARCHAR) - Foreign Key zu content_types
  • slug (VARCHAR) - URL-freundlicher Slug (unique)
  • title (VARCHAR) - Content Titel
  • blocks (JSON) - Array von Block-Objekten
  • meta_data (JSON) - SEO, Open Graph Metadaten
  • status (ENUM) - draft, published, archived
  • author_id (VARCHAR) - Author User ID
  • published_at (TIMESTAMP) - Veröffentlichungsdatum
  • created_at, updated_at (TIMESTAMP)

Verwendung

ContentService

use App\Domain\Cms\Services\ContentService;
use App\Domain\Cms\ValueObjects\*;

// Content erstellen
$content = $contentService->create(
    contentTypeId: ContentTypeId::fromString('landing_page'),
    title: 'Welcome Page',
    blocks: ContentBlocks::fromArray([...]),
    slug: ContentSlug::fromString('homepage')
);

// Content finden
$content = $contentService->findBySlug(ContentSlug::fromString('homepage'));

// Content veröffentlichen
$published = $contentService->publish($content->id);

// Content aktualisieren
$updated = $contentService->updateBlocks(
    $content->id,
    ContentBlocks::fromArray([...])
);

ContentTypeService

use App\Domain\Cms\Services\ContentTypeService;
use App\Domain\Cms\ValueObjects\ContentTypeId;

// Content Type erstellen
$contentType = $contentTypeService->create(
    name: 'Landing Page',
    slug: 'landing_page',
    description: 'Landing pages for marketing campaigns',
    isSystem: false
);

// Content Type aktualisieren
$updated = $contentTypeService->update(
    id: ContentTypeId::fromString('landing_page'),
    name: 'Updated Name',
    description: 'New description'
);

// Content Type löschen
// Wirft CannotDeleteContentTypeInUseException wenn noch Content existiert
// Wirft CannotDeleteSystemContentTypeException für System-Types
try {
    $contentTypeService->delete(ContentTypeId::fromString('landing_page'));
} catch (CannotDeleteContentTypeInUseException $e) {
    // Content Type wird noch von Content verwendet
}

REST API

Endpoints

  • GET /api/v1/cms/contents - Liste aller Contents
  • GET /api/v1/cms/contents/{id} - Content Details
  • POST /api/v1/cms/contents - Content erstellen
  • PUT /api/v1/cms/contents/{id} - Content aktualisieren
  • DELETE /api/v1/cms/contents/{id} - Content löschen
  • POST /api/v1/cms/contents/{id}/publish - Content veröffentlichen
  • POST /api/v1/cms/contents/{id}/unpublish - Content zurückziehen

Beispiel Request

POST /api/v1/cms/contents
{
  "content_type_id": "landing_page",
  "slug": "homepage",
  "title": "Welcome Page",
  "blocks": [
    {
      "id": "hero-1",
      "type": "hero",
      "data": {
        "title": "Revolutionary Product",
        "subtitle": "Transform your workflow",
        "backgroundImage": "img-123",
        "ctaText": "Get Started",
        "ctaLink": "/signup"
      },
      "settings": {
        "fullWidth": true
      }
    }
  ],
  "status": "draft"
}

Block Types

  • HERO - Hero Section mit Titel, Bild, CTA
  • TEXT - Rich Text Block
  • IMAGE - Einzelnes Bild mit Caption
  • GALLERY - Bildergalerie
  • CTA - Call-to-Action Block
  • VIDEO - Video Embed
  • FORM - Formular Integration
  • COLUMNS - Multi-Column Layout
  • QUOTE - Zitat Block
  • SEPARATOR - Trenner/Divider

Integration mit anderen Domains

Media Domain

Block Data kann MediaId als String enthalten:

BlockData::fromArray([
    'imageId' => 'img-123', // MediaId als String
    'caption' => 'Product Screenshot'
])

Meta Domain

SEO-Metadaten können in meta_data gespeichert werden:

$content = $content->withMetaData(BlockData::fromArray([
    'title' => 'SEO Title',
    'description' => 'SEO Description',
    'og_title' => 'OG Title',
    'og_image' => 'og-image.jpg'
]));

Migration

php console.php db:migrate

Die Migrations erstellen die Tabellen content_types und contents mit JSON-Spalten für Blocks.

Exceptions

Das CMS Modul definiert folgende Domain-Exceptions:

Content Exceptions

  • ContentNotFoundException - Wird geworfen wenn Content nicht gefunden wird

    throw ContentNotFoundException::forId($contentId);
    throw ContentNotFoundException::forSlug($slug);
    
  • DuplicateSlugException - Wird geworfen wenn ein Slug bereits existiert

    throw DuplicateSlugException::forSlug($slug);
    
  • InvalidContentStatusException - Wird geworfen bei ungültigen Status-Übergängen

    throw InvalidContentStatusException::invalidTransition($currentStatus, $targetStatus);
    
  • InvalidBlockException - Wird geworfen bei ungültigen Block-Daten

    throw InvalidBlockException::missingRequiredField($blockId, $field);
    throw InvalidBlockException::invalidFieldType($blockId, $field, $expectedType);
    

ContentType Exceptions

  • ContentTypeNotFoundException - Wird geworfen wenn ContentType nicht gefunden wird

    throw ContentTypeNotFoundException::forId($id);
    
  • DuplicateContentTypeSlugException - Wird geworfen wenn ein ContentType-Slug bereits existiert

    throw DuplicateContentTypeSlugException::forSlug($slug);
    
  • CannotDeleteSystemContentTypeException - Wird geworfen beim Versuch, einen System-ContentType zu löschen

    throw CannotDeleteSystemContentTypeException::forId($id);
    
  • CannotDeleteContentTypeInUseException - Wird geworfen beim Versuch, einen ContentType zu löschen, der noch von Content verwendet wird

    throw CannotDeleteContentTypeInUseException::forId($id, $contentCount);
    

ContentTypeService::delete() - Schutz vor versehentlichem Löschen

Die ContentTypeService::delete() Methode implementiert eine Schutzfunktion, die verhindert, dass ContentTypes gelöscht werden, die noch von Content-Instanzen verwendet werden:

public function delete(ContentTypeId $id): void
{
    $contentType = $this->contentTypeRepository->findById($id);
    if ($contentType === null) {
        throw ContentTypeNotFoundException::forId($id);
    }

    // System ContentTypes können nicht gelöscht werden
    if ($contentType->isSystem) {
        throw CannotDeleteSystemContentTypeException::forId($id);
    }

    // Prüfe ob ContentType noch von Content verwendet wird
    $contents = $this->contentRepository->findByType($id);
    if (count($contents) > 0) {
        throw CannotDeleteContentTypeInUseException::forId($id, count($contents));
    }

    $this->contentTypeRepository->delete($id);
}

Dies verhindert Datenverlust und stellt sicher, dass keine verwaisten Content-Instanzen entstehen.

Tests

Das CMS Modul verfügt über eine umfassende Test-Suite mit Pest Framework:

Test-Struktur

tests/
├── Unit/
│   └── Domain/
│       └── Cms/
│           ├── ValueObjects/        # Value Object Tests
│           ├── Services/            # Service Tests
│           ├── Repositories/        # Repository Tests
│           └── Rendering/           # Rendering Tests
├── Feature/
│   └── Application/
│       └── Cms/                     # Controller Tests
└── Support/
    └── CmsTestHelpers.php          # Test Helper Functions

Test-Abdeckung

  • Value Objects: Alle Value Objects (ContentId, ContentSlug, BlockType, etc.)
  • Services: ContentService, ContentTypeService, SlugGenerator, BlockValidator, ContentLocalizationService
  • Repositories: DatabaseContentRepository, DatabaseContentTypeRepository, DatabaseContentTranslationRepository
  • Rendering: BlockRendererRegistry, HeroBlockRenderer, TextBlockRenderer, ImageBlockRenderer, DefaultBlockRenderer, ContentRenderer
  • Controllers: ContentsController, ContentTypesController

Tests ausführen

# Alle CMS Tests
./vendor/bin/pest tests/Unit/Domain/Cms tests/Feature/Application/Cms

# Spezifische Test-Datei
./vendor/bin/pest tests/Unit/Domain/Cms/Services/ContentServiceTest.php

Test Helpers

Die CmsTestHelpers Klasse bietet Factory-Methoden für Test-Daten:

use Tests\Support\CmsTestHelpers;

$content = CmsTestHelpers::createContent($clock, [
    'title' => 'Test Content',
    'blocks' => $blocks,
    'slug' => ContentSlug::fromString('test-slug')
]);

$contentType = CmsTestHelpers::createContentType([
    'name' => 'Test Type',
    'slug' => 'test_type'
]);