SEO & Structured Data
Alle öffentlichen Seiten sind mit Open Graph Tags, Twitter Cards, JSON-LD Structured Data und Canonical URLs ausgestattet. Ergänzend: Google Search Console API Integration und Core Web Vitals Monitoring.
Architektur
graph TD
A["Public Layout<br>(public.blade.php)"] --> B["OG Tags"]
A --> C["Twitter Cards"]
A --> D["Canonical URL"]
A --> E["JSON-LD"]
A --> F["Web Vitals JS"]
F -->|sendBeacon| G["POST /api/web-vitals"]
G --> H["WebVitalsService"]
H --> I["web_vitals_metrics"]
J["seo:sync-search-console"] -->|JWT Auth| K["Google Search Console API"]
K --> L["search_console_metrics"]
M["Admin SEO Dashboard"] --> I
M --> L
Layout-Props (public.blade.php)
Alle SEO-relevanten Daten werden als Props an das <x-layouts.public> Layout übergeben.
Location: resources/views/components/layouts/public.blade.php
| Prop | Typ | Default | Beschreibung |
|---|---|---|---|
ogTitle | string | null | OG Title (Fallback: $title) |
ogDescription | string | null | OG Description (Fallback: $metaDescription) |
ogImage | string | null | OG Image URL (Fallback: asset('assets/og-default.png')) |
ogType | string | 'website' | OG Type (website, profile, etc.) |
ogUrl | string | null | OG URL (Fallback: url()->current()) |
twitterCard | string | 'summary_large_image' | Twitter Card Type |
canonicalUrl | string | null | Canonical URL |
jsonLd | array | null | JSON-LD Schema (einzeln oder Array von Arrays) |
metaDescription | string | null | Meta Description |
metaRobots | string | null | Robots-Direktive (noindex, nofollow) |
Fallback-Logik
og:title→$ogTitle ?? $title ?? nullog:description→$ogDescription ?? $metaDescription ?? nullog:image→$ogImage ?? asset('assets/og-default.png')(immer gesetzt)twitter:title/description→ gleiche Fallbacks wie OG
Seiten-spezifische SEO-Konfiguration
| Seite | Component/Controller | ogType | ogImage | twitterCard | JSON-LD | canonicalUrl |
|---|---|---|---|---|---|---|
| Homepage | welcome.blade.php | website | og-default.png | summary_large_image | WebSite + Organization | url('/') |
| Explorer Index | PublicExplorer\Index | website | og-default.png | summary_large_image | CollectionPage | route('public-explorer.index') |
| Profil Detail | PublicExplorer\Show | profile | thumbnail_url | summary | ProfilePage + BreadcrumbList | route('public-explorer.show', ...) |
| Category Landing | PublicExplorer\CategoryLanding | website | og-default.png | summary_large_image | CollectionPage + BreadcrumbList | route('public-explorer.category', ...) |
| Tag Landing | PublicExplorer\TagLanding | website | og-default.png | summary_large_image | CollectionPage + BreadcrumbList | route('public-explorer.tag', ...) |
| CMS-Seiten | PageController | website | og-default.png | summary_large_image | WebPage | url($page->url) |
| Login | Auth View | – | – | – | – | noindex, nofollow |
| Register | Auth View | – | – | – | – | noindex, nofollow |
JSON-LD Schema-Typen
Homepage: WebSite + Organization
[
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Postbox",
"url": "https://app.postbox.so",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://app.postbox.so/explorer?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
},
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Postbox",
"url": "https://app.postbox.so",
"logo": "https://app.postbox.so/assets/logo.svg"
}
]
Location: resources/views/welcome.blade.php
Profil Detail: ProfilePage + BreadcrumbList
ProfilePage mit Person-Entity und mehrstufiger BreadcrumbList (Explorer → Kategorie → Profil).
Location: app/Livewire/PublicExplorer/Show.php → buildJsonLd()
Landing Pages: CollectionPage + BreadcrumbList
Category- und Tag-Landing-Pages nutzen CollectionPage mit BreadcrumbList (Explorer → Category/Tag).
Location: app/Livewire/PublicExplorer/CategoryLanding.php, TagLanding.php
CMS-Seiten: WebPage
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Impressum",
"description": "...",
"url": "https://app.postbox.so/impressum_p1",
"datePublished": "2026-01-01T00:00:00+00:00",
"dateModified": "2026-02-20T15:30:00+00:00"
}
Location: resources/views/pages/show.blade.php
OG-Default-Image
Statisches Fallback-Bild (1200×630px) für Seiten ohne spezifisches OG-Image.
| Property | Wert |
|---|---|
| Datei | public/assets/og-default.png |
| Größe | 1200×630px |
| Format | PNG |
| Inhalt | Postbox-Branding |
Profil-Detail-Seiten nutzen das Profilbild ($profile->thumbnail_url) als og:image. Alle anderen Seiten fallen auf og-default.png zurück.
Core Web Vitals Monitoring
Frontend (Browser)
Web-Vitals JS-Library (v4, CDN) erfasst automatisch auf allen öffentlichen Seiten — also jede Seite, die das <x-layouts.public> Layout nutzt:
| Seitentyp | Beispiel-URLs |
|---|---|
| Startseite | / |
| Explorer Index | /explorer |
| Profil Detail | /explorer/{profile} |
| Kategorie Landing | /explorer/category/{slug} |
| Tag Landing | /explorer/tags/{tag} |
| CMS-Seiten | /{page-slug} (Impressum, Datenschutz etc.) |
| Kontaktformular | /kontakt |
Nicht getrackt: Admin-Seiten, eingeloggte Bereiche (Dashboard, Watchers etc.) — nur das Public Layout trackt.
| Metrik | Beschreibung | Google-Schwellwert (gut) |
|---|---|---|
| LCP | Largest Contentful Paint | ≤ 2500ms |
| FCP | First Contentful Paint | ≤ 1800ms |
| INP | Interaction to Next Paint | ≤ 200ms |
| CLS | Cumulative Layout Shift | ≤ 0.1 |
| TTFB | Time to First Byte | ≤ 800ms |
Transport via navigator.sendBeacon() (bevorzugt) oder fetch() mit keepalive.
Location: resources/views/components/layouts/public.blade.php
API Endpoint
POST /api/web-vitals
Content-Type: application/json
{
"name": "LCP",
"value": 2500.0,
"url": "https://app.postbox.so/explorer/creator_12345",
"device": "mobile",
"page_type": "profile"
}
| Feld | Typ | Validierung |
|---|---|---|
name | string | required, in: LCP, FID, CLS, INP, TTFB, FCP |
value | numeric | required, min: 0, max: 999999 |
url | string | required, url, max: 500 |
device | string | nullable, in: mobile, desktop, tablet |
page_type | string | nullable, max: 30 |
Response: {"ok": true} (200) oder 204 wenn Feature deaktiviert.
Rate Limit: 30 Requests/Minute pro IP (konfigurierbar via WEB_VITALS_RATE_LIMIT). Das ist ein eigener Schutz gegen Missbrauch — kein externes Google-Limit. Pro Seitenaufruf werden maximal 5 Metriken gesendet (LCP, FCP, INP, CLS, TTFB), d.h. ein Besucher müsste 6 Seiten/Minute laden, um das Limit zu erreichen.
Location: app/Http/Controllers/Api/WebVitalsController.php
V2: Gerätetyp- und Seitenbereich-Erkennung
Das Frontend erkennt automatisch Gerätetyp und Seitenbereich:
Gerätetyp (User-Agent-basiert):
| device | Erkennung |
|---|---|
mobile | /Mobi|Android/i im User-Agent |
tablet | /Tablet|iPad/i im User-Agent |
desktop | Alles andere |
Seitenbereich (URL-Pathname-basiert):
| page_type | Pfad-Muster |
|---|---|
homepage | / (exakt) |
explore | /explorer* |
tops_flops | /tops-flops* |
profile | */explorer/* (Detail) |
watchers | /watchers* |
dashboard | /dashboard* |
tags | /tags* |
settings | /settings* |
admin | /admin* |
cms | Alles andere bekannte |
other | Fallback |
Location: resources/views/components/layouts/public.blade.php (JS-Funktionen getDeviceType(), getPageType())
Aggregation (WebVitalsService)
Metriken werden pro URL + Metrik + Tag + Gerätetyp aggregiert via EMA (Exponential Moving Average):
- p50/p75/p90: EMA-basierte Perzentil-Approximation (kein Median-Sort nötig)
- avg_value: Gewichteter Durchschnitt
- sample_count: Inkrementiert pro Messung
- URL-Normalisierung: Query-Parameter werden entfernt
- Validierung: URLs > 500 Zeichen und ungültige Metriken werden ignoriert
V2 Service-Methoden:
| Methode | Beschreibung |
|---|---|
record() | Einzelne Messung speichern (+ deviceType, pageType) |
getSummary() | Aggregierte p75-Werte pro Metrik (mit Device/PageType-Filter) |
getComparison() | Zeitraum-Vergleich: aktuell vs. vorherige Periode |
getDeviceBreakdown() | p75/Rating pro Gerät × Metrik |
getPageTypeBreakdown() | p75/Rating pro Seitenbereich × Metrik |
getTrend() | Tägliche Trend-Daten für Charts |
getTopUrlsByMetric() | Top-N URLs mit schlechtestem p75 (≥3 Samples) |
getRating() | Google-Schwellwert-basiertes Rating |
Location: app/Services/Seo/WebVitalsService.php
Datenbank
Tabelle: web_vitals_metrics
| Feld | Typ | Beschreibung |
|---|---|---|
date | date | Aggregations-Tag |
url | string | Normalisierte URL (ohne Query-Params) |
metric_name | string | LCP, FCP, INP, CLS, TTFB |
device_type | varchar(10) | mobile, desktop, tablet, unknown |
page_type | varchar(30) | homepage, explore, profile, admin etc. |
sample_count | integer | Anzahl Messungen |
p50 | decimal | 50. Perzentil (Median) |
p75 | decimal | 75. Perzentil (Google-Referenzwert) |
p90 | decimal | 90. Perzentil |
avg_value | decimal | Durchschnitt |
Unique: (date, url, metric_name, device_type)
Index: (date, device_type, page_type) — für schnelle Breakdown-Queries
Model: app/Models/WebVitalMetric.php (explizit $table = 'web_vitals_metrics')
Google Search Console Integration
Aktivierung (Schritt für Schritt)
1. Service Account E-Mail finden
Die App nutzt denselben Service Account wie das Google API Quota Tracking (GOOGLE_APPLICATION_CREDENTIALS):
cat /pfad/zu/service-account.json | grep client_email
# → "client_email": "postbox@dein-projekt.iam.gserviceaccount.com"
2. Service Account in Google Search Console als Nutzer hinzufügen
- Google Search Console öffnen
- Property auswählen (Domain-Property
postbox.sooder URL-Propertyhttps://app.postbox.so) - Einstellungen → Nutzer und Berechtigungen → Nutzer hinzufügen
- E-Mail: Die
client_emailaus dem Service Account JSON - Berechtigung: Eingeschränkt (Read-Only reicht — Scope ist
webmasters.readonly)
3. .env konfigurieren
SEARCH_CONSOLE_ENABLED=true
SEARCH_CONSOLE_SITE_URL=https://app.postbox.so
Hinweis Domain-Property: Bei einer Domain-Property (postbox.so) in der Search Console muss SEARCH_CONSOLE_SITE_URL trotzdem die konkrete URL-Variante enthalten (https://app.postbox.so). Die Search Console API unterstützt keine Domain-Properties direkt — die API-Abfrage auf die URL-Property wird aber automatisch berechtigt, weil die Domain-Property alle URL-Varianten umfasst.
4. Testen
# Manueller Sync (letzte 3 Tage — GSC hat ~2 Tage Daten-Verzögerung)
php artisan seo:sync-search-console
# Bestimmtes Datum
php artisan seo:sync-search-console --date=2026-02-22
# Mehr Tage nachholen
php artisan seo:sync-search-console --days=7
Danach sollten Daten auf /admin/seo-dashboard sichtbar sein.
Authentifizierung
JWT-basierte Service Account Auth über bestehende GOOGLE_APPLICATION_CREDENTIALS. Scope: webmasters.readonly.
Token-Caching: 50-Minuten Cache (Token läuft nach 60 Minuten ab).
Location: app/Services/Seo/SearchConsoleService.php
Sync Command
# Standard: letzte 3 Tage (GSC hat 2-Tage Verzögerung)
php artisan seo:sync-search-console
# Bestimmtes Datum
php artisan seo:sync-search-console --date=2026-02-20
# Mehr Tage nachholen
php artisan seo:sync-search-console --days=7
| Option | Typ | Default | Beschreibung |
|---|---|---|---|
--days= | int | 3 | Anzahl Tage zum Syncen |
--date= | string | – | Bestimmtes Datum (YYYY-MM-DD) |
Schedule: Täglich 08:00 UTC (nach Sitemap-Generierung).
Location: app/Console/Commands/SyncSearchConsoleMetrics.php
Datenbank
Tabelle: search_console_metrics
| Feld | Typ | Beschreibung |
|---|---|---|
date | date | Metriken-Datum |
dimension | string | page oder query |
url_or_query | string | URL (bei page) oder Suchbegriff (bei query) |
clicks | integer | Klicks |
impressions | integer | Impressionen |
ctr | decimal | Click-Through-Rate |
position | decimal | Durchschnittliche Position |
Unique: (date, dimension, url_or_query)
Model: app/Models/SearchConsoleMetric.php
SEO Metrics Pruning
# Standard: älter als 90 Tage
php artisan seo:prune-metrics
# Custom Retention
php artisan seo:prune-metrics --days=60
# Vorschau
php artisan seo:prune-metrics --dry-run
Löscht sowohl search_console_metrics als auch web_vitals_metrics Records älter als die angegebene Retention.
Schedule: Wöchentlich Sonntag 03:30 UTC.
Location: app/Console/Commands/PruneSeoMetrics.php
Admin SEO Dashboard (V2)
Route: /admin/seo-dashboard
Component: app/Livewire/Admin/SeoDashboard/Index.php
Tab-basiertes Lazy-Loading
Die Seite ist in drei Tabs unterteilt. Nur der aktive Tab laedt Daten — reduziert die initiale Ladezeit deutlich.
| Tab | Property-Wert | Inhalt |
|---|---|---|
| Google Search Console | gsc (Default) | GSC Totals, Top Seiten, Top Suchanfragen, Sync-Button |
| IndexNow | indexnow | IndexNow Status, Submission-Statistiken |
| Core Web Vitals | webvitals | Vergleich, Trends, Device/Page Breakdown, URL-Drilldown |
Steuerung via $activeTab Property und wire:click="$set('activeTab', '...')". Gleiche Architektur wie AI Agent Analytics.
Bereiche
| Bereich | Tab | Datenquelle | Inhalt |
|---|---|---|---|
| GSC Totals | GSC | SearchConsoleMetric (7d) | Klicks, Impressionen, Ø CTR, Ø Position |
| Top Seiten | GSC | SearchConsoleMetric (page, 7d) | Top 20 URLs nach Klicks |
| Top Suchanfragen | GSC | SearchConsoleMetric (query, 7d) | Top 20 Queries nach Impressionen |
| IndexNow Status | IndexNow | IndexNowService | Konfiguration, Submission-Stats |
| Web Vitals Vergleich | Web Vitals | WebVitalMetric (V2) | Zeitraum-Vergleich mit Delta-Prozenten |
| Trend-Charts | Web Vitals | WebVitalMetric (V2) | Tägliche p75-Verläufe pro Metrik |
| Device Breakdown | Web Vitals | WebVitalMetric (V2) | Mobile/Desktop/Tablet × Metrik-Matrix |
| Page Type Breakdown | Web Vitals | WebVitalMetric (V2) | Seitenbereich × Metrik-Matrix |
| URL-Drilldown | Web Vitals | WebVitalMetric (V2) | Top 20 schlechteste URLs pro Metrik |
V2 Filter
| Property | Beschreibung | Optionen |
|---|---|---|
wvComparisonDays | Vergleichs-Zeitraum | 7, 14, 28 Tage |
wvDeviceFilter | Gerätetyp-Filter | Alle, Desktop, Mobile, Tablet |
wvPageTypeFilter | Seitenbereich-Filter | Alle + dynamisch aus PAGE_TYPE_LABELS |
wvDrilldownMetric | Drilldown-Metrik | LCP, FCP, INP, CLS, TTFB |
Aktionen
- GSC Sync: Manueller Sync-Button (
syncNow()dispatcht Command) - Web Vitals Status: Grün/Gelb/Rot je nach Google-Schwellwerten
- Trend-Charts: Flux UI Pro Charts mit täglicher Granularität
- URL-Drilldown: Worst-performing URLs mit ≥3 Samples
- Nächster geplanter Lauf: Zeigt die nächste geplante Sync-Zeit an, wenn der heutige Lauf noch nicht stattgefunden hat. Admins erkennen sofort ob ein Job überfällig ist.
Wöchentlicher Report (E3)
seo:web-vitals-report — wöchentlicher Admin-Report (Montags 09:00 UTC):
- Vergleicht aktuelle 7-Tage-Periode gegen vorherige 7 Tage
- Sendet nur bei ≥10 Samples in der aktuellen Periode
- Erkennt Degradation: >10% Verschlechterung oder Rating "poor"
- Admin-only Notification (
web_vitals_report) mit 14 Tagen Ablauf
Location: app/Console/Commands/WebVitalsReport.php
Konfiguration
# Google Search Console
SEARCH_CONSOLE_ENABLED=false
SEARCH_CONSOLE_SITE_URL=https://app.postbox.so
# Core Web Vitals
WEB_VITALS_ENABLED=true
WEB_VITALS_RATE_LIMIT=30
# Google Service Account (geteilt mit API Quota Tracking)
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
Config-Pfad: config/postbox.php → seo.search_console.*, seo.web_vitals.*
OG-Image-Generierung
Dynamische OG-Images (1200x630px) für öffentliche Profile im Public Explorer via spatie/browsershot (Headless Chrome).
Architektur
graph TD
A["og-images:generate<br>(Daily 08:00)"] --> B["GenerateOgImageJob<br>(Queue: og-images)"]
B --> C["OgImageGenerator"]
C --> D["Blade Template<br>(og-images/profile.blade.php)"]
D --> E["Browsershot<br>(Headless Chrome)"]
E --> F["Temp PNG"]
F --> G["Storage::disk('public')->put()"]
G --> H{PUBLIC_STORAGE_DRIVER}
H -->|local| I["storage/app/public/og-images/"]
H -->|s3| J["R2: og-images/{platform}/{id}.png"]
OgImageGenerator Service
Location: app/Services/OgImages/OgImageGenerator.php
| Method | Return | Beschreibung |
|---|---|---|
generateForProfile(profile) | ?string | OG-Image für Public Explorer Profil generieren |
generateForCategory(name, thumbnails, count) | ?string | OG-Image für Kategorie-Landing |
generateForTag(name, thumbnails, count) | ?string | OG-Image für Tag-Landing |
generateForPage(identifier, title, description) | ?string | OG-Image für CMS/generische Seiten |
shouldRegenerate(profile) | bool | Hash-Vergleich: Regenerierung nötig? |
computeProfileHash(profile) | string | SHA-256 aus Profil-Daten (title, platform, follower_tier, score, category, description, thumbnail_url via CDN, is_verified) |
deleteForProfile(profile) | void | OG-Image + DB-Metadaten löschen |
Hash-basierte Invalidierung
Jedes Profil hat einen og_image_hash (SHA-256). Berechnet aus: title, platform, follower_tier, postbox_score, ai_category, ai_description (120 Zeichen), thumbnail_url (externe CDN-URL des SocialProfile), is_verified. Bei Änderungen an diesen Feldern ergibt sich ein neuer Hash → shouldRegenerate() gibt true zurück.
Thumbnail-URL: OG-Image-Rendering und Hash-Berechnung verwenden die externe CDN-Thumbnail-URL des verknuepften SocialProfile (YouTube/Instagram CDN). Lokale Storage-URLs sind fuer den headless Chrome (Browsershot) nicht erreichbar. Fallback: PublicExplorerProfile::thumbnail_url.
Emoji-/Symbol-Bereinigung
Headless Chrome auf dem Server hat keine Emoji-Fonts installiert. Emoji und Symbole werden als eckige Platzhalter-Boxen gerendert. stripUnrenderableChars() entfernt vor dem Rendering folgende Unicode-Ranges aus Profiltiteln:
- U+1F000–U+1FFFF (Emoticons, Symbole, Flaggen)
- U+2600–U+27BF (Misc Symbols, Dingbats)
- U+FE00–U+FE0F (Variation Selectors)
- U+200D (Zero Width Joiner — Composite-Emoji-Sequenzen)
- U+20E3 (Combining Enclosing Keycap)
- U+E0020–U+E007F (Tags — Flag-Sequenzen)
Die Bereinigung wird sowohl beim Rendering als auch bei der Hash-Berechnung angewandt, damit konsistente Change-Detection gewaehrleistet ist.
Location: app/Services/OgImages/OgImageGenerator.php::stripUnrenderableChars()
OG-Image Design (Blade Template)
Dark-Mode-Design (1200x630px) mit:
- Profilbild (120x120px, rund, Indigo-Border)
- Profilname (42px, bold, Ellipsis)
- Plattform-Badge (YouTube/Instagram Icon)
- Follower-Zahl (formatiert)
- Score-Balken (0–100, farbcodiert: gruen/gelb/orange/rot)
- Kategorie-Badge
- AI-Beschreibung (max. 120 Zeichen)
- Postbox-Branding (oben rechts)
Location: resources/views/og-images/profile.blade.php
Integration in Public Explorer
PublicExplorer\Show.php setzt og:image mit Priorität:
$profile->og_image_path(dynamisches OG-Image)$profile->thumbnail_url(Profilbild-Fallback)asset('assets/og-default.png')(Layout-Fallback)
Twitter Card wechselt zu summary_large_image wenn OG-Image vorhanden.
Location: app/Livewire/PublicExplorer/Show.php
Konfiguration
OG_IMAGES_ENABLED=false
OG_IMAGES_MAX_CONCURRENT=2
OG_IMAGES_QUALITY=90
#OG_IMAGES_NODE_BINARY=/usr/bin/node
#OG_IMAGES_CHROMIUM_PATH=/usr/bin/chromium-browser
Config-Pfad: config/postbox.php → og_images.*
Admin Dashboard
Route: /admin/open-graph
Component: App\Livewire\Admin\OpenGraph\Index
Zeigt: Statistiken (Public Explorer Profile gesamt, mit/ohne OG-Image, Abdeckung %, Storage), Platform-Breakdown, Generierungs-Controls (Platform/Tier-Filter, Force-Checkbox).
Commands
Detaillierte Command-Referenz: Commands: SEO, OG-Images & Storage
| Command | Beschreibung | Schedule |
|---|---|---|
og-images:generate | OG-Images für Public Explorer Profile generieren | Täglich 08:00 (--missing-only) |
og-images:cleanup | Verwaiste OG-Images löschen | Sonntag 04:00 |
XML Sitemap
Architektur
graph TD
A["sitemap:generate<br>(Daily 07:30)"] --> B["SitemapGenerator"]
B --> C["XMLWriter Streaming"]
C --> D["storage/app/sitemaps/"]
D --> E["Symlinks in public/"]
B --> F["sitemap.xml<br>(Sitemap Index)"]
B --> G["sitemap-static.xml<br>(Homepage, Explorer, CMS)"]
B --> H["sitemap-categories.xml"]
B --> I["sitemap-tags.xml"]
B --> J["sitemap-profiles-N.xml<br>(partitioniert)"]
A --> K["Validierung<br>(XML, Groesse, URL-Count)"]
A --> L["SitemapDailySnapshot<br>(14-Tage-History)"]
A --> N["URL-Drop Alerting<br>(>10% → Admin-Notification)"]
Dateien
| Datei | Inhalt | Max. URLs |
|---|---|---|
sitemap.xml | Sitemap Index (verweist auf Sub-Sitemaps) | — |
sitemap-static.xml | Homepage, Explorer Index, Newsletter, CMS-Seiten | ~20 |
sitemap-categories.xml | Explorer Kategorie-Landing-Pages | ~30 |
sitemap-tags.xml | Explorer Tag-Landing-Pages | variabel |
sitemap-profiles-{n}.xml | Explorer Profile (partitioniert) | 10.000 pro Datei |
Storage & Deployment
Sitemaps werden in storage/app/sitemaps/ generiert (Forge-safe, ueberlebt Deployments) und via Symlinks in public/ verlinkt. Bei fehlenden Symlinks (z.B. nach Deployment) stellt eine Fallback-Route die Dateien aus Storage bereit und erstellt die Symlinks automatisch neu.
Validierung (pro Datei)
Nach Generierung wird jede Sitemap-Datei validiert:
| Pruefung | Beschreibung |
|---|---|
| XML-Validitaet | simplexml_load_string() + libxml Error-Check |
| Groesse | < 50 MB (Google-Limit) |
| URL-Count | substr_count() fuer memory-safe Zaehlung |
Ergebnisse werden pro Datei im Admin-Dashboard angezeigt (gruene/rote Badges).
14-Tage-Inventar (SitemapDailySnapshot)
Taegliche Snapshots speichern URL-Zaehlung, Dateigroesse, Generierungsdauer und Validierungsstatus. Das Admin-Dashboard zeigt:
- Inventar-Tabelle: 14 Tage mit Delta-Anzeige (Aenderung zum Vortag als Tooltip mit URL-Diff pro Kategorie)
- Liniendiagramm: URL-Entwicklung ueber 14 Tage (Chart.js)
- Health Score: 0-100 Punkte aus 4 Faktoren (Validierung 40%, Stabilitaet 30%, Lueckenfreiheit 20%, Geschwindigkeit 10%)
Tabelle: sitemap_daily_snapshots
| Feld | Typ | Beschreibung |
|---|---|---|
date | date (unique) | Snapshot-Datum |
total_urls | integer | Gesamt-URLs |
static_urls | integer | Statische URLs |
category_urls | integer | Kategorie-URLs |
tag_urls | integer | Tag-URLs |
profile_urls | integer | Profil-URLs |
files_count | integer | Anzahl Sitemap-Dateien |
total_size_bytes | bigint | Gesamtgroesse |
duration_seconds | float | Generierungsdauer |
all_valid | boolean | Alle Dateien valide? |
generated_at | datetime | Generierungszeitpunkt |
Model: app/Models/SitemapDailySnapshot.php
Google Ping (E3) — Entfernt
Google hat den /ping?sitemap= Endpoint 2023 deprecated (liefert HTTP 404). Der Ping-Code wurde am 2026-02-28 entfernt. Google crawlt Sitemaps eigenstaendig via robots.txt und Search Console.
URL-Drop Alerting (E2)
Vergleicht heutige URL-Anzahl mit dem Vortags-Snapshot. Bei einem Rueckgang >10% wird eine Admin-Notification (sitemap_url_drop) erstellt. Moegliche Ursachen: fehlerhafter Explorer Refresh, Massen-Loeschung von Profilen, Generierungs-Bug.
Notification-Typ: sitemap_url_drop (config: postbox.notifications.notification_types)
Dependency-Check
sitemap:generate prueft vor Ausfuehrung ob public-explorer:refresh heute gelaufen ist (Heartbeat-Check). Bei fehlendem Heartbeat:
- Generierung wird abgebrochen (Exit Code 1)
- Admin-Notification (
sitemap_dependency_timeout) wird erstellt --forceueberspringt den Check
robots.txt Integration
Nach Generierung wird robots.txt automatisch regeneriert mit differenzierten Regeln für AI-Bots:
Erlaubte Bots (Traffic-generierend): Googlebot, Bingbot, OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, DuckAssistBot Blockierte AI-Training-Bots: GPTBot, ClaudeBot, CCBot, Google-Extended, Meta-ExternalAgent, Bytespider, Applebot-Extended Blockierte SEO-Tools: AhrefsBot, SemrushBot, MJ12bot, DotBot
Sitemap: https://app.postbox.so/sitemap.xml
Location: app/Services/Sitemap/SitemapGenerator.php → updateRobotsTxt()
Konfiguration
# Public Explorer (Sitemap benoetigt aktiven Explorer)
PUBLIC_EXPLORER_ENABLED=true
# Profiles pro Sitemap-Datei (Default: 10000, Google-Limit: 50000)
PUBLIC_EXPLORER_SITEMAP_PROFILES_PER_FILE=10000
Config-Pfad: config/postbox.php → public_explorer.sitemap.profiles_per_file
Commands
Detaillierte Command-Referenz: Commands: SEO, OG-Images & Storage
| Command | Beschreibung | Schedule |
|---|---|---|
sitemap:generate | XML Sitemaps generieren | Taeglich 07:30 UTC |
Admin Dashboard
Route: /admin/sitemap
Component: App\Livewire\Admin\SitemapManagement\Index
Zeigt: Generierungs-Status, Datei-Tabelle mit Validierung und URL-Count, Health Score, 14-Tage-Inventar mit Deltas, URL-Entwicklungs-Chart, Konfiguration.
Tests
| Datei | Tests | Inhalt |
|---|---|---|
tests/Feature/Commands/GenerateSitemapCommandTest.php | 15 | Generierung, Dry-Run, Explorer-Deaktivierung, noindex-Ausschluss, XML-Struktur, lastmod, Metadata, Heartbeat, Dependency-Check, Fallback-Route, Validierung, Snapshots, URL-Drop |
Structured Data testen
Google Rich Results Test
Offizielle Google-Validierung der JSON-LD Structured Data:
- URL: search.google.com/test/rich-results
- Profil-URL eingeben: z.B.
https://app.postbox.so/explorer/creator-name_12345 - Ergebnis: zeigt erkannte Schema-Typen (ProfilePage, BreadcrumbList, VideoObject etc.)
- Fehler/Warnungen: fehlende Pflichtfelder, ungültige Werte
Schema.org Validator
Detailliertere Validierung als Google Rich Results Test:
- URL: validator.schema.org
- URL-Modus oder Code Snippet einfügen
- Prüft gegen die vollständige Schema.org-Spezifikation
Facebook Sharing Debugger
OG-Tags und OG-Image-Vorschau testen:
- URL: developers.facebook.com/tools/debug
- Profil-URL eingeben → zeigt og:title, og:description, og:image
- Scrape Again Button zum Cache-Refresh (Facebook cacht aggressiv)
Twitter Card Validator
Twitter-Card-Vorschau testen:
- URL: cards-dev.twitter.com/validator
- Profil-URL eingeben → zeigt Card-Typ, Bild, Titel
Automatisierte Tests (Pest)
# Alle SEO-Tests
php artisan test tests/Feature/Seo/
# Nur OG-Image-Tests
php artisan test tests/Feature/OgImages/
OG-Image-Generierung testen
Lokaler Test
# 1. Feature aktivieren
# .env: OG_IMAGES_ENABLED=true
# 2. Einzelnes Profil generieren (über Tinker)
php artisan tinker
>>> $gen = new \App\Services\OgImages\OgImageGenerator();
>>> $profile = \App\Models\PublicExplorerProfile::query()->published()->first();
>>> $gen->generateForProfile($profile);
# → 'og-images/youtube/12345.png' (oder null bei Fehler)
# 3. Ergebnis prüfen
>>> Storage::disk('public')->exists('og-images/youtube/12345.png');
# → true
# 4. URL im Browser öffnen
>>> Storage::disk('public')->url('og-images/youtube/12345.png');
Fehlerbehebung
| Problem | Ursache | Lösung |
|---|---|---|
generateForProfile() gibt null zurück | Feature deaktiviert oder Chromium-Fehler | OG_IMAGES_ENABLED=true prüfen, Logs checken |
Browsershot::html() failed | Chromium/Node nicht installiert | npx puppeteer browsers install chrome auf dem Server |
| Leeres/weißes Bild | Font nicht geladen oder Bild-Timeout | screenshot_delay erhöhen (500→1000ms) |
| R2 Upload-Fehler | Falsche R2-Credentials oder Bucket-Config | .env R2-Variablen prüfen, storage:verify-r2 |
Hash-Prüfung
php artisan tinker
>>> $gen = new \App\Services\OgImages\OgImageGenerator();
>>> $profile = \App\Models\PublicExplorerProfile::find(123);
>>> $gen->shouldRegenerate($profile); // true = Bild muss neu generiert werden
>>> $gen->computeProfileHash($profile); // aktueller Hash
>>> $profile->og_image_hash; // gespeicherter Hash
Tests
| Datei | Tests | Inhalt |
|---|---|---|
tests/Feature/Seo/SeoMetaTagsTest.php | 18 | OG Tags, Twitter Cards, JSON-LD, Canonical, noindex (Login, Register, Forgot-Password, Confirm-Password, Newsletter) |
tests/Feature/OgImages/OgImageGeneratorTest.php | 9 | Hash-Stabilität, Regeneration-Detection, OG-Image in Show, Fallback, Twitter Card |
tests/Unit/Services/Seo/WebVitalsServiceTest.php | 8 | EMA-Aggregation, URL-Normalisierung, Ratings, Trends |
tests/Feature/Api/WebVitalsApiTest.php | 5 | Endpoint-Validierung, Feature-Flag, Recording |
tests/Feature/Commands/PruneSeoMetricsTest.php | 3 | Pruning, Dry-Run |
IndexNow Integration
Was ist IndexNow?
Open-Source-Protokoll zur sofortigen Benachrichtigung von Suchmaschinen über Content-Änderungen. Statt auf Crawling zu warten, werden URLs aktiv gepusht. Unterstützt von Bing, Yandex, Naver, Seznam.cz, Yep. Google unterstützt IndexNow NICHT — Sitemap bleibt primär für Google.
Architektur
graph TD
A["Model Observer<br>(created/updated/deleted)"] --> B["IndexNowService"]
C["indexnow:submit-updated<br>(Daily 08:15)"] --> B
B --> D["Dedup Check<br>(5 Min Cooldown)"]
D --> E["HTTP POST<br>api.indexnow.org/indexnow"]
B --> F["index_now_submissions<br>(Logging)"]
F --> G["MassPrunable<br>(30 Tage)"]
F --> H["AI-Agent Analytics<br>(/admin/ai-agent-analytics)"]
F --> I["SEO Dashboard<br>(/admin/seo-dashboard)"]
IndexNowService
Location: app/Services/Seo/IndexNowService.php
| Methode | Beschreibung |
|---|---|
submit(url) | Einzelne URL submitten |
submitBatch(urls) | Batch-Submit (max 10.000 pro Batch) |
getStats(days) | Statistiken für Admin-Dashboard |
getRecentSubmissions(limit) | Letzte Submissions für Log-Ansicht |
Guards:
INDEXNOW_ENABLED=false→ deaktiviert- Kein
INDEXNOW_KEY→ deaktiviert - Nicht-Production-Environment → deaktiviert
- URL-Dedup: gleiche URL innerhalb 5 Min → übersprungen
IndexNow Observer
Location: app/Observers/IndexNowObserver.php
Automatische URL-Submissions bei Model-Events:
| Model | Events | Bedingung |
|---|---|---|
| PublicExplorerProfile | created, updated, deleted | Nur published Profiles |
| Page | created, updated, deleted | Nur nicht-Draft Pages |
Registriert in AppServiceProvider::boot().
Key Verification
IndexNow verlangt eine Key-Verification-Datei unter /{key}.txt. Route dynamisch generiert aus Config:
GET /{key}.txt → text/plain mit Key-Inhalt
Constraint: key = [a-f0-9]{32}
Datenbank
Tabelle: index_now_submissions
| Feld | Typ | Beschreibung |
|---|---|---|
url | varchar(2048) | Submitted URL |
http_status | integer | API Response Status |
success | boolean | Erfolg? |
error | varchar(500) | Fehlermeldung (optional) |
submitted_at | datetime | Zeitpunkt |
Index: (url, submitted_at, success) — für Dedup-Queries
Model: app/Models/IndexNowSubmission.php (MassPrunable, 30 Tage Retention)
Commands
| Command | Beschreibung | Schedule |
|---|---|---|
indexnow:submit-updated --hours=8 | Profile der letzten N Stunden submitten | Täglich 08:15 UTC |
indexnow:submit-updated --all | Alle published Profile submitten (Seed) | Manuell |
model:prune --model=IndexNowSubmission | Alte Logs aufräumen | Täglich 03:25 UTC |
Konfiguration
# IndexNow aktivieren (nur in Production wirksam)
INDEXNOW_ENABLED=true
# IndexNow API Key (32-Zeichen hex, generiert via: php -r "echo bin2hex(random_bytes(16));")
INDEXNOW_KEY=abc123def456abc123def456abc123de
# Retention für Submission-Logs in Tagen (Default: 30)
INDEXNOW_RETENTION_DAYS=30
Config-Pfad: config/postbox.php → seo.indexnow.*
Tests
| Datei | Tests | Inhalt |
|---|---|---|
tests/Unit/Services/Seo/IndexNowServiceTest.php | 7 | Guards, Stats, Dedup, Recent Submissions |
tests/Unit/Models/IndexNowSubmissionTest.php | 4 | Casts, Prunable, Retention Config |
tests/Feature/Seo/IndexNowKeyVerificationTest.php | 4 | Key Match, Mismatch, No Key, Pattern |
tests/Feature/Seo/IndexNowObserverTest.php | 4 | Observer-Events ohne Crash |
tests/Feature/Console/IndexNowSubmitUpdatedTest.php | 5 | Disabled, No Key, Hours, --all, Zero URLs |
llms.txt & llms-full.txt
Was ist llms.txt?
Ein aufkommender Standard (Jeremy Howard, September 2024) für LLM-freundliche Website-Beschreibung in Markdown. Ähnlich wie robots.txt für Suchmaschinen, aber für AI-Agents.
Routen
| Route | Typ | Inhalt | Cache |
|---|---|---|---|
/llms.txt | Statisch (Blade) | Plattformbeschreibung, öffentliche Seiten, CMS-Footer-Links | 24h Cache-Control |
/llms-full.txt | Dynamisch (Service) | Stats, Kategorien, Top-20-Profile nach Score | 24h Cache (LlmsTxtService) |
Content-Type: text/plain; charset=utf-8
LlmsTxtService
Location: app/Services/Seo/LlmsTxtService.php
Generiert dynamische Daten für llms-full.txt:
| Daten | Quelle |
|---|---|
stats | Aggregierte Counts (total, youtube, instagram, categories) |
categories | Sortiert nach Profil-Count, mit URLs |
topProfiles | Top 20 nach postbox_score |
generatedAt | Zeitstempel |
Cache: 24h TTL, regenerate() erzwingt Neuberechnung.
Tests
| Datei | Tests | Inhalt |
|---|---|---|
tests/Unit/Services/Seo/LlmsTxtServiceTest.php | 4 | Stats, Score-Sortierung, Cache, Unpublished |
tests/Feature/Seo/LlmsTxtRoutesTest.php | 4 | Content-Type, Dynamic Data, Cache-Control |
Content-Freshness Headers (E7)
Middleware für effizientes Caching durch AI-Crawler und CDN.
Location: app/Http/Middleware/ContentFreshness.php
Angewendet auf: /explorer/* Route-Gruppe
| Header | Wert | Zweck |
|---|---|---|
ETag | MD5 des Response-Contents | Client-Cache-Validierung |
Last-Modified | Aktueller Zeitstempel | Crawler-Effizienz |
Cache-Control | public, max-age=300, must-revalidate | CDN/Cloudflare Caching (5 Min) |
304 Not Modified: Wenn If-None-Match Header den aktuellen ETag matcht, wird ein 304 zurückgegeben (kein Body).
Tests
| Datei | Tests | Inhalt |
|---|---|---|
tests/Feature/Middleware/ContentFreshnessTest.php | 7 | ETag, Last-Modified, Cache-Control, 304, Non-200 Skip |
AI-Agent Analytics Dashboard (E3)
Route: /admin/ai-agent-analytics
Component: app/Livewire/Admin/AiAgentAnalytics/Index.php
Bereiche
| Bereich | Inhalt |
|---|---|
| SEO-Dateien Status | robots.txt, llms.txt, llms-full.txt, sitemap.xml Verfügbarkeit |
| Bot-Konfiguration | Tabelle aller AI-Bots mit Status (Erlaubt/Blockiert), Kategorie, Betreiber |
| IndexNow Status | Konfiguration (Enabled, Key, Production) + Statistiken |
| Submission Log | Letzte 50 Submissions mit URL, HTTP-Status, Erfolg/Fehler |
Filter
| Property | Beschreibung | Optionen |
|---|---|---|
statsDays | Statistik-Zeitraum | 7, 14, 30 Tage |
Tests
| Datei | Tests | Inhalt |
|---|---|---|
tests/Feature/Admin/AiAgentAnalyticsTest.php | 6 | Admin/Non-Admin Access, Bot Table, IndexNow, Submissions |