web: Stale-while-revalidate-Cache für die öffentlichen Seitendaten

Start-/Kategorieseiten bauten nach Ablauf der 5-Minuten-TTL im Besucher-
Request neu (~25-30 Archiv-Queries über ~100k PMs) — Long-Tail-Rubriken
waren dadurch praktisch bei jedem Aufruf langsam. WebPageCache nutzt jetzt
Cache::flexible([300, 3600]): Innerhalb 5 Min frisch wie bisher, danach
wird bis zu 1 h sofort der alte Stand serviert und der Neuaufbau läuft
nach der Response (deferred). HomePageData/CategoryPageData/TickerData
laufen dafür über die gemeinsame WebPageCache-Konvention (Keys unverändert),
die Hub-Kennzahlen über flexible([3600, 14400]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-20 14:29:15 +00:00
parent 622cd4e289
commit da189c05b6
6 changed files with 77 additions and 37 deletions

View file

@ -7,34 +7,31 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed-
* Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken.
*
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
* ~30 Queries über das gesamte Archiv und die Daten ändern sich höchstens
* minütlich. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
* vor dem Cache-Write wirft.
* Das Ergebnis wird stale-while-revalidate gecacht (WebPageCache): Der Aufbau
* umfasst ~30 Queries über das gesamte Archiv und darf nicht im Besucher-
* Request laufen. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
* vor dem Cache-Write wirft; wird eine Rubrik nachträglich deaktiviert, wirft
* der deferred Neuaufbau ins Leere und der alte Stand verschwindet spätestens
* mit Ablauf der Stale-Grenze.
*/
class CategoryPageData
{
private const CACHE_TTL_SECONDS = 300;
/**
* @return array<string, mixed>
*/
public function build(WebPortalContext $context, string $slug): array
{
$cacheKey = sprintf('web.category.%s.%s.%s', $context->portal->value, $context->language, $slug);
return Cache::remember(
$cacheKey,
self::CACHE_TTL_SECONDS,
return WebPageCache::remember(
WebPageCache::key('category', [$context->portal->value, $context->language, $slug]),
fn (): array => $this->buildFresh($context, $slug)
);
}

View file

@ -8,33 +8,29 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Support\EditorialClock;
use App\Support\WebPageCache;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
*
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
* ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens
* minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL.
* Das Ergebnis wird stale-while-revalidate gecacht (WebPageCache): Der Aufbau
* umfasst ~25 Queries über das gesamte Archiv und darf nicht im Besucher-
* Request laufen — neue Meldungen erscheinen spätestens nach Ablauf der
* Frische-TTL beim übernächsten Aufruf.
*/
class HomePageData
{
private const CACHE_TTL_SECONDS = 300;
/**
* @return array<string, mixed>
*/
public function build(Portal $portal, string $language = 'de'): array
{
$cacheKey = sprintf('web.home.%s.%s', $portal->value, $language);
return Cache::remember(
$cacheKey,
self::CACHE_TTL_SECONDS,
return WebPageCache::remember(
WebPageCache::key('home', [$portal->value, $language]),
fn (): array => $this->buildFresh($portal, $language)
);
}

View file

@ -13,12 +13,18 @@ use Illuminate\Support\Facades\Cache;
/**
* Datenaufbereitung der Hub-Landing (pressekonto.de): aktiver Tarif-Katalog
* plus echte Plattform-Kennzahlen statt statischer Marketing-Zahlen.
*
* Die Kennzahlen ändern sich träge, ihr Aufbau zählt aber übers Gesamtarchiv
* (inkl. whereHas über alle Firmen) → stale-while-revalidate mit langem
* Fenster, damit der Aufbau nie im Besucher-Request läuft.
*/
class HubPageData
{
private const StatsCacheKey = 'web.hub.landing-stats';
private const StatsTtlSeconds = 3600;
private const StatsFreshSeconds = 3600;
private const StatsStaleSeconds = 14400;
/**
* @return array<string, mixed>
@ -28,7 +34,7 @@ class HubPageData
return [
'plans' => Plan::query()->active()->get(),
'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'),
'hubStats' => Cache::remember(self::StatsCacheKey, self::StatsTtlSeconds, function (): array {
'hubStats' => Cache::flexible(self::StatsCacheKey, [self::StatsFreshSeconds, self::StatsStaleSeconds], function (): array {
$published = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->visibleInArchive();

View file

@ -4,27 +4,25 @@ namespace App\Services\Web;
use App\Enums\Portal;
use App\Models\PressRelease;
use Illuminate\Support\Facades\Cache;
use App\Support\WebPageCache;
use Illuminate\Support\Str;
/**
* Ad-Hoc-Ticker-Meldungen der Web-Frontends: die jüngsten veröffentlichten
* Pressemitteilungen des Portals. Zentral hier statt je Seite, damit der
* Ticker auf allen Unterseiten identisch gefüllt ist; kurz gecacht, weil
* der Header auf jeder Seite rendert.
* Ticker auf allen Unterseiten identisch gefüllt ist; stale-while-revalidate
* gecacht (WebPageCache), weil der Header auf jeder Seite rendert.
*/
class TickerData
{
private const CACHE_TTL_SECONDS = 300;
/**
* @return list<array{time: string, text: string, href: ?string}>
*/
public function items(Portal $portal, string $language): array
{
$cacheKey = sprintf('web.ticker.%s.%s', $portal->value, $language);
$cacheKey = WebPageCache::key('ticker', [$portal->value, $language]);
return Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($portal, $language): array {
return WebPageCache::remember($cacheKey, function () use ($portal, $language): array {
return PressRelease::query()
->forPortalValues([$portal->value, Portal::Both->value])
->forLanguage($language)