- 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)
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 Identifiername(VARCHAR) - Display Nameslug(VARCHAR) - URL-freundlicher Slugdescription(TEXT) - Beschreibungis_system(BOOLEAN) - System Content Type
contents
Speichert Content Instanzen:
id(VARCHAR) - ULID Identifiercontent_type_id(VARCHAR) - Foreign Key zu content_typesslug(VARCHAR) - URL-freundlicher Slug (unique)title(VARCHAR) - Content Titelblocks(JSON) - Array von Block-Objektenmeta_data(JSON) - SEO, Open Graph Metadatenstatus(ENUM) - draft, published, archivedauthor_id(VARCHAR) - Author User IDpublished_at(TIMESTAMP) - Veröffentlichungsdatumcreated_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 ContentsGET /api/v1/cms/contents/{id}- Content DetailsPOST /api/v1/cms/contents- Content erstellenPUT /api/v1/cms/contents/{id}- Content aktualisierenDELETE /api/v1/cms/contents/{id}- Content löschenPOST /api/v1/cms/contents/{id}/publish- Content veröffentlichenPOST /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, CTATEXT- Rich Text BlockIMAGE- Einzelnes Bild mit CaptionGALLERY- BildergalerieCTA- Call-to-Action BlockVIDEO- Video EmbedFORM- Formular IntegrationCOLUMNS- Multi-Column LayoutQUOTE- Zitat BlockSEPARATOR- 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 wirdthrow ContentNotFoundException::forId($contentId); throw ContentNotFoundException::forSlug($slug); -
DuplicateSlugException- Wird geworfen wenn ein Slug bereits existiertthrow DuplicateSlugException::forSlug($slug); -
InvalidContentStatusException- Wird geworfen bei ungültigen Status-Übergängenthrow InvalidContentStatusException::invalidTransition($currentStatus, $targetStatus); -
InvalidBlockException- Wird geworfen bei ungültigen Block-Datenthrow InvalidBlockException::missingRequiredField($blockId, $field); throw InvalidBlockException::invalidFieldType($blockId, $field, $expectedType);
ContentType Exceptions
-
ContentTypeNotFoundException- Wird geworfen wenn ContentType nicht gefunden wirdthrow ContentTypeNotFoundException::forId($id); -
DuplicateContentTypeSlugException- Wird geworfen wenn ein ContentType-Slug bereits existiertthrow DuplicateContentTypeSlugException::forSlug($slug); -
CannotDeleteSystemContentTypeException- Wird geworfen beim Versuch, einen System-ContentType zu löschenthrow CannotDeleteSystemContentTypeException::forId($id); -
CannotDeleteContentTypeInUseException- Wird geworfen beim Versuch, einen ContentType zu löschen, der noch von Content verwendet wirdthrow 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'
]);