b2in/packages/flux-cms/ARCHITECTURE.md
2026-04-10 17:18:17 +02:00

20 KiB

Flux CMS — Technische Architektur

Datenfluss

┌───────────────────────────────────────────────────────┐
│                    Blade Template                      │
│  {{ cms('welcome.hero.heading') }}                    │
│  {{ cms_media_url('welcome.hero.image', 'hero') }}    │
│  {{ media_url('file.webp', 'card') }}                 │
└────────────────────┬──────────────────────────────────┘
                     │
         ┌───────────▼───────────────┐
         │   Helper-Funktionen       │
         │   cms(), cms_media_url(), │
         │   media_url(), tcms()     │
         │   (app/helpers.php)       │
         └───────────┬───────────────┘
                     │
         ┌───────────▼───────────────┐
         │   CmsContentService       │ Singleton via ServiceProvider
         │   get($key, $replace)     │
         └───────────┬───────────────┘
                     │
           ┌─────────▼─────────┐
           │   Cache Layer     │ Cache::remember() pro Gruppe
           │   (config TTL)    │ Auto-Invalidierung bei Admin-Save
           └─────────┬─────────┘
                     │
           ┌─────────▼─────────┐     ┌──────────────────┐
           │  CmsContent Model │     │  __($key)        │
           │  (DB Lookup)      ├─NO──►  Fallback zu     │
           │  Übersetzbar      │     │  Laravel Lang     │
           └───────────────────┘     └──────────────────┘

Medien-Datenfluss

┌────────────────────────────────────────────────────────────┐
│  Blade Template                                             │
│  {{ media_url('datei.webp', 'hero') }}                     │
└─────────────────────┬──────────────────────────────────────┘
                      │
          ┌───────────▼────────────────┐
          │  media_url() Helper        │
          │  1. In-Memory-Cache prüfen │
          │  2. CmsMedia::where(...)   │
          │  3. Conversion vorhanden?  │
          │  4. Fallback: asset(...)   │
          └───────────┬────────────────┘
                      │
         ┌────────────▼────────────────┐
         │  CmsMedia Model             │
         │  - getUrl()                 │
         │  - getConversionUrl($name)  │
         │  - hasThumbnail()           │
         │  - isPdf() / isImage()      │
         └────────────┬────────────────┘
                      │
         ┌────────────▼────────────────┐
         │  Storage (public Disk)      │
         │  cms/media/originals/       │
         │  cms/media/conversions/     │
         │  cms/media/thumbnails/      │
         └─────────────────────────────┘

media_url() im Detail

function media_url(?string $filename, string $profile = ''): string
{
    // 1. Null/Leer → leerer String
    // 2. In-Memory-Cache: $resolved[$cacheKey]
    // 3. CmsMedia::where('filename', $filename)->first()
    // 4. Wenn $profile und Conversion existiert → getConversionUrl($profile)
    // 5. Ohne Profil → getUrl() (Original)
    // 6. Fallback: asset('assets/images/' . $filename)
}

cms_media_url() im Detail

function cms_media_url(string $key, string $profile = ''): string
{
    // 1. cms($key) → Dateiname aus CmsContent
    // 2. media_url($filename, $profile)
}

Key-Resolution

Der cms()-Aufruf cms('welcome.hero.heading') wird so aufgelöst:

  1. Parsing: welcomegroup, hero.headingkey
  2. Cache-Lookup: flux_cms.content.welcome (alle Einträge der Gruppe)
  3. Key-Match: firstWhere('key', 'hero.heading')
  4. Übersetzung: getTranslation('value', app()->getLocale()) mit Fallback auf config('app.fallback_locale')
  5. Platzhalter: :highlight → Ersetzung aus $replace Array
  6. Fallback: Wenn nichts in DB → __('welcome.hero.heading', $replace, $locale)

Datenbankschema

flux_cms_contents

CREATE TABLE flux_cms_contents (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `group`     VARCHAR(255) NOT NULL,
    `key`       VARCHAR(255) NOT NULL,
    type        VARCHAR(255) DEFAULT 'text',   -- text, html, image, json, link
    value       JSON NOT NULL,                 -- {"de": "...", "en": "..."}
    `order`     INT DEFAULT 0,
    created_at  TIMESTAMP NULL,
    updated_at  TIMESTAMP NULL,
    UNIQUE INDEX (group, key)
);

flux_cms_media

