- 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>
18 KiB
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
parseModelCastsMethod: trueinphpstan.neonsetzen.- Der Rest der 266 Meldungen ist überwiegend echtes Rauschen ohne Codefehler:
84 ×
property.notFound(withCount-Aliasse wierecent_releases_count, Relationen ohne Generic), 50 ×method.notFound(eigene Scopes wieforPortalValues(),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. - Baseline danach neu erzeugen, nicht vorher — sonst zementiert sie die falschen Cast-Meldungen.
composer analyseintests.ymlaufnehmen. 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ämtlicheweb.-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) |
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.
companies.emailhat 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 INDEXläuft auch mit vorhandenem FULLTEXT-IndexINPLACE— nurADD COLUMNerzwingt den COPY-Rebuild.- Aufbewahrung von
press_release_eventsist 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. - Bounce-Suppression ist unvollständig (M9) — harter Blocker für 1b.6, 1d.3 und 3.2.
- 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. ReleaseDetailPageDataist ungecacht,NewsroomDetailPageDatagecacht, 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):
- H1 —
Cache::forget()in die beiden Such-Migrationen. Sonst kostet der FULLTEXT-Umzug eine Minute 500er auf jeder Suche. - H2 — Flush aus der Schleife holen,
chunkById()stattget(). - H3 + H4 — Umkehrbarkeit von
gone_atgeradeziehen, jeweils mit Test. - M5 —
Cache::forget()in die Watermark-Migration.
Direkt danach (Werkzeug, größter Hebel auf alles Weitere):
- P1 —
parseModelCastsMethod: true, Scope-Annotationen, Baseline neu,composer analyseins CI.
Vor dem ersten Roadmap-Arbeitspaket:
- M9 — Bounce-Suppression vervollständigen (Blocker für 1b.6/1d.3/3.2).
- M4 — Aufbewahrung
press_release_eventsentscheiden (blockiert 0.3/1c.1). - Index auf
companies.email(Voraussetzung für 1a).
Laufend, ohne Eile:
- M1, M2, M3, M6, M7, M8 sowie L1–L9.