# 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 ```php 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' => '

Our product is...

', '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 ```php 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 ```php 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 ```json 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: ```php BlockData::fromArray([ 'imageId' => 'img-123', // MediaId als String 'caption' => 'Product Screenshot' ]) ``` ### Meta Domain SEO-Metadaten können in `meta_data` gespeichert werden: ```php $content = $content->withMetaData(BlockData::fromArray([ 'title' => 'SEO Title', 'description' => 'SEO Description', 'og_title' => 'OG Title', 'og_image' => 'og-image.jpg' ])); ``` ## Migration ```bash 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 ```php throw ContentNotFoundException::forId($contentId); throw ContentNotFoundException::forSlug($slug); ``` - **`DuplicateSlugException`** - Wird geworfen wenn ein Slug bereits existiert ```php throw DuplicateSlugException::forSlug($slug); ``` - **`InvalidContentStatusException`** - Wird geworfen bei ungültigen Status-Übergängen ```php throw InvalidContentStatusException::invalidTransition($currentStatus, $targetStatus); ``` - **`InvalidBlockException`** - Wird geworfen bei ungültigen Block-Daten ```php throw InvalidBlockException::missingRequiredField($blockId, $field); throw InvalidBlockException::invalidFieldType($blockId, $field, $expectedType); ``` ### ContentType Exceptions - **`ContentTypeNotFoundException`** - Wird geworfen wenn ContentType nicht gefunden wird ```php throw ContentTypeNotFoundException::forId($id); ``` - **`DuplicateContentTypeSlugException`** - Wird geworfen wenn ein ContentType-Slug bereits existiert ```php throw DuplicateContentTypeSlugException::forSlug($slug); ``` - **`CannotDeleteSystemContentTypeException`** - Wird geworfen beim Versuch, einen System-ContentType zu löschen ```php throw CannotDeleteSystemContentTypeException::forId($id); ``` - **`CannotDeleteContentTypeInUseException`** - Wird geworfen beim Versuch, einen ContentType zu löschen, der noch von Content verwendet wird ```php 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: ```php 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 ```bash # 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: ```php 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' ]); ```