Zum Hauptinhalt springen

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

PropTypDefaultBeschreibung
ogTitlestringnullOG Title (Fallback: $title)
ogDescriptionstringnullOG Description (Fallback: $metaDescription)
ogImagestringnullOG Image URL (Fallback: asset('assets/og-default.png'))
ogTypestring'website'OG Type (website, profile, etc.)
ogUrlstringnullOG URL (Fallback: url()->current())
twitterCardstring'summary_large_image'Twitter Card Type
canonicalUrlstringnullCanonical URL
jsonLdarraynullJSON-LD Schema (einzeln oder Array von Arrays)
metaDescriptionstringnullMeta Description
metaRobotsstringnullRobots-Direktive (noindex, nofollow)

Fallback-Logik

  • og:title$ogTitle ?? $title ?? null
  • og:description$ogDescription ?? $metaDescription ?? null
  • og:image$ogImage ?? asset('assets/og-default.png') (immer gesetzt)
  • twitter:title/description → gleiche Fallbacks wie OG

Seiten-spezifische SEO-Konfiguration

SeiteComponent/ControllerogTypeogImagetwitterCardJSON-LDcanonicalUrl
Homepagewelcome.blade.phpwebsiteog-default.pngsummary_large_imageWebSite + Organizationurl('/')
Explorer IndexPublicExplorer\Indexwebsiteog-default.pngsummary_large_imageCollectionPageroute('public-explorer.index')
Profil DetailPublicExplorer\Showprofilethumbnail_urlsummaryProfilePage + BreadcrumbListroute('public-explorer.show', ...)
Category LandingPublicExplorer\CategoryLandingwebsiteog-default.pngsummary_large_imageCollectionPage + BreadcrumbListroute('public-explorer.category', ...)
Tag LandingPublicExplorer\TagLandingwebsiteog-default.pngsummary_large_imageCollectionPage + BreadcrumbListroute('public-explorer.tag', ...)
CMS-SeitenPageControllerwebsiteog-default.pngsummary_large_imageWebPageurl($page->url)
LoginAuth Viewnoindex, nofollow
RegisterAuth Viewnoindex, 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.phpbuildJsonLd()

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.

PropertyWert
Dateipublic/assets/og-default.png
Größe1200×630px
FormatPNG
InhaltPostbox-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:

SeitentypBeispiel-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.

MetrikBeschreibungGoogle-Schwellwert (gut)
LCPLargest Contentful Paint≤ 2500ms
FCPFirst Contentful Paint≤ 1800ms
INPInteraction to Next Paint≤ 200ms
CLSCumulative Layout Shift≤ 0.1
TTFBTime 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"
}
FeldTypValidierung
namestringrequired, in: LCP, FID, CLS, INP, TTFB, FCP
valuenumericrequired, min: 0, max: 999999
urlstringrequired, url, max: 500
devicestringnullable, in: mobile, desktop, tablet
page_typestringnullable, 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):

deviceErkennung
mobile/Mobi|Android/i im User-Agent
tablet/Tablet|iPad/i im User-Agent
desktopAlles andere

Seitenbereich (URL-Pathname-basiert):

