20 KiB
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:
- Parsing:
welcome→group,hero.heading→key - Cache-Lookup:
flux_cms.content.welcome(alle Einträge der Gruppe) - Key-Match:
firstWhere('key', 'hero.heading') - Übersetzung:
getTranslation('value', app()->getLocale())mit Fallback aufconfig('app.fallback_locale') - Platzhalter:
:highlight→ Ersetzung aus$replaceArray - 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_pathist 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: '*')inbootstrap/app.phpfür korrekte URL-GenerierungURL::forceScheme('https')inAppServiceProvider
File-Uploads
flux:file-uploadKomponente (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