CREATE TABLE flux_cms_media (
    id               BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    filename         VARCHAR(255) NOT NULL,        -- Original-Dateiname
    path             VARCHAR(512) NOT NULL,         -- Storage-Pfad
    type             VARCHAR(50) DEFAULT 'image',   -- image, pdf, document
    mime_type        VARCHAR(255) NULL,
    file_size        BIGINT UNSIGNED NULL,
    original_width   INT UNSIGNED NULL,
    original_height  INT UNSIGNED NULL,
    disk             VARCHAR(50) DEFAULT 'public',
    collection       VARCHAR(255) NULL,             -- Optionale Sammlung
    conversions      JSON NULL,                     -- {"hero": "path/...", "card": "path/..."}
    title            JSON NULL,                     -- Übersetzbar
    alt_text         JSON NULL,                     -- Übersetzbar
    is_published     BOOLEAN DEFAULT TRUE,
    created_at       TIMESTAMP NULL,
    updated_at       TIMESTAMP NULL,
    INDEX (filename),
    INDEX (type),
    INDEX (collection)
);

flux_cms_downloads

CREATE TABLE flux_cms_downloads (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title         JSON NOT NULL,                   -- Übersetzbar
    description   JSON NULL,                       -- Übersetzbar
    category      VARCHAR(255) NOT NULL,           -- case_study, capability, success_story
    file_path     JSON NOT NULL,                   -- Übersetzbar: {"de": "file-de.pdf", "en": "file-en.pdf"}
    thumbnail     VARCHAR(255) NULL,               -- CmsMedia-Dateiname
    icon          VARCHAR(255) NULL,               -- Heroicon-Name
    sub_category  VARCHAR(255) NULL,               -- Detail-Kategorie
    type_label    JSON NULL,                       -- Übersetzbar
    alt           JSON NULL,                       -- Übersetzbar: Alt-Text
    open_text     JSON NULL,                       -- Übersetzbar: Button-Text
    download_text JSON NULL,                       -- Übersetzbar: Button-Text
    highlights    JSON NULL,                       -- [{"value": "100%", "label": "..."}]
    checkpoints   JSON NULL,                       -- [{"value": "..."}]
    is_published  BOOLEAN DEFAULT TRUE,
    `order`       INT DEFAULT 0,
    created_at    TIMESTAMP NULL,
    updated_at    TIMESTAMP NULL
);

flux_cms_news_items

CREATE TABLE flux_cms_news_items (
    id                BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    icon              VARCHAR(255) NULL,
    text              JSON NULL,                   -- Ticker-Text (übersetzbar)
    title             JSON NULL,
    excerpt           JSON NULL,
    content           JSON NULL,                   -- Voller HTML-Body
    image             VARCHAR(255) NULL,            -- CmsMedia-Dateiname
    date              DATE NULL,
    author            VARCHAR(255) NULL,
    link              VARCHAR(255) NULL,
    pdf_path          VARCHAR(255) NULL,            -- CmsMedia-Dateiname (PDF)
    pdf_open_text     JSON NULL,
    pdf_download_text JSON NULL,
    is_published      BOOLEAN DEFAULT TRUE,
    `order`           INT DEFAULT 0,
    created_at        TIMESTAMP NULL,
    updated_at        TIMESTAMP NULL
);

flux_cms_industries

CREATE TABLE flux_cms_industries (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name          JSON NOT NULL,                   -- Übersetzbar
    is_published  BOOLEAN DEFAULT TRUE,
    `order`       INT DEFAULT 0,
    created_at    TIMESTAMP NULL,
    updated_at    TIMESTAMP NULL
);

flux_cms_faqs

CREATE TABLE flux_cms_faqs (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    category      VARCHAR(255) NOT NULL,
    question      JSON NOT NULL,
    answer        JSON NOT NULL,
    help          JSON NULL,
    is_published  BOOLEAN DEFAULT TRUE,
    `order`       INT DEFAULT 0,
    created_at    TIMESTAMP NULL,
    updated_at    TIMESTAMP NULL
);

flux_cms_linkedin_posts

CREATE TABLE flux_cms_linkedin_posts (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    linkedin_id   VARCHAR(255) NULL UNIQUE,
    title         JSON NULL,
    excerpt       JSON NULL,
    content       JSON NULL,
    author        VARCHAR(255) NULL,
    date          DATE NULL,
    url           VARCHAR(255) NULL,
    image         VARCHAR(255) NULL,                -- CmsMedia-Dateiname
    tags          JSON NULL,
    source        VARCHAR(255) DEFAULT 'manual',
    is_published  BOOLEAN DEFAULT TRUE,
    `order`       INT DEFAULT 0,
    created_at    TIMESTAMP NULL,
    updated_at    TIMESTAMP NULL
);

flux_cms_search_index

