# 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 */` 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.