Zum Hauptinhalt springen

Page-Cache-Layer für Public Pages

Pre-rendert öffentliche Pages in einen Redis-Cache-Layer für SEO-Geschwindigkeit. Bot-Crawler (Google, Bing, ChatGPT, Claude, Perplexity) bekommen sub-ms HTML statt 200-500ms PHP-Render.

Architektur

GET /de/explorer


┌─────────────────────────────────┐
│ CachePageResponse Middleware │
└─────────────────────────────────┘

├─ Cache-Hit ────► PageCacheService::get() → CachedPage VO
│ │
│ ├─ ETag-Match? → 304 Not Modified
│ └─ Stale? → respond + dispatch RefreshPageCacheJob

└─ Cache-Miss ──► Controller rendert HTML

├─ shouldStore()? (Warmer-UA?, no-form, no-store?)
│ │
│ ▼
│ HtmlMinifier::minify()
│ │
│ ▼
│ PageCacheService::put() → Redis-Hash

└─ Response mit ETag + Cache-Control: public, max-age

Konfiguration

.env

# Redis maxmemory (in MB) — Source-of-Truth für Budget-Berechnung.
# Muss auch in /etc/redis/redis.conf entsprechend gesetzt werden.
REDIS_MAX_MEMORY_MB=512

# Page-Cache-Budget als Prozent vom Redis-Total.
# Default 60% von 512 MB ≈ 307 MB. 40% Headroom für App-Caches/Pulse/Token-Health/Locks.
# (Bei größeren Redis-Instanzen kann der Anteil höher liegen, z.B. 75% bei ≥1 GB.)
PAGE_CACHE_BUDGET_PERCENT=60

# Aktivierung. Erstmal opt-in via env. Default false.
PAGE_CACHE_ENABLED=true

# Stop-Threshold: Warming pausiert wenn Page-Cache zu X% des Budgets voll.
PAGE_CACHE_STOP_AT_PERCENT=90

# TTL pro Page-Type (Sekunden)
PAGE_CACHE_TTL_LANDING=86400 # 24h — Home, Newsletter
PAGE_CACHE_TTL_EXPLORER_INDEX=43200 # 12h — /explorer
PAGE_CACHE_TTL_CATEGORY=43200 # 12h — Category-Landings
PAGE_CACHE_TTL_PROFILE=21600 # 6h — Profile-Detail-Pages
PAGE_CACHE_TTL_TAG=21600 # 6h — Tag-Landings
PAGE_CACHE_TTL_CMS=43200 # 12h — Impressum, Datenschutz, Hilfe
PAGE_CACHE_TTL_TOOLS=43200 # 12h — youtube-publishing-analytics

# Warming-Run-Steuerung
# 0 = unlimited. Stop kommt rein über den Memory-Soft-Cap (siehe unten).
# Nur setzen wenn man absichtlich kleine Probe-Runs will (z.B. Debug).
PAGE_CACHE_WARMER_MAX_PER_RUN=0
PAGE_CACHE_WARMER_SLEEP_MS=200

# HTTP-Timeout pro Page-Request (Sekunden). 30s deckt langsame Profile-
# Detail-Pages unter Last (Watcher-Map, Score-Charts, Related-Profiles).
# Vorher 8s — produzierte tausende cURL-28-Errors/h und flutete Flare-Quota.
PAGE_CACHE_WARMER_TIMEOUT=30
PAGE_CACHE_WARMER_CONNECT_TIMEOUT=5

# cURL-28-Timeouts NICHT pro URL loggen (Default true).
# PagesWarm emittiert ein Aggregat-Log am Run-Ende; RefreshPageCacheJob
# degradiert Timeout-Errors auf info-Level. Andere Errors (DNS, 5xx,
# parse) gehen weiter als Warning durch.
PAGE_CACHE_WARMER_SILENCE_TIMEOUT_LOGS=true

# Stale-While-Revalidate Window (Sekunden). Im Fenster wird stale-served
# während Background-Job re-rendert. Default 10 Min.
PAGE_CACHE_SWR_WINDOW=600

Welche Pages werden gecached

Nur öffentliche, NICHT user-individuelle Pages:

Route-PatternTierTTL
/{locale}/ (Home)124h
/{locale}/explorer112h
/{locale}/explorer/category/{slug}1, 412h
/{locale}/explorer/tag/{tag}36h
/{locale}/explorer/{path}_<id> (Profile)2, 56h
/{locale}/tools/youtube-publishing-analytics112h
/{locale}/newsletter124h
/{locale}/{slug}_p<id> (CMS)112h