CREATE TABLE flux_cms_search_index (
    id                        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    item_id                   VARCHAR(255) NOT NULL UNIQUE,  -- z.B. 'home', 'leistungen'
    route                     VARCHAR(255) NOT NULL,          -- Named Route
    route_params              JSON NULL,                      -- Route-Parameter
    category                  JSON NULL,                      -- Übersetzbar: Kategorie
    title_key                 VARCHAR(255) NULL,              -- CMS-Key für Titel
    title_fallback            JSON NULL,                      -- Übersetzbar: Fallback-Titel
    description_key           VARCHAR(255) NULL,              -- CMS-Key für Beschreibung
    description_fallback_key  VARCHAR(255) NULL,              -- Sekundärer CMS-Key
    description_fallback_text JSON NULL,                      -- Übersetzbar: Statischer Text
    keywords                  JSON NULL,                      -- Übersetzbar: String-Array
    is_published              BOOLEAN DEFAULT TRUE,
    `order`                   INT DEFAULT 0,
    created_at                TIMESTAMP NULL,
    updated_at                TIMESTAMP NULL,
    INDEX (item_id)
);

Admin UI — Komponenten-Architektur

Alle Admin-Views sind Livewire Volt Functional Components:

<?php
use function Livewire\Volt\{state, computed, on, layout};
layout('components.layouts.cms');

state([...]);
$items = computed(fn() => ...);
$save = function () { ... };
?>

<div>
    {{-- Blade/Flux UI Template --}}
</div>

Ausnahmen: Class-based Livewire

File-Uploads und modale Auswahl benötigen class-based Livewire-Komponenten:

Komponente Grund
MediaLibraryUploader WithFileUploads Trait für Multi-File Upload
MediaPicker WithFileUploads Trait für Quick-Upload + Event-System

MediaPicker — Event-Architektur

┌───────────────────────────────────────────┐
│  Admin-View (Volt Functional)             │
│  state: $imageMediaId, $image             │
│                                           │
│  on('media-selected', fn($field, ...))    │
│      → setzt MediaId + Dateiname          │
│                                           │
│  <livewire:admin.cms.media-picker         │
│      :value="$imageMediaId"               │
│      field="news_image"                   │
│      type="image"                         │
│      profile="news" />                    │
└──────────────┬────────────────────────────┘
               │ dispatches 'media-selected'
┌──────────────▼────────────────────────────┐
│  MediaPicker (Class-based)                │
│  - Modal mit Medienauswahl                │
│  - Filterung nach Typ                     │
│  - Quick-Upload                           │
│  - $this->dispatch('media-selected', ...) │
└───────────────────────────────────────────┘

Medien-Speicherung in Modellen

Alle Medien werden über ihren CmsMedia-Dateiname referenziert:

CmsContent (type=image)  → value = "keyvisual.webp"
CmsNewsItem              → image = "news-header.webp", pdf_path = "report.pdf"
CmsDownload              → thumbnail = "case-study.webp", file_path = {"de": "cs-de.pdf", "en": "cs-en.pdf"}
CmsLinkedinPost          → image = "linkedin-post.webp"
Team (JSON in CmsContent) → image = "person.jpg"

Die Auflösung zum tatsächlichen URL erfolgt immer über media_url($filename, $profile).

toFrontendArray() Pattern

Für die Frontend-Ausgabe bieten CmsNewsItem und CmsDownload eine toFrontendArray() Methode:

// CmsDownload::toFrontendArray()
public function toFrontendArray(): array
{
    return [
        'title'       => $this->getTranslation('title', app()->getLocale()),
        'description' => $this->getTranslation('description', app()->getLocale()),
        'image'       => media_url($this->thumbnail ?? '', 'card'),
        'pdf_path'    => media_url(
            $this->getTranslation('file_path', app()->getLocale()) ?? ''
        ),
        'highlights'  => $this->highlights ?? [],
        'checkpoints' => $this->checkpoints ?? [],
        'icon'        => $this->icon,
        // ... weitere Felder
    ];
}

Frontend-Verwendung:

@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $download)
    <x-download-article-card :article="$download->toFrontendArray()" :index="$loop->index" />
@endforeach

Bildoptimierung — MediaConversionService

