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 |
|---|---|---|---|
title | string | null | Page-Title (Plan 61 F3 — als expliziter Prop fuer head.blade.php) |
ogTitle | string | null | OG Title (Fallback: $title) |
ogDescription | string | null | OG Description (Fallback: $metaDescription → Default aus Config) |
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 (Plan 61 F5: Auto-Default = url()->current()) |
autoCanonical | bool | true | Plan 61 F5 — opt-out fuer Pages mit relevanten Query-Params |
jsonLd | array | null | JSON-LD Schema (einzeln oder Array von Arrays) |
metaDescription | string | null | Meta Description (Plan 61 F4: Default aus postbox.seo.meta.default_description) |
metaRobots | string | null | Robots-Direktive (noindex, nofollow) |
dcType | string | 'Text' | Plan 61 F2 — Dublin-Core-Type (Service / Dataset / Collection / Text / InteractiveResource) |
dcSubject | string | null | Plan 61 F2 — komma-separierte Schlagworte fuer DC.subject |
dcDate | string | null | Plan 61 F2 — ISO-8601 Datum fuer DC.date |
prevUrl | string | null | Plan 61 F10 — <link rel="prev"> fuer Pagination im <head> |
nextUrl | string | null | Plan 61 F10 — <link rel="next"> fuer Pagination im <head> |
Fallback-Logik
og:title→$ogTitle ?? $title ?? nullog:description→$ogDescription ?? $metaDescription ?? config('postbox.seo.meta.default_description')og:image→$ogImage ?? asset('assets/og-default.png')(immer gesetzt)twitter:title/description→ gleiche Fallbacks wie OGtwitter:site→config('postbox.seo.meta.twitter_handle')(Plan 61 F6)meta description→$metaDescription ?? config('postbox.seo.meta.default_description')(nie leer)link rel="canonical"→$canonicalUrl ?? url()->current()(wennautoCanonical=true)
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 + SoftwareApplication + BreadcrumbList | 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 |