NICHT gecached:

  • Alles hinter Auth (/dashboard, /discover, /explore, /compare, /watchers, /admin)
  • Forms-Pages (/login, /register, etc.) — CSRF-Token-Detection
  • User-individuelle Inhalte

Warming-Tiers

5-Tier-System mit ~40.630 URLs total = ~4.1 GB im Cache:

TierBeschreibungURLsMemory
1Landing + Top-30 Cat + Tools + CMS + Newsletter~80~16 MB
2Top-10K Profile-Pages (nach Followers)10.000~1.0 GB
3Top-100 Tag-Pages100~10 MB
4Top-30/60 Categories60~6 MB
5Top-30K Profile-Pages (nach Score, Long-Tail)30.000~3 GB
Total~40.630~4.1 GB

→ Passt in 6 GB Budget mit 1.9 GB Headroom.

Schedule

// Alle 30 Min — refresht ablaufende Pages on-the-fly
Schedule::command('pages:warm --skip-existing')
->everyThirtyMinutes()
->withoutOverlapping(60);

// Voller Re-Warm täglich um 04:00 UTC (off-peak)
Schedule::command('pages:warm --force')
->dailyAt('04:00')
->timezone('UTC')
->withoutOverlapping(180);

Smart-Invalidation

PageCacheInvalidationObserver hängt an folgenden Models:

ModelTrigger-FelderInvalidiert
SocialProfiletitle, description, ai_keywords, ai_description, ai_category, postbox_score, tracking_enabled, blocked_at, sanitized_at, archived_at, is_verified, is_privatepublic-explorer.show (DE+EN)
PublicExplorerProfilepublished_at, slug, followers_count, postbox_score, category, detected_countrypublic-explorer.show (DE+EN)
ExploreCategoryname, slug, is_active, profile_count, sort_orderIndex + Category-Landing (DE+EN)
Pagetitle, content, slug, is_publishedpages.show (DE+EN)

Memory-Schutz (Defense-in-Depth)

  1. Application-Soft-Cap: PageCacheService::put() checkt vor jedem Write currentSizeMB() vs. budgetMB(). Bei 90%+ → skip + Log.
  2. Redis-Hard-Cap: maxmemory 512mb + maxmemory-policy allkeys-lru. Bei vollem Memory evictet Redis automatisch alte Page-Cache-Keys (heißeste URLs bleiben, kalter Long-Tail fliegt raus — gewollt).
  3. Warming-Stop-Threshold: pages:warm pausiert bei 90% Budget (Status stopped_at_threshold). Nächster Run prüft erneut.
  4. Smart-Invalidation: alte Page-Eintraege werden bei Updates gelöscht statt zu akkumulieren.

Warming ist „unlimited per run" — Stop kommt aus dem Memory-Budget

Seit 10f0faa (2026-05-03) gilt PAGE_CACHE_WARMER_MAX_PER_RUN=0 als Default. Der Warmer iteriert alle URLs aus dem Collector und stoppt ausschließlich über den Memory-Soft-Cap:

budget_mb = REDIS_MAX_MEMORY_MB × PAGE_CACHE_BUDGET_PERCENT / 100
= 512 × 60 / 100 ≈ 307 MB
stop_at = budget_mb × PAGE_CACHE_STOP_AT_PERCENT / 100
= 307 × 90 / 100 ≈ 276 MB

Der PagesWarm-Loop prüft alle 50 URLs isOverThreshold() und bricht sauber mit Status stopped_at_threshold ab, falls erreicht. Bei 512 MB Redis × 60% × 90% ≈ 276 MB Stop-Threshold passen bei ~80 KB/Page ungefähr ~3 400 heiße Pages in den Cache; der kalte Long-Tail wird von der LRU-Eviction wieder rausgeworfen und lazy beim ersten Hit gerendert. Auf größeren Redis-Instanzen (≥1 GB) lohnt es sich, PAGE_CACHE_BUDGET_PERCENT auf 75% zu erhöhen.

Tracking-Sauberkeit: Warmer-UA wird vom Matomo ausgeschlossen

Page-Warmer-Self-Requests (User-Agent: Postbox-Page-Warmer/1.0) werden in TrackPageView::shouldTrack() explizit aussortiert. Sonst würden bei vollen Runs ~40k zusätzliche „Page-Views" pro Tag in den Stats auftauchen. Gleicher UA-Check filtert auch im RateLimiter (AppServiceProvider::isWarmerRequest).

Stale-While-Revalidate (SWR)

Der Cache speichert Pages mit EXPIRE = ttl + swr_window. Im SWR-Fenster (Default 10 Min) gibt die Middleware stale-Werte zurück und dispatcht einen RefreshPageCacheJob der die Page im Hintergrund neu rendert.

→ User bekommt SOFORT die alte HTML, nächster Request bekommt frische Version.

