- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen, gegen Code und Git abgeglichen (Stand v1.7.0) - Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung, 2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen → archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets, Go-Live-Roadmap vom 20.07. - Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update) und changelog/ (Decision-Update Changelog) - dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf die neuen Pfade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
18 KiB
Markdown
375 lines
18 KiB
Markdown
# Bestandsaufnahme Code — Stand 01.09.2026
|
||
|
||
**Zweck:** Ausgangslage vor der Umsetzung von `roadmap-2026.md`. Was ist gesund,
|
||
was ist kaputt, was muss vor dem ersten Roadmap-Arbeitspaket geradegezogen
|
||
werden.
|
||
|
||
**Prüfumfang:** 348 PHP-Dateien / ~33.900 Zeilen in `app/`, 108 Migrationen,
|
||
201 Testdateien, dazu der gesamte noch nicht committete Arbeitsstand
|
||
(41 geänderte + 22 neue Dateien aus den Blöcken „Suchtabelle/FULLTEXT-Umzug",
|
||
„410-Tombstone", „API-Portal-Zuordnung", „Bounce-Import").
|
||
|
||
> **Bearbeitungsstand 01.09.2026:** Abschnitt 7 ist vollständig abgearbeitet.
|
||
> Was genau geändert wurde, welche Vorschläge abgewandelt oder verworfen wurden
|
||
> und welche Entscheidung noch offen ist, steht in
|
||
> **[`umsetzung-protokoll.md`](umsetzung-protokoll.md)**.
|
||
>
|
||
> **Eine Korrektur an diesem Bericht:** Befund **L6** war falsch — siehe unten.
|
||
|
||
---
|
||
|
||
## 1 Werkzeugstand
|
||
|
||
| Prüfung | Befehl | Ergebnis |
|
||
|---|---|---|
|
||
| Tests | `php artisan test --compact` | **1479 grün**, 1 übersprungen, 6472 Assertions, 121 s |
|
||
| Codestil | `vendor/bin/pint --test` | **passed** |
|
||
| Statische Analyse | `vendor/bin/phpstan analyse` | **fehlgeschlagen — 390 Fehler außerhalb der Baseline** |
|
||
|
||
Die Testabdeckung ist gut und der neue Arbeitsstand ist mitgetestet
|
||
(`GoneTombstoneTest`, `PressReleaseSearchIndexTest`, `ApiPortalResolutionTest`,
|
||
`FixApiPortalDuplicatesTest`, `ImportNewsletterBouncesTest`,
|
||
`OnlineSchemaChangeCommandTest`). Die statische Analyse dagegen ist faktisch
|
||
außer Betrieb — siehe P1, das ist der Befund mit dem größten Hebel.
|
||
|
||
---
|
||
|
||
## 2 Werkzeug & Prozess
|
||
|
||
### P1 — Statische Analyse liefert seit Monaten nur noch Rauschen
|
||
|
||
**Symptom:** 390 Fehler außerhalb der Baseline. Darunter Dutzende Meldungen der
|
||
Art „Strict comparison using === between string and
|
||
`App\Enums\PressReleaseStatus::Published` will always evaluate to false"
|
||
(u. a. `app/Models/PressRelease.php:507`, `app/Services/PressRelease/BoostService.php:45`,
|
||
`app/Listeners/ProcessStripeWebhook.php:424`). Zur Laufzeit funktioniert dieser
|
||
Code — die Meldungen sind falsch.
|
||
|
||
**Ursache:** Larastan löst **überhaupt keine Casts auf**. Nachgewiesen mit
|
||
`PHPStan\dumpType()`:
|
||
|
||
```
|
||
$pressRelease->status → string (erwartet: PressReleaseStatus)
|
||
$pressRelease->portal → string (erwartet: Portal)
|
||
$pressRelease->published_at → string|null (erwartet: Carbon|null)
|
||
```
|
||
|
||
Grund: Alle 49 Models deklarieren ihre Casts über die `casts()`-**Methode**
|
||
(Laravel-11-Stil). Laravel führt diese Methode erst zur Laufzeit mit
|
||
`$this->casts` zusammen (`HasAttributes.php:206`), Larastan instanziiert Models
|
||
aber mit `newInstanceWithoutConstructor()` und sieht deshalb nichts. Für genau
|
||
diesen Fall gibt es den Schalter `parseModelCastsMethod`, dessen Larastan-Default
|
||
`false` ist (`vendor/larastan/larastan/extension.neon:34`) und der in
|
||
`phpstan.neon` nicht gesetzt ist.
|
||
|
||
**Nachgewiesene Wirkung des Schalters:** mit `parseModelCastsMethod: true`
|
||
lösen alle drei Typen oben korrekt auf, die Fehlerzahl fällt von 390 auf 266.
|
||
|
||
**Zweitursache:** Die Baseline (`phpstan-baseline.neon`, 127 Einträge) ist seit
|
||
dem 12.05.2026 unverändert — knapp vier Monate Drift. 39 ihrer Einträge greifen
|
||
inzwischen ins Leere (`ignore.unmatched`).
|
||
|
||
**Drittursache:** `composer analyse` existiert, läuft aber in keiner Pipeline.
|
||
`.github/workflows/lint.yml` ruft nur Pint auf, `tests.yml` nur die Tests. Nichts
|
||
hält die Analyse grün.
|
||
|
||
**Schritte**
|
||
|
||
1. `parseModelCastsMethod: true` in `phpstan.neon` setzen.
|
||
2. Der Rest der 266 Meldungen ist überwiegend echtes Rauschen ohne Codefehler:
|
||
84 × `property.notFound` (`withCount`-Aliasse wie `recent_releases_count`,
|
||
Relationen ohne Generic), 50 × `method.notFound` (eigene Scopes wie
|
||
`forPortalValues()`, `visibleInArchive()` ohne `@method`-Annotation),
|
||
35 × `nullsafe.neverNull`. Das lässt sich mit `@method`-Annotationen auf den
|
||
Scopes und `/** @return Builder<static> */` an den Scope-Methoden
|
||
substanziell senken, ohne eine Zeile Logik anzufassen.
|
||
3. Baseline **danach** neu erzeugen, nicht vorher — sonst zementiert sie die
|
||
falschen Cast-Meldungen.
|
||
4. `composer analyse` in `tests.yml` aufnehmen. Ohne Gate driftet die Baseline
|
||
binnen weniger Wochen erneut.
|
||
|
||
---
|
||
|
||
## 3 Befunde — hoch
|
||
|
||
### H1 — Deploy-Fenster: bis zu 60 s SQL-Fehler in der Suche
|
||
|
||
`app/Models/PressReleaseSearchIndex.php:75` — `isAvailable()` cacht die Antwort
|
||
eine Minute:
|
||
|
||
```php
|
||
return Cache::remember('press-release-search.available', now()->addMinute(), …);
|
||
```
|
||
|
||
Solange `false` gecacht ist, greift der Rückfallpfad
|
||
(`PressReleaseSearchIndex.php:110`) und setzt `whereFullText()` **auf
|
||
`press_releases`** ab. Läuft in diesem Fenster die Migration
|
||
`drop_fulltext_indexes_from_press_releases`, existiert der Index dort nicht mehr
|
||
→ MySQL-Fehler 1191 („Can't find FULLTEXT index matching the column list") →
|
||
500er auf jeder Suche der Web-Seite und der Admin-PM-Liste, bis der Cache-
|
||
Eintrag abläuft.
|
||
|
||
**Fix:** In `up()` beider Such-Migrationen (`create_press_release_search_table`
|
||
und `drop_fulltext_indexes_from_press_releases`)
|
||
`Cache::forget('press-release-search.available')` aufrufen. Ergänzend den
|
||
Rückfallpfad in `constrain()` gegen `QueryException` absichern und im Fehlerfall
|
||
auf die Suchtabelle umschalten.
|
||
|
||
### H2 — Newsroom-Entfernung flusht den kompletten Seiten-Cache N-mal
|
||
|
||
`app/Services/Company/CompanyRemovalService.php:61-71` lädt **alle**
|
||
Pressemitteilungen der Firma mit `->get()` in den Speicher und ruft je Zeile
|
||
`PressReleaseService::markGone()`. Diese Methode flusht in
|
||
`PressReleaseService.php:495` über `WebCaches::forgetPublicPages()` jedes Mal:
|
||
|
||
* `WebPageCache::flush()` → Generations-Zähler +1 → **sämtliche** `web.`-Seiten-Caches verworfen,
|
||
* `WebCaches::forgetSitemaps()` → alle Sitemap-Caches je Host.
|
||
|
||
Bei einer Firma mit 300 Meldungen sind das 300 vollständige Cache-Invalidierungen
|
||
in einer Schleife, gefolgt von einer 301. in Zeile 73. Jede davon zwingt die
|
||
gesamte öffentliche Seite in den Neuaufbau — ein selbst ausgelöster
|
||
Cache-Stampede auf einer Aktion, die aus dem Admin heraus mit einem Klick kommt.
|
||
Dazu der unbegrenzte `->get()`.
|
||
|
||
**Fix:** `markGone()`/`restoreFromGone()` um einen Parameter erweitern, der den
|
||
Flush unterdrückt (oder eine interne `markGoneWithoutFlush()`), über
|
||
`chunkById()` statt `get()` iterieren und **einmal** am Ende flushen. Zeile 73
|
||
macht das ohnehin schon.
|
||
|
||
### H3 — `restoreFromGone()` aktiviert Firmen, die vorher deaktiviert waren
|
||
|
||
`app/Services/Company/CompanyRemovalService.php:90-93` setzt beim Aufheben
|
||
bedingungslos `is_active = true`. `markGone()` (Zeile 46-49) setzt vorher
|
||
bedingungslos `is_active = false`, ohne den Ausgangswert zu sichern.
|
||
|
||
War die Firma **vor** der 410-Entfernung bereits deaktiviert — im Bestand der
|
||
Normalfall bei Karteileichen —, ist sie nach dem Aufheben aktiv und taucht in
|
||
Newsroom-Übersicht und Sitemap auf. Die UI verspricht ausdrücklich
|
||
Umkehrbarkeit; das ist keine.
|
||
|
||
**Fix:** Ausgangszustand mitschreiben (z. B. Flag in `activity_log`-Meta oder
|
||
eine Spalte `was_active_before_gone`) und beim Aufheben zurücksetzen. Minimal:
|
||
`is_active` beim Aufheben gar nicht anfassen und den Redakteur entscheiden
|
||
lassen.
|
||
|
||
### H4 — `no_export` bleibt nach dem Aufheben einer PM-Entfernung stehen
|
||
|
||
`PressReleaseService::markGone()` (Zeile 480-490) setzt `no_export = true`.
|
||
`restoreFromGone()` (Zeile 511-519) setzt nur `gone_at` zurück. Die Mitteilung
|
||
ist danach zwar wieder freigegeben, bleibt aber dauerhaft aus allen Exporten
|
||
ausgeschlossen — ohne dass das irgendwo sichtbar wäre. Dieselbe Asymmetrie wie
|
||
H3, im selben Arbeitspaket.
|
||
|
||
**Fix:** Entweder `no_export` mit zurücksetzen (dann den Vorzustand sichern, weil
|
||
die Spalte auch redaktionell gesetzt sein kann) oder `no_export` in `markGone()`
|
||
gar nicht setzen — `gone_at` filtert bereits über `scopeVisibleInArchive()`.
|
||
|
||
---
|
||
|
||
## 4 Befunde — mittel
|
||
|
||
### M1 — Generations-Zähler wird prozessweit eingefroren
|
||
|
||
`app/Support/WebPageCache.php:74`
|
||
|
||
```php
|
||
return self::$generation ??= (int) Cache::get(self::GenerationKey, 0);
|
||
```
|
||
|
||
Unter PHP-FPM stimmt das (ein Prozess = ein Request). In langlebigen Prozessen —
|
||
`queue:work`, ein späterer Octane-Betrieb — wird der Wert beim ersten Aufruf
|
||
eingefroren und nie wieder gelesen. Konkrete Folge:
|
||
`BoostService::forgetPlacementCaches()` (Zeile 230-240) baut seine
|
||
`Cache::forget()`-Keys über `WebPageCache::key()`. Läuft das in einem Worker mit
|
||
veralteter Generation, löscht es Keys einer alten Generation, während der
|
||
aktuelle Eintrag stehen bleibt — die Boost-Platzierung erscheint bis zum
|
||
TTL-Ablauf nicht.
|
||
|
||
**Fix:** Memoisierung an die Request-/Job-Lebensdauer binden (Reset in einem
|
||
`JobProcessing`-Listener) oder ganz streichen und pro Aufruf lesen — bei Redis
|
||
ist das ein GET.
|
||
|
||
### M2 — Der Generations-Zähler liegt im Namensraum, den er invalidiert
|
||
|
||
`WebPageCache::GenerationKey = 'web.generation'` (Zeile 39). Der Zähler ist genau
|
||
der eine `web.`-Key, der einen Flush überleben muss. Der Code fängt einen Verlust
|
||
zwar ab (Zeile 88-94), aber nur innerhalb desselben Prozesses. Umbenennen auf
|
||
etwas außerhalb des Präfixes, z. B. `page-cache:generation`.
|
||
|
||
### M3 — Wasserstandsmarke ist nur chunkweise idempotent
|
||
|
||
`app/Console/Commands/AggregatePressReleaseViews.php:90-101`: erst laufen alle
|
||
`increment()` des Chunks, danach wird die Marke geschrieben. Bricht der Prozess
|
||
dazwischen ab (OOM, Deploy, `kill`), sind die bereits verbuchten Releases beim
|
||
nächsten Lauf **ein zweites Mal** dran. Der Klassenkommentar (Zeile 26-27)
|
||
behauptet ausdrücklich das Gegenteil („ein doppelter Lauf zählt nichts doppelt").
|
||
|
||
**Fix:** Increments und `AppSetting::setValue()` je Chunk in eine
|
||
`DB::transaction()`. Kommentar entsprechend präzisieren.
|
||
|
||
### M4 — `press_release_events` wächst unbegrenzt
|
||
|
||
Die Tabelle ist seit dem Umbau die Quelle jedes Seitenaufrufs (`PressRelease::recordDeferredView()`),
|
||
liegt laut Migrationskommentar bei knapp einer Million Zeilen und wächst mit
|
||
jedem Request weiter. Im Scheduler (`routes/console.php`) gibt es Aufräum-Tasks
|
||
für Magic Links, Snapshots und Entwürfe — für dieses Log keinen.
|
||
|
||
Nach der Aggregation werden die Aufruf-Ereignisse nur noch für die geplante
|
||
Absenderansicht (Roadmap 1c.1, „Aufrufe im Verlauf") gebraucht. Das legt einen
|
||
Aufbewahrungszeitraum nahe, keine Ewigkeit.
|
||
|
||
**Fix:** Retention festlegen (Vorschlag: 13 Monate, damit Vorjahresvergleiche
|
||
möglich bleiben) und einen `press-releases:purge-events`-Task ergänzen, der
|
||
ausschließlich Ereignisse **unterhalb der Wasserstandsmarke** löscht. Diese
|
||
Entscheidung gehört vor 1c.1 — sie bestimmt, wie weit die Verlaufsansicht
|
||
zurückreicht.
|
||
|
||
### M5 — Migration umgeht den AppSetting-Cache
|
||
|
||
`database/migrations/2026_08_25_120000_seed_press_release_view_watermark.php`
|
||
schreibt mit `DB::table('app_settings')->updateOrInsert()` und räumt den
|
||
`rememberForever`-Eintrag `app-setting.press_release_views.aggregated_through_id`
|
||
nicht ab. Lief der Scheduler-Task im Deploy-Fenster einmal vor der Migration, hält
|
||
der Cache die alte, niedrigere Marke und der nächste Lauf zählt das Fenster
|
||
dazwischen doppelt. Ein `Cache::forget()` am Ende von `up()` genügt.
|
||
|
||
### M6 — `$companyId` in der Firmen-Bearbeitung ist nicht `#[Locked]`
|
||
|
||
`resources/views/livewire/admin/companies/edit.blade.php:19` —
|
||
`public int $companyId;` ohne Attribut, obwohl das Gegenstück in
|
||
`admin/press-releases/edit.blade.php:34` `#[Locked] public int $id` verwendet.
|
||
`save()`, `deleteCompany()` und die neuen `markGone()`/`restoreGone()` arbeiten
|
||
alle auf diesem vom Client manipulierbaren Wert. Die `authorize('companies:write')`-
|
||
Prüfung greift, das Ausmaß ist also begrenzt — aber die Aktion trifft dann eine
|
||
andere Firma als die im Formular angezeigte, und die Inkonsistenz zum
|
||
PM-Formular ist unnötig. `#[Locked]` ergänzen.
|
||
|
||
### M7 — Legacy-API kann `portal = null` schreiben
|
||
|
||
`app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php:111`
|
||
|
||
```php
|
||
$pressRelease->portal = $this->resolveRequestedPortal(…)?->value;
|
||
```
|
||
|
||
`resolveRequestedPortal()` (`app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php:20`)
|
||
gibt `null` zurück, wenn weder Parameter noch Portal-Kontext noch
|
||
`$company->portal` greifen. Die Spalte ist ein `ENUM … NOT NULL DEFAULT 'both'`
|
||
(`create_press_releases_table.php:19-22`) — das Ergebnis ist ein SQL-Fehler und
|
||
damit ein 500er statt einer verwertbaren Fehlermeldung für den Alt-Client.
|
||
Praktisch schwer zu erreichen, aber der `?->`-Operator sagt, dass der Autor den
|
||
Fall für möglich hält. Entweder auf `Portal::Both` defaulten oder mit 422
|
||
antworten.
|
||
|
||
### M8 — Passwort in der pt-osc-Optionsdatei ist nicht maskiert
|
||
|
||
`app/Console/Commands/OnlineSchemaChange.php:192-196` schreibt
|
||
|
||
```php
|
||
sprintf("[client]\nuser=%s\npassword=%s\n", …)
|
||
```
|
||
|
||
In einer my.cnf beginnt `#` einen Kommentar, führende/abschließende Leerzeichen
|
||
werden verschluckt. Ein DB-Passwort mit `#` wird an dieser Stelle abgeschnitten
|
||
und pt-osc scheitert mit einem Authentifizierungsfehler, dessen Ursache man
|
||
nirgends sieht. Wert in doppelte Anführungszeichen setzen und `"`/`\` escapen.
|
||
|
||
Die Umgehung der Kommandozeile (Zeile 81-83) ist ansonsten richtig gelöst,
|
||
ebenso `tempnam()` + `chmod(0600)` vor dem Schreiben.
|
||
|
||
### M9 — Bounce-Suppression erfasst nur `users`, nicht `newsletter_subscriptions`
|
||
|
||
`app/Console/Commands/ImportNewsletterBounces.php:58` sucht ausschließlich über
|
||
`User::query()->where('email', …)` und protokolliert alles Übrige als „Kein
|
||
User-Konto". Die Tabelle `newsletter_subscriptions` führt eine eigene
|
||
Empfängerliste mit eigenem `unsubscribed_at` und bleibt unberührt.
|
||
|
||
Die Roadmap macht „Bounce-Suppression produktiv" zum harten Blocker für 1b.6,
|
||
1d.3 und 3.2. Solange die zweite Liste nicht mitgeführt wird, ist diese
|
||
Voraussetzung nicht erfüllt — mit unmittelbarer Wirkung auf die
|
||
Absender-Reputation und damit auf die Zustellbarkeit der Magic Links, die die
|
||
Roadmap selbst als das eigentliche Risiko benennt.
|
||
|
||
**Fix:** `newsletter_subscriptions` in denselben Lauf aufnehmen und ein
|
||
gemeinsames Suppression-Konzept festlegen (eigene Tabelle oder gemeinsames
|
||
Merkmal), bevor Block 1b/1d gebaut wird.
|
||
|
||
---
|
||
|
||
## 5 Befunde — niedrig / Aufräumen
|
||
|
||
| Nr. | Fundstelle | Befund |
|
||
|---|---|---|
|
||
| L1 | `app/Services/PressRelease/ProofPdfRenderer.php:211` | `$image === null` ist immer falsch — `$embed` ist bereits null, wenn `$image` null ist |
|
||
| L2 | `app/Services/PressRelease/ProofPdfRenderer.php:946` | `$width < 1 \|\| $height < 1` nach `imagesx()`/`imagesy()` immer falsch |
|
||
| L3 | `app/Services/Billing/ManualInvoiceService.php:78` | `current_period_end === null` — laut Schema nicht nullable, Prüfung tot |
|
||
| L4 | `app/Models/Company.php:178` | linke Seite von `&&` immer wahr |
|
||
| L5 | `app/Models/PressRelease.php:415` | `null`-Arm im `match ($this->portal)` unerreichbar (Spalte NOT NULL) |
|
||
| ~~L6~~ | `app/Listeners/ProcessStripeWebhook.php:56-57` | **FALSCHER BEFUND.** Beide werden in den Zeilen 123, 149, 257 und 293 gelesen. Die PHPStan-Meldung ist ein Folgefehler: Cashier typisiert `findBillable()` gegen den *Trait* `Billable`, PHPStan hält `if (! $user instanceof User) return;` deshalb für immer wahr und alles danach — inklusive der Lesezugriffe — für unerreichbar. Nichts entfernt. |
|
||
| L7 | `app/Console/Commands/ReindexPressReleaseSearch.php:66` | `upsert()` mit 500 `mediumText`-Zeilen kann `max_allowed_packet` reißen — `--chunk` beim Erstlauf niedriger ansetzen oder im Kommentar vermerken |
|
||
| L8 | `resources/views/livewire/admin/press-releases/index.blade.php:301` | `fn($inner)` statt `fn ($inner)` — Pint erfasst Blade nicht, deshalb unbemerkt |
|
||
| L9 | `app/Console/Commands/ImportNewsletterBounces.php:58` | Adressen werden kleingeschrieben verglichen; unter MySQL (`utf8mb4_*_ci`) egal, unter SQLite im Test nicht — verdeckt mögliche Lücken |
|
||
|
||
Positiv und ausdrücklich erwähnt: keine Roh-SQL-Stelle nimmt Nutzereingaben
|
||
entgegen (alle `selectRaw`/`whereRaw` sind parametrisiert oder erhalten
|
||
Literale), alle `{!! !!}`-Ausgaben in `resources/views/web/` speisen sich aus
|
||
Sprachdateien oder laufen über `PressReleaseHtmlSanitizer::render()`, und die
|
||
neuen Admin-Aktionen prüfen durchgängig `authorize()`.
|
||
|
||
---
|
||
|
||
## 6 Voraussetzungen, die die Roadmap braucht und die noch fehlen
|
||
|
||
Reine Feststellungen aus dem Code, keine neuen Arbeitspakete.
|
||
|
||
1. **`companies.email` hat keinen B-Tree-Index.** Vorhanden sind nur
|
||
`(portal, slug)`, `(portal, is_active)`, `(created_at, id)` und ein
|
||
FULLTEXT über `(name, email, slug)`. Block 1a schlüsselt die
|
||
Newsroom-Konsolidierung ausdrücklich über die Firmen-E-Mail, und 1a.4 will
|
||
diesen Lookup bei **jeder** eingehenden API-Meldung ausführen. Ohne Index ist
|
||
das ein Full Scan über 70 MB pro Einreichung. Der Index selbst ist billig:
|
||
`ADD INDEX` läuft auch mit vorhandenem FULLTEXT-Index `INPLACE` — nur
|
||
`ADD COLUMN` erzwingt den COPY-Rebuild.
|
||
2. **Aufbewahrung von `press_release_events` ist offen** (M4). Sie bestimmt, wie
|
||
weit die Verlaufsansicht aus 1c.1 zurückreicht, und muss vor 0.3
|
||
(„Bestandsaufnahme Aufrufzahlen: Median und Verteilung der letzten drei
|
||
Monate") entschieden sein.
|
||
3. **Bounce-Suppression ist unvollständig** (M9) — harter Blocker für 1b.6, 1d.3
|
||
und 3.2.
|
||
4. **Der `gone_at`-Pfad ist nicht sauber umkehrbar** (H3, H4). Roadmap 1b.5
|
||
(„fällt ein Newsroom auf null Meldungen, verschwindet die öffentliche Seite
|
||
mit") baut direkt darauf auf.
|
||
5. **`ReleaseDetailPageData` ist ungecacht, `NewsroomDetailPageData` gecacht**,
|
||
und der Newsroom zahlt seit dem 410-Umbau einen zusätzlichen Slug-Lookup
|
||
**vor** dem Cache (`NewsroomDetailPageData.php:34-40`). Das ist bewusst so
|
||
entschieden und im Code begründet; sobald 1b/1c den Newsroom zum Produkt
|
||
machen und die Aufrufzahlen steigen, gehört die Entscheidung noch einmal auf
|
||
den Tisch — eine gecachte Menge entfernter Slugs mit kurzer TTL erreicht
|
||
dasselbe ohne Query pro Aufruf.
|
||
|
||
---
|
||
|
||
## 7 Empfohlene Reihenfolge
|
||
|
||
**Vor dem nächsten Deploy** (betrifft laufenden Betrieb):
|
||
|
||
1. H1 — `Cache::forget()` in die beiden Such-Migrationen. Sonst kostet der
|
||
FULLTEXT-Umzug eine Minute 500er auf jeder Suche.
|
||
2. H2 — Flush aus der Schleife holen, `chunkById()` statt `get()`.
|
||
3. H3 + H4 — Umkehrbarkeit von `gone_at` geradeziehen, jeweils mit Test.
|
||
4. M5 — `Cache::forget()` in die Watermark-Migration.
|
||
|
||
**Direkt danach** (Werkzeug, größter Hebel auf alles Weitere):
|
||
|
||
5. P1 — `parseModelCastsMethod: true`, Scope-Annotationen, Baseline neu,
|
||
`composer analyse` ins CI.
|
||
|
||
**Vor dem ersten Roadmap-Arbeitspaket:**
|
||
|
||
6. M9 — Bounce-Suppression vervollständigen (Blocker für 1b.6/1d.3/3.2).
|
||
7. M4 — Aufbewahrung `press_release_events` entscheiden (blockiert 0.3/1c.1).
|
||
8. Index auf `companies.email` (Voraussetzung für 1a).
|
||
|
||
**Laufend, ohne Eile:**
|
||
|
||
9. M1, M2, M3, M6, M7, M8 sowie L1–L9.
|