presseportale/dev/archive/2026-09-nutzergewinnung/bestandsaufnahme-code.md
Kevin Adametz 8e3554bf91 dev: Doku aufgeräumt, neue Roadmap als einzige Liste offener Punkte
- 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>
2026-10-09 11:25:09 +00:00

18 KiB
Raw Permalink Blame History

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.

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:

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

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

$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

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):

  1. P1 — parseModelCastsMethod: true, Scope-Annotationen, Baseline neu, composer analyse ins CI.

Vor dem ersten Roadmap-Arbeitspaket:

  1. M9 — Bounce-Suppression vervollständigen (Blocker für 1b.6/1d.3/3.2).
  2. M4 — Aufbewahrung press_release_events entscheiden (blockiert 0.3/1c.1).
  3. Index auf companies.email (Voraussetzung für 1a).

Laufend, ohne Eile:

  1. M1, M2, M3, M6, M7, M8 sowie L1–L9.