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-Pattern | Tier | TTL |
|---|---|---|
/{locale}/ (Home) | 1 | 24h |
/{locale}/explorer | 1 | 12h |
/{locale}/explorer/category/{slug} | 1, 4 | 12h |
/{locale}/explorer/tag/{tag} | 3 | 6h |
/{locale}/explorer/{path}_<id> (Profile) | 2, 5 | 6h |
/{locale}/tools/youtube-publishing-analytics | 1 | 12h |
/{locale}/newsletter | 1 | 24h |
/{locale}/{slug}_p<id> (CMS) | 1 | 12h |
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:
| Tier | Beschreibung | URLs | Memory |
|---|---|---|---|
| 1 | Landing + Top-30 Cat + Tools + CMS + Newsletter | ~80 | ~16 MB |
| 2 | Top-10K Profile-Pages (nach Followers) | 10.000 | ~1.0 GB |
| 3 | Top-100 Tag-Pages | 100 | ~10 MB |
| 4 | Top-30/60 Categories | 60 | ~6 MB |
| 5 | Top-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:
| Model | Trigger-Felder | Invalidiert |
|---|---|---|
SocialProfile | title, description, ai_keywords, ai_description, ai_category, postbox_score, tracking_enabled, blocked_at, sanitized_at, archived_at, is_verified, is_private | public-explorer.show (DE+EN) |
PublicExplorerProfile | published_at, slug, followers_count, postbox_score, category, detected_country | public-explorer.show (DE+EN) |
ExploreCategory | name, slug, is_active, profile_count, sort_order | Index + Category-Landing (DE+EN) |
Page | title, content, slug, is_published | pages.show (DE+EN) |
Memory-Schutz (Defense-in-Depth)
- Application-Soft-Cap:
PageCacheService::put()checkt vor jedem WritecurrentSizeMB() vs. budgetMB(). Bei 90%+ → skip + Log. - 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). - Warming-Stop-Threshold:
pages:warmpausiert bei 90% Budget (Statusstopped_at_threshold). Nächster Run prüft erneut. - 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', …)) mitpid,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 alsstopped_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
| Command | Zweck |
|---|---|
php artisan pages:warm | Wärmt alle Tiers (default 500 pages/run) |
php artisan pages:warm --tier=1 | Nur Tier 1 |
php artisan pages:warm --force | Renewt alle Pages (ignoriert TTL) |
php artisan pages:warm --skip-existing | Skip bereits gecachte Pages |
php artisan pages:warm --dry-run | Zeigt URLs ohne auszuführen |
php artisan pages:clear | Leert kompletten Page-Cache-Layer (nur pages:* Keys) |
php artisan cache:flush-app | Leert ALLE App-Cache-Keys in DB 1 (<REDIS_PREFIX><CACHE_PREFIX>*) |
php artisan cache:flush-app --dry-run | Zeigt 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:
| Tier | Inhalt | Größe |
|---|---|---|
| 1 — Static | Landing pro Locale, Tools, CMS-Pages, llms.txt, Public-Explorer-Index | ~20 URLs |
| 2 — Public-Explorer-Frontend | Top-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-Tiefe | Pro 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-Size | Budget-Anteil | Page-Cache-Budget | ~ Pages (≈80 KB/Page) |
|---|---|---|---|
| 256 MB | 60 % | 154 MB | ~1 900 |
| 512 MB (aktuell) | 60 % | ~307 MB | ~3 800 |
| 1 GB | 75 % | 768 MB | ~9 600 |
| 8 GB | 75 % | 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).