page_typePfad-Muster
homepage/ (exakt)
explore/explorer*
tops_flops/tops-flops*
profile*/explorer/* (Detail)
watchers/watchers*
dashboard/dashboard*
tags/tags*
settings/settings*
admin/admin*
cmsAlles andere bekannte
otherFallback

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:

MethodeBeschreibung
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

FeldTypBeschreibung
datedateAggregations-Tag
urlstringNormalisierte URL (ohne Query-Params)
metric_namestringLCP, FCP, INP, CLS, TTFB
device_typevarchar(10)mobile, desktop, tablet, unknown
page_typevarchar(30)homepage, explore, profile, admin etc.
sample_countintegerAnzahl Messungen
p50decimal50. Perzentil (Median)
p75decimal75. Perzentil (Google-Referenzwert)
p90decimal90. Perzentil
avg_valuedecimalDurchschnitt

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

  1. Google Search Console öffnen
  2. Property auswählen (Domain-Property postbox.so oder URL-Property https://app.postbox.so)
  3. EinstellungenNutzer und BerechtigungenNutzer hinzufügen
  4. E-Mail: Die client_email aus dem Service Account JSON
  5. 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
OptionTypDefaultBeschreibung
--days=int3Anzahl Tage zum Syncen
--date=stringBestimmtes Datum (YYYY-MM-DD)

Schedule: Täglich 08:00 UTC (nach Sitemap-Generierung).

Location: app/Console/Commands/SyncSearchConsoleMetrics.php

Datenbank

Tabelle: search_console_metrics

FeldTypBeschreibung
datedateMetriken-Datum
dimensionstringpage oder query
url_or_querystringURL (bei page) oder Suchbegriff (bei query)
clicksintegerKlicks
impressionsintegerImpressionen
ctrdecimalClick-Through-Rate
positiondecimalDurchschnittliche 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.

TabProperty-WertInhalt
Google Search Consolegsc (Default)GSC Totals, Top Seiten, Top Suchanfragen, Sync-Button
IndexNowindexnowIndexNow Status, Submission-Statistiken
Core Web VitalswebvitalsVergleich, Trends, Device/Page Breakdown, URL-Drilldown

Steuerung via $activeTab Property und wire:click="$set('activeTab', '...')". Gleiche Architektur wie AI Agent Analytics.

Bereiche

BereichTabDatenquelleInhalt
GSC TotalsGSCSearchConsoleMetric (7d)Klicks, Impressionen, Ø CTR, Ø Position
Top SeitenGSCSearchConsoleMetric (page, 7d)Top 20 URLs nach Klicks
Top SuchanfragenGSCSearchConsoleMetric (query, 7d)Top 20 Queries nach Impressionen
IndexNow StatusIndexNowIndexNowServiceKonfiguration, Submission-Stats
Web Vitals VergleichWeb VitalsWebVitalMetric (V2)Zeitraum-Vergleich mit Delta-Prozenten
Trend-ChartsWeb VitalsWebVitalMetric (V2)Tägliche p75-Verläufe pro Metrik
Device BreakdownWeb VitalsWebVitalMetric (V2)Mobile/Desktop/Tablet × Metrik-Matrix
Page Type BreakdownWeb VitalsWebVitalMetric (V2)Seitenbereich × Metrik-Matrix
URL-DrilldownWeb VitalsWebVitalMetric (V2)Top 20 schlechteste URLs pro Metrik

V2 Filter

PropertyBeschreibungOptionen
wvComparisonDaysVergleichs-Zeitraum7, 14, 28 Tage
wvDeviceFilterGerätetyp-FilterAlle, Desktop, Mobile, Tablet
wvPageTypeFilterSeitenbereich-FilterAlle + dynamisch aus PAGE_TYPE_LABELS
wvDrilldownMetricDrilldown-MetrikLCP, 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.phpseo.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

MethodReturnBeschreibung
generateForProfile(profile)?stringOG-Image für Public Explorer Profil generieren
generateForCategory(name, thumbnails, count)?stringOG-Image für Kategorie-Landing
generateForTag(name, thumbnails, count)?stringOG-Image für Tag-Landing
generateForPage(identifier, title, description)?stringOG-Image für CMS/generische Seiten
shouldRegenerate(profile)boolHash-Vergleich: Regenerierung nötig?
computeProfileHash(profile)stringSHA-256 aus Profil-Daten (title, platform, follower_tier, score, category, description, thumbnail_url via CDN, is_verified)
deleteForProfile(profile)voidOG-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:

  1. $profile->og_image_path (dynamisches OG-Image)
  2. $profile->thumbnail_url (Profilbild-Fallback)
  3. 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.phpog_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

CommandBeschreibungSchedule
og-images:generateOG-Images für Public Explorer Profile generierenTäglich 08:00 (--missing-only)
og-images:cleanupVerwaiste OG-Images löschenSonntag 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

DateiInhaltMax. URLs
sitemap.xmlSitemap Index (verweist auf Sub-Sitemaps)
sitemap-static.xmlHomepage, Explorer Index, Newsletter, CMS-Seiten~20
sitemap-categories.xmlExplorer Kategorie-Landing-Pages~30
sitemap-tags.xmlExplorer Tag-Landing-Pagesvariabel
sitemap-profiles-{n}.xmlExplorer 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:

PruefungBeschreibung
XML-Validitaetsimplexml_load_string() + libxml Error-Check
Groesse< 50 MB (Google-Limit)
URL-Countsubstr_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

FeldTypBeschreibung
datedate (unique)Snapshot-Datum
total_urlsintegerGesamt-URLs
static_urlsintegerStatische URLs
category_urlsintegerKategorie-URLs
tag_urlsintegerTag-URLs
profile_urlsintegerProfil-URLs
files_countintegerAnzahl Sitemap-Dateien
total_size_bytesbigintGesamtgroesse
duration_secondsfloatGenerierungsdauer
all_validbooleanAlle Dateien valide?
generated_atdatetimeGenerierungszeitpunkt

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
  • --force ueberspringt 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.phpupdateRobotsTxt()

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.phppublic_explorer.sitemap.profiles_per_file

Commands

Detaillierte Command-Referenz: Commands: SEO, OG-Images & Storage

CommandBeschreibungSchedule
sitemap:generateXML Sitemaps generierenTaeglich 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

DateiTestsInhalt
tests/Feature/Commands/GenerateSitemapCommandTest.php15Generierung, 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:

  1. URL: search.google.com/test/rich-results
  2. Profil-URL eingeben: z.B. https://app.postbox.so/explorer/creator-name_12345
  3. Ergebnis: zeigt erkannte Schema-Typen (ProfilePage, BreadcrumbList, VideoObject etc.)
  4. Fehler/Warnungen: fehlende Pflichtfelder, ungültige Werte

Schema.org Validator

Detailliertere Validierung als Google Rich Results Test:

  1. URL: validator.schema.org
  2. URL-Modus oder Code Snippet einfügen
  3. Prüft gegen die vollständige Schema.org-Spezifikation

Facebook Sharing Debugger

OG-Tags und OG-Image-Vorschau testen:

  1. URL: developers.facebook.com/tools/debug
  2. Profil-URL eingeben → zeigt og:title, og:description, og:image
  3. Scrape Again Button zum Cache-Refresh (Facebook cacht aggressiv)

Twitter Card Validator

Twitter-Card-Vorschau testen:

  1. URL: cards-dev.twitter.com/validator
  2. 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

ProblemUrsacheLösung
generateForProfile() gibt null zurückFeature deaktiviert oder Chromium-FehlerOG_IMAGES_ENABLED=true prüfen, Logs checken
Browsershot::html() failedChromium/Node nicht installiertnpx puppeteer browsers install chrome auf dem Server
Leeres/weißes BildFont nicht geladen oder Bild-Timeoutscreenshot_delay erhöhen (500→1000ms)
R2 Upload-FehlerFalsche 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

DateiTestsInhalt
tests/Feature/Seo/SeoMetaTagsTest.php18OG Tags, Twitter Cards, JSON-LD, Canonical, noindex (Login, Register, Forgot-Password, Confirm-Password, Newsletter)
tests/Feature/OgImages/OgImageGeneratorTest.php9Hash-Stabilität, Regeneration-Detection, OG-Image in Show, Fallback, Twitter Card
tests/Unit/Services/Seo/WebVitalsServiceTest.php8EMA-Aggregation, URL-Normalisierung, Ratings, Trends
tests/Feature/Api/WebVitalsApiTest.php5Endpoint-Validierung, Feature-Flag, Recording
tests/Feature/Commands/PruneSeoMetricsTest.php3Pruning, 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

MethodeBeschreibung
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:

ModelEventsBedingung
PublicExplorerProfilecreated, updated, deletedNur published Profiles
Pagecreated, updated, deletedNur 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

FeldTypBeschreibung
urlvarchar(2048)Submitted URL
http_statusintegerAPI Response Status
successbooleanErfolg?
errorvarchar(500)Fehlermeldung (optional)
submitted_atdatetimeZeitpunkt

Index: (url, submitted_at, success) — für Dedup-Queries

Model: app/Models/IndexNowSubmission.php (MassPrunable, 30 Tage Retention)

Commands

CommandBeschreibungSchedule
indexnow:submit-updated --hours=8Profile der letzten N Stunden submittenTäglich 08:15 UTC
indexnow:submit-updated --allAlle published Profile submitten (Seed)Manuell
model:prune --model=IndexNowSubmissionAlte Logs aufräumenTä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.phpseo.indexnow.*

Tests

DateiTestsInhalt
tests/Unit/Services/Seo/IndexNowServiceTest.php7Guards, Stats, Dedup, Recent Submissions
tests/Unit/Models/IndexNowSubmissionTest.php4Casts, Prunable, Retention Config
tests/Feature/Seo/IndexNowKeyVerificationTest.php4Key Match, Mismatch, No Key, Pattern
tests/Feature/Seo/IndexNowObserverTest.php4Observer-Events ohne Crash
tests/Feature/Console/IndexNowSubmitUpdatedTest.php5Disabled, 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

RouteTypInhaltCache
/llms.txtStatisch (Blade)Plattformbeschreibung, öffentliche Seiten, CMS-Footer-Links24h Cache-Control
/llms-full.txtDynamisch (Service)Stats, Kategorien, Top-20-Profile nach Score24h Cache (LlmsTxtService)

Content-Type: text/plain; charset=utf-8

LlmsTxtService

Location: app/Services/Seo/LlmsTxtService.php

Generiert dynamische Daten für llms-full.txt:

DatenQuelle
statsAggregierte Counts (total, youtube, instagram, categories)
categoriesSortiert nach Profil-Count, mit URLs
topProfilesTop 20 nach postbox_score
generatedAtZeitstempel

Cache: 24h TTL, regenerate() erzwingt Neuberechnung.

Tests

DateiTestsInhalt
tests/Unit/Services/Seo/LlmsTxtServiceTest.php4Stats, Score-Sortierung, Cache, Unpublished
tests/Feature/Seo/LlmsTxtRoutesTest.php4Content-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

HeaderWertZweck
ETagMD5 des Response-ContentsClient-Cache-Validierung
Last-ModifiedAktueller ZeitstempelCrawler-Effizienz
Cache-Controlpublic, max-age=300, must-revalidateCDN/Cloudflare Caching (5 Min)

304 Not Modified: Wenn If-None-Match Header den aktuellen ETag matcht, wird ein 304 zurückgegeben (kein Body).

Tests

DateiTestsInhalt
tests/Feature/Middleware/ContentFreshnessTest.php7ETag, 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

BereichInhalt
SEO-Dateien Statusrobots.txt, llms.txt, llms-full.txt, sitemap.xml Verfügbarkeit
Bot-KonfigurationTabelle aller AI-Bots mit Status (Erlaubt/Blockiert), Kategorie, Betreiber
IndexNow StatusKonfiguration (Enabled, Key, Production) + Statistiken
Submission LogLetzte 50 Submissions mit URL, HTTP-Status, Erfolg/Fehler

Filter

PropertyBeschreibungOptionen
statsDaysStatistik-Zeitraum7, 14, 30 Tage

Tests

DateiTestsInhalt
tests/Feature/Admin/AiAgentAnalyticsTest.php6Admin/Non-Admin Access, Bot Table, IndexNow, Submissions