┌─────────────────────────────────────────────────────┐
│  MediaConversionService (Singleton)                  │
│                                                      │
│  Abhängigkeiten: intervention/image v3               │
│                                                      │
│  storeUploadedFile($file, $collection)               │
│  ├── Speichert Original in cms/media/originals/      │
│  ├── Erstellt CmsMedia-Eintrag (mit Dimensionen)     │
│  ├── Generiert Thumbnail automatisch                 │
│  └── Gibt CmsMedia zurück                            │
│                                                      │
│  convert($media, $profileName)                       │
│  ├── Liest Profil aus Config                         │
│  ├── Resize (cover fit) via intervention/image       │
│  ├── Konvertiert Format (webp, jpg, png)             │
│  ├── Setzt Qualität                                  │
│  ├── Speichert in cms/media/conversions/             │
│  └── Aktualisiert $media->conversions JSON           │
│                                                      │
│  generateThumbnail($media)                           │
│  ├── 200x200 Cover, WebP, Q70                        │
│  ├── Speichert in cms/media/thumbnails/              │
│  └── Setzt $media->conversions['thumb']              │
│                                                      │
│  generateAllConversions($media)                      │
│  └── Iteriert alle Profiles aus Config               │
└─────────────────────────────────────────────────────┘

Conversion-Profile

Konfigurierbar in config/flux-cms.php:

'media' => [
    'max_upload_size' => 20480,    // KB
    'allowed_types' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'pdf', 'doc', 'docx'],
    'storage_disk' => 'public',
    'profiles' => [
        'hero'      => ['width' => 1920, 'height' => 800,  'format' => 'webp', 'quality' => 85],
        'card'      => ['width' => 768,  'height' => 512,  'format' => 'webp', 'quality' => 80],
        'thumbnail' => ['width' => 400,  'height' => 300,  'format' => 'webp', 'quality' => 75],
        'avatar'    => ['width' => 400,  'height' => 400,  'format' => 'webp', 'quality' => 80],
        'news'      => ['width' => 1200, 'height' => 630,  'format' => 'webp', 'quality' => 80],
        'thumb'     => ['width' => 200,  'height' => 200,  'format' => 'webp', 'quality' => 70],
    ],
],

Content-Typ-Erkennung (Seeder)

Bedingung Typ
PHP-Array (assoziativ → flatten; indexed → direkt) json
Enthält HTML-Tags (<p>, <br>, <strong>, etc.) html
Dateiendung .jpg, .png, .svg, etc. image
Dateiendung .pdf, .doc, etc. link
Alles andere text

HTML-Bereinigung (Seeder)

font-semibold → <strong>

<!-- Vorher (aus Lang-Datei) -->
<span class="font-semibold">Wichtig</span>

<!-- Nachher (in DB) -->
<strong>Wichtig</strong>

text-gradient-premium → :highlight Pattern

// Vorher:
'heading' => 'Wie unsere Ingenieurstrategen <span class="text-gradient-premium">die drei Säulen sichern</span>',

// Nachher:
'heading' => ':highlight die drei Säulen sichern',
'heading_highlight' => 'Wie unsere Ingenieurstrategen',

Caching-Strategie

  • Content-Cache: Pro Gruppe gecached (z.B. flux_cms.content.welcome)
  • media_url() Cache: In-Memory-Cache pro Request (statische Variable)
  • TTL: Konfigurierbar (flux-cms.cache.ttl, Standard: 3600s)
  • Invalidierung: Automatisch bei Save im Admin via CmsContentService::clearCache($group)
  • Cache-Store: Konfigurierbar (flux-cms.cache.store)
  • Seeder: Ruft clearCache() nach dem Seeding auf

Sortierung

  • Inhalte werden in der Reihenfolge der lang/-Datei gespeichert (chronologisch)
  • order-Feld wird beim Seeding sequentiell vergeben
  • Industries, Downloads, News haben Up/Down-Buttons + numerisches Order-Feld
  • Downloads können zusätzlich nach Kategorie gefiltert werden

Mehrsprachigkeit

Alle Modelle nutzen Spatie\Translatable\HasTranslations:

$content->getTranslation('value', 'de');
$content->setTranslation('value', 'en', 'New value');
$content->save();

Besonderheit bei CmsDownload:

  • file_path ist ebenfalls übersetzbar (separates PDF pro Sprache)
  • toFrontendArray() löst automatisch die aktuelle Locale auf

Im Admin kann zwischen Sprachen gewechselt werden via switchLocale().

Infrastruktur-Hinweise

HTTPS / Proxy

  • trustProxies(at: '*') in bootstrap/app.php für korrekte URL-Generierung
  • URL::forceScheme('https') in AppServiceProvider

File-Uploads

  • flux:file-upload Komponente (Flux UI) für Livewire-Uploads
  • Maximale Upload-Größe konfigurierbar in config/flux-cms.php
  • Erlaubte MIME-Types in Validation-Rules der Upload-Komponenten