ETag + 304 Conditional Response

Jede Page bekommt einen ETag (md5 des HTML, 16 Zeichen). Bei If-None-Match: <etag> Match → 304 Not Modified.

→ Spart Bandbreite für Bot-Crawler die immer dieselben URLs abfragen.

Forge-Deploy-Skript

# Vor Activate-Release:
$FORGE_PHP artisan optimize:clear # Filesystem-Caches + FLUSHDB DB 1
$FORGE_PHP artisan optimize # Re-Build: config/route/view/event
$FORGE_PHP artisan cache:warm # waermt Plan-01 Hot-Keys

# Kein pages:warm im Deploy-Skript! Der Schedule (alle 30 Min) startet
# das Warming automatisch nach Deploy. Vorteile: Deploy ist schneller,
# kein 30-60s Warming-Wait, Output bleibt sauber.

Cache-Reset bei Deploy: optimize:clear ist die Default-Option und fuehrt intern cache:clear aus → das ist ein FLUSHDB auf der Cache- Connection (DB 1). Postbox nutzt Redis dediziert, daher unkritisch. Falls du Redis je mit anderen Apps teilst, nimm stattdessen cache:flush-app (SCAN MATCH <REDIS_PREFIX><CACHE_PREFIX>*) — das laesst fremde Keys unangetastet. Lock-Connection (DB 0) ist in beiden Faellen nicht betroffen.

Cloudflare-403-Bypass (PFLICHT auf Production hinter Cloudflare)

Cloudflare's Bot-Schutz blockiert Self-Requests mit 403, weil der Warmer- User-Agent kein "echter" Browser ist. Lösung: Self-Requests gehen an localhost, der Host-Header trägt aber den echten Hostname für korrektes Routing.

# Setzt den Warmer auf Localhost-Bypass — Self-Request geht direkt an
# 127.0.0.1, der Host-Header behält den App-Hostname (für route()-Generation
# und Middleware-Routing).
PAGE_CACHE_WARMER_BASE_URL=http://127.0.0.1

Falls Postbox auf einem anderen Port läuft (z.B. nginx auf 80, php-fpm direkt auf 9000), entsprechend anpassen: http://127.0.0.1:80.

Validierung nach Setzen:

php artisan pages:warm --tier=1 --max-pages=5
# Sollte ✓ statt ✗ HTTP 403 zeigen

Admin-UI

/admin/cache-management zeigt:

  • Memory-Bar mit Color-Threshold (grün <70%, amber 70-90%, rot >90%)
  • Anzahl gecachter Pages (60s-Cache, wird beim Flush sofort invalidiert)
  • Warming-Läufe vertikal gestapelt — ein Block pro Tier mit eigenem Status-Badge, Progress-Bar und Skipped/Errors/Start-Zeitstempel. Laufende Runs zuerst.
  • Top-20 Pages nach Hits (24h-Window) als Hit-Heatmap
  • Aktion-Buttons: Warming starten (= Tier 0 = alle), Tier 1, Tier 2, Page-Cache leeren

Per-Tier Status-Keys & Stop-Signal

Jeder pages:warm-Run schreibt seinen Fortschritt in einen tier-spezifischen Cache-Key pages:warm:status:t{N} (N = 0…5). Frühere Versionen nutzten einen einzigen geteilten Key pages:warm:status — dort haben sich konkurrierende Runs (Admin-Klick + Schedule-Run, oder mehrere Admin-Klicks gleichzeitig) gegenseitig überschrieben, was den "Aktueller Lauf"-Counter im UI zwischen mehreren Werten flackern ließ.

Pro Tier existiert ein zusätzlicher Lock pages:warm:lock:t{N} (TTL 30 Min): nur ein Run pro Tier zur Zeit. Versucht ein zweiter Run (Schedule oder Klick) denselben Tier zu starten, gibt das Command sauber mit Warning zurück.

Diagnose-Hooks (seit 2026-05-27)

Production-Symptom (204× in 30 Tagen): Scheduler protokolliert Scheduled command [/usr/bin/php8.4 artisan pages:warm --skip-existing] failed with exit code . — exit code leer = Prozess vor Rückkehr gekillt, vermutlich OOM oder externer Watchdog. Da der Command immer self::SUCCESS zurückgibt und alle inneren Fehler abfängt, lieferte der bestehende Code keinerlei Spur, wo der Prozess gestorben ist.

PagesWarm hat jetzt drei Observability-Hooks:

  • Start-Log (Log::info('PagesWarm started', …)) mit pid, started_at, urls_total, memory_start_mb, memory_limit_mb, force, skip_existing — damit man im Forge-Log nachverfolgen kann, welcher Prozess wann gestartet ist.
  • Heartbeat-Log alle 1.000 URLs mit urls_processed, memory_current_mb, memory_percent (relativ zu PHP-memory_limit), runtime_seconds. Bei externem Kill ist das letzte Heartbeat-Log der Anker, an welcher URL-Position der Prozess war.
  • Soft-Abort bei memory_percent > 85% — Prozess stoppt sauber statt vom OOM-Killer mit SIGKILL erschlagen zu werden. Log::warning('PagesWarm aborting due to memory pressure', …) dokumentiert es.
  • Completion-Log (PagesWarm completed) am Ende mit Peak-Memory, Runtime, finalen Countern. Fehlt dieses Log in Production, war der Prozess vor dieser Zeile tot — die letzten Heartbeats zeigen wo.

Helper phpMemoryLimitBytes() parst ini_get('memory_limit') (M/G/K-Suffix unterstützt).

Beim Klick auf "Page-Cache leeren" setzt PageCacheService::flushAll() zusätzlich:

  • Stop-Signal pages:warm:abort (TTL 5 Min) → der Warmer prüft alle 25 Iterationen und beendet sich als stopped_by_user
  • Forget aller pages:warm:status:t*-Keys (Anzeige zeigt nicht weiter alte Counter)
  • Forget pages:cache:count + pages:cache:size_mb (60s-Caches, sonst lag das UI hinterher)

Status-Klassen pro Run: running / completed / completed_with_errors / stopped_at_threshold (Memory voll) / stopped_by_user (Admin hat geleert).

Console-Commands

CommandZweck
php artisan pages:warmWärmt alle Tiers (default 500 pages/run)
php artisan pages:warm --tier=1Nur Tier 1
php artisan pages:warm --forceRenewt alle Pages (ignoriert TTL)
php artisan pages:warm --skip-existingSkip bereits gecachte Pages
php artisan pages:warm --dry-runZeigt URLs ohne auszuführen
php artisan pages:clearLeert kompletten Page-Cache-Layer (nur pages:* Keys)
php artisan cache:flush-appLeert ALLE App-Cache-Keys in DB 1 (<REDIS_PREFIX><CACHE_PREFIX>*)
php artisan cache:flush-app --dry-runZeigt Anzahl ohne zu loeschen

Warming-Tiers (PageCacheUrlCollector)

Die URL-Sammlung ist auf 3 Tiers reduziert; Long-Tail-Profile und alle SEO-Tag-Pages werden NICHT mehr pre-warmed:

TierInhaltGröße
1 — StaticLanding pro Locale, Tools, CMS-Pages, llms.txt, Public-Explorer-Index~20 URLs
2 — Public-Explorer-FrontendTop-30 Kategorien (/explorer/category/{slug}, Page 1), ~80 in den /explore-Index-Slidern verlinkte Profile (/explorer/{slug}_{id}), Top-100 Tags aus der Schlagwort-Wolke (/explorer/tag/{tag})~300 URLs
3 — Kategorie-TiefePro Top-30 Kategorie zusätzlich ?page=2 + ?page=3, plus Top-72 Profile (24 × 3) jeder Kategorie nach postbox_score desc~3.000 URLs

Hinweis zu /explore: die Routen /explore, /explore/browse, /explore/trending-videos sind auth-required (Route::middleware('auth')). Die CachePageResponse-Middleware skippt authentisierte Requests — Pre-Warming hätte dort keinen Effekt. Stattdessen werden die öffentlich gecachten /$locale/explorer/*-Pendants gewärmt (Kategorien, Featured-Profile, Schlagwort-Wolke-Tags), die dieselben Inhalte für anonyme Besucher und Crawler bereitstellen.

Begründung: Long-Tail-Profil-Pages (>10 k Views) bekommen im Verhältnis zum Memory- und Render-Budget zu wenig Traffic, um pre-Warming zu rechtfertigen — sie werden lazy beim ersten Hit gerendert. SEO-Tag-Pages (/explorer/tag/{tag}) werden nur noch gewärmt, wenn der Tag in der Schlagwort-Wolke auf /explore sichtbar ist.

Skalierung

Redis-SizeBudget-AnteilPage-Cache-Budget~ Pages (≈80 KB/Page)
256 MB60 %154 MB~1 900
512 MB (aktuell)60 %~307 MB~3 800
1 GB75 %768 MB~9 600
8 GB75 %6 144 MB~76 800

Skalierung nach oben: REDIS_MAX_MEMORY_MB=8192 + redis.conf maxmemory 8gb + PAGE_CACHE_BUDGET_PERCENT=75 setzen (Code-Änderung nicht nötig — alle Berechnungen sind parametrisch).