Symfony 7 auf 8: Was die Docs nicht erzählen

Ein praktisches Playbook für die 7.x zu 8.0 Migration: Container-Drift, framework.yaml-Drift, Asset-Pipeline und Doctrine, alles was die Docs auslassen.

Eine Notizbuchseite mit einem skizzierten Symfony 7 auf 8 Upgrade-Plan in vier Spalten (Analyse, Vorbereitung, Anpassung, Launch), mit Klebezetteln zu Deprecations, Rector, Bundles, Container-Diff, framework.yaml-Drift, AssetMapper-Reife, Doctrine-ORM-Kompatibilität und PHP-Versionsreihenfolge, plus einer Spalte mit Release-Daten von Symfony 7.0 bis 8.0

Symfony 8 ist da. Wenn Sie auf 7.x sind, sagt Ihnen der offizielle Upgrade-Guide, Sie sollen Ihre Deprecations beheben, Rector laufen lassen und die Constraint bumpen. Alles davon stimmt. Nichts davon ist der Teil, der Teams zwei Wochen nach dem Branch überrascht.

In Symfony-7.x-Upgrade-Arbeit ist das Muster oft gleich: Die Deprecations sind einfach, der Dependency-Tree benimmt sich anständig, die Test-Suite bleibt meistens grün, und dann trifft die Sache das Team mit etwas, das die Release-Notes in einer einzigen Zeile am unteren Rand einer Seite erwähnen, die niemand gelesen hat. Dieser Beitrag sammelt diese Momente. Es ist das Playbook, das wir Teams in der Audit-Phase unseres Symfony-Upgrade-Engagements übergeben, und es ist das, was die Docs nicht erzählen, weil sie wohlwollend annehmen, dass Sie es bereits wissen.

Wenn Sie noch auf 6.x sind, ist dieser Beitrag noch nicht für Sie. Lesen Sie zuerst unser Symfony-Major-Versionen-Upgrade-Playbook, laufen Sie 6.x auf 7.x als eigenes Projekt, liefern Sie es aus, stabilisieren Sie eine Woche, dann kommen Sie zurück. Einen Major in einem PR überspringen zu wollen ist der zuverlässigste Weg, ein Upgrade in einen quartalslangen Todesmarsch zu verwandeln.

Reihenfolge der Operationen, nochmal, weil beim ersten Mal niemand zuhört

PHP zuerst. Symfony danach. Niemals beides im selben PR.

Symfony 8 verlangt PHP 8.4. Wenn Ihre Produktion heute auf 8.2 oder 8.3 läuft, haben Sie mindestens einen PHP-Minor-Bump vor sich. Die Versuchung ist, ihn in den Symfony-Branch zu packen, weil das Changelog sagt, dass es sowieso passieren muss. Tun Sie das nicht.

PHP 8.3 hat typisierte Klassenkonstanten, das #[\Override]-Attribut und einige subtile String-zu-Int-Coercion-Änderungen eingeführt. PHP 8.4 hat Property Hooks eingeführt und eine aggressive Deprecation impliziter nullable Parametertypen. Jedes davon hat eigene Fehlermodi, und sie mit einem Symfony-Major zu verschränken verdoppelt die Oberfläche, die Sie debuggen müssen, wenn das Staging-Deploy rot wird. Wir machen es immer so:

  1. PHP auf den Zielminor in einem eigenen PR bumpen. Volle Test-Suite. Anwendung lokal laufen lassen. Auf Staging ausliefern. Eine Woche warten. Auf Produktion ausliefern.
  2. Sobald PHP sitzt, das Symfony-Upgrade abzweigen.

Die Woche Wartezeit ist nicht optional. Sie ist, wann Sie das Subtile finden: eine Vendor-Library, die unter PHP 8.4 leicht andere Ausgabe produziert, eine Regex, die jetzt ein Zeichen mehr matched, ein Datumsformat, das eine Deprecation in Batch-Jobs auswirft, die nur sonntags laufen.

Der Container ist nach dem Upgrade nicht mehr derselbe

Symfonys Container ändert sich zwischen jedem Major. Autoconfiguration-Defaults verschieben sich. Service-Tagging-Konventionen ändern sich. Compiler-Pass-Reihenfolgen werden justiert. Die Release-Notes erwähnen das in einem Satz und ziehen weiter. Die Konsequenz in einer echten App: Ein Service, von dem Sie abhängen, existiert vor dem Upgrade und nicht mehr danach, oder existiert mit anderer Sichtbarkeit, oder hat andere Tags angelegt.

Wir machen das immer vor dem Merge des Upgrade-Branches:

Bash
git checkout main
symfony console debug:container --show-private > before.txt
git checkout symfony-8-upgrade
symfony console debug:container --show-private > after.txt
diff before.txt after.txt > container-diff.txt

Dann lesen wir container-diff.txt. Jeder entfernte Service ist ein Ort, an dem ein Stück Code zur Laufzeit fehlschlägt, oft Wochen nach dem Upgrade-Release, wenn ein selten benutzter Codepfad feuert. Jedes geänderte Tag ist ein Kandidat für ein stilles Verhaltens-Drift. Jeder neu öffentliche Service legt vielleicht etwas frei, das Sie intern halten wollten.

Dieser Schritt dauert eine halbe Stunde. Er fängt Dinge ab, die die Test-Suite nicht abfängt, weil Tests Services über öffentliche APIs ansprechen, nicht über den Container.

framework.yaml-Drift ist der leise Killer

Öffnen Sie config/packages/framework.yaml aus einem frischen symfony new gegen Symfony 8.0. Jetzt Ihre. Sie haben nicht dieselbe Form. Sie hatten ungefähr seit Symfony 5 nicht mehr dieselbe Form.

Der Drift ist in fünfundneunzig Prozent der Fälle harmlos, weil die deprecated Keys mit einer Warnung weiterhin akzeptiert werden. Die anderen fünf Prozent sind, wo Sie einen Samstag verlieren. Ein Key wird in einem Minor entfernt, den Sie vor Monaten angewendet haben, und Ihre Config fällt seitdem still auf einen Default zurück, der nicht der ist, den Sie wollten. Das Verhalten ist auf Staging korrekt, weil Staging die Produktion spiegelt. Es ist gegen den neuen Major inkorrekt, weil sich der Default geändert hat.

Der Fix ist unspektakulär. Ein frisches Symfony-8-Projekt generieren. Dessen framework.yaml, security.yaml, messenger.yaml, mailer.yaml und routes.yaml gegen Ihre diffen. Für jede Differenz entscheiden: Wollen wir die neue Form, oder hatten wir einen Grund für die alte? Die Antwort in einem Kommentar dokumentieren.

Wir machen diesen Durchgang bei jedem Major. Er fängt jedes Mal ein, zwei echte Bugs ab.

Den Deprecation-Helper richtig zu konfigurieren ist das, was niemand tut

Die meisten Teams schalten den Deprecation-Helper einmal ein, lassen ihn auf Defaults und halten die Sache für erledigt. Die Defaults sind für die meisten Produktions-Codebasen falsch.

Die korrekte Einstellung für eine 7.x-Anwendung, die ein zeitnahes 8.0-Upgrade plant:

XML
<phpunit>
    <php>
        <env name="SYMFONY_DEPRECATIONS_HELPER"
             value="max[self]=0&amp;max[direct]=0&amp;quiet[]=indirect&amp;quiet[]=other"/>
    </php>
</phpunit>

max[self]=0 heißt: keine vom eigenen Code ausgelöste Deprecation ist akzeptabel. max[direct]=0 heißt: keine von einer direkt eingebundenen Vendor-Library ausgelöste Deprecation ist akzeptabel. quiet[]=indirect und quiet[]=other heißen: Deprecations von transitiven Abhängigkeiten werden geloggt, aber lassen den Build nicht scheitern, weil Sie sie nicht immer im selben Fenster fixen können.

Dies anzuschalten macht die Test-Suite zum Deprecation-Dashboard. Wenn CI grün ist, haben Sie null Deprecation-Schulden in First-Party-Code. Wenn CI rot ist, haben Sie eine präzise Liste zum Abarbeiten. Der psychologische Effekt von “PRs können mit einer Deprecation drin nicht gemergt werden” ist größer als der technische: Engineers fixen die Deprecation in dem PR, der sie eingeführt hat, nicht in einem Ticket, das niemand zieht.

Die PHPUnit-Bridge-Dokumentation erklärt das volle Vokabular, falls Sie feiner tunen müssen.

Rector erledigt den langweiligen Teil, aber lesen Sie seinen Output

Rector mit dem Symfony-8-Set ist das, was in diesem ganzen Prozess am nächsten an Magie herankommt. Es schreibt Attribute-Imports um, ersetzt deprecated Konstruktor-Muster, fixt Typhint-Verschiebungen und aktualisiert Aufrufe auf umbenannte Methoden. Auf einer gesunden 7.x-Codebasis erledigt es neunzig Prozent der mechanischen Arbeit in einem Nachmittag.

Die Falle ist, es als automatisch zu behandeln. Rector-Regeln sind Pattern-Matching-Werkzeuge. Sie behandeln die häufige Form eines Problems, nicht jede Variante. Wir haben Rector verpassen sehen:

  • Custom Doctrine Types, die eine Basisklasse erweiterten, deren Signatur sich änderte
  • Voter-Implementierungen, die eine zwischen Majors umbenannte protected Methode überschrieben
  • Compiler-Passes, die Container-Parameter durch String-Interpolation referenzierten statt durch den Array-Accessor
  • Custom Twig Extensions, die Symfony-Komponenten umwickelten, deren interne API sich verschob

Nichts davon sind Rector-Bugs. Es sind Stellen, an denen die Codebasis etwas tat, das die Regel nicht erkannt hat. Rector immer zuerst mit --dry-run laufen lassen, den Diff scannen und die Änderungen bewusst akzeptieren. In dem Moment, in dem Sie anfangen, Rector-PRs blind zu mergen, mergen Sie irgendwann einen, der etwas Subtiles bricht.

Ein minimales rector.php für den 7 auf 8 Übergang:

PHP
<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;
use Rector\ValueObject\PhpVersion;

return RectorConfig::configure()
    ->withPaths([__DIR__.'/src', __DIR__.'/tests', __DIR__.'/config'])
    ->withPhpVersion(PhpVersion::PHP_84)
    ->withSets([
        SymfonySetList::SYMFONY_80,
        SymfonySetList::SYMFONY_CODE_QUALITY,
        SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
    ])
    ->withImportNames(importShortClasses: false);

Beachten Sie das config/-Verzeichnis in withPaths. Genug Teams vergessen, dass Symfony-Konfiguration heutzutage PHP ist, und überspringen das Verzeichnis. Rector hat Regeln, die auch Config aktualisieren.

AssetMapper, Encore und die Asset-Pipeline-Falle

Wenn Sie auf Webpack Encore sind, zwingt Sie das Symfony-8-Upgrade nicht zum Umzug auf AssetMapper. Die Release-Notes sind diplomatisch darüber. Die Community ist nach unserer Erfahrung weitergezogen.

Das ist eine Stelle, an der Teams die Arbeit unterschätzen. Die Migration von Encore zu AssetMapper sieht wie ein Eintagsjob aus, weil die AssetMapper-Docs es einfach aussehen lassen. In einer echten App ist es selten ein Eintagsjob. Sie stoßen auf:

  • Encore-Plugins (PostCSS, Bildoptimierung, custom Loader), die kein AssetMapper-Äquivalent haben
  • Stimulus-Controller, verdrahtet durch bootstrap.js-Muster, die AssetMapper anders behandelt
  • Drittanbieter-JS, das nur als CommonJS-Modul ausgeliefert wird und nicht als ESM neu veröffentlicht wurde
  • CSP-Header, die spezifische Webpack-gehashte Dateinamen erlauben

Planen Sie AssetMapper als Schwesterprojekt, nicht als Teil des Symfony-8-PR. Wenn Sie sie zusammen ausliefern und die Asset-Pipeline sich auf dem Produktions-CDN danebenbenimmt, ist die Rollback-Strategie “das Symfony-Upgrade zurücknehmen”, und dieser Revert trägt jetzt für zwei Sachen statt für eine.

Doctrine ORM hat seinen eigenen Zeitplan

Doctrine ORM hat seinen eigenen Release-Rhythmus, und Symfony 8 verlangt keinen Doctrine-ORM-Major-Bump. Zum Zeitpunkt dieses Textes ist ORM 3.6 die aktuelle stabile Linie und erlaubt bereits Symfony-8-Komponenten. ORM 4 ist etwas, das Sie beobachten sollten, aber nicht blind in den Symfony-Branch packen.

Das praktische Risiko bleibt trotzdem real. Doctrine DBAL, Doctrine Bundle, Migrations, Custom Types, Identifier-Handling und Entity-Metadaten können alle auf Timelines laufen, die nicht zu Symfony passen. Nichts davon ist hart, wenn Sie es als eigenen Schritt behandeln. Es ist hart, wenn Sie es auf dem gleichen Branch wie den Symfony-Major packen, denn jetzt könnte jeder Doctrine-bezogene Fehler beides sein, und Sie müssen triagieren, bevor Sie fixen können.

Unsere Reihenfolge in echten Engagements:

  1. PHP-Minor-Bump, ausliefern, stabilisieren.
  2. Doctrine-Dependency-Cleanup, ausliefern, stabilisieren.
  3. Symfony-Major-Bump, ausliefern, stabilisieren.

Jede Etappe ein bis zwei Wochen. Die Gesamtzeit sechs bis acht Wochen. Die drei in einen Branch zu pressen sind zwölf Wochen forensisches Debugging.

Bundle-Kompatibilität, in 2026

Die Community hat Symfony 8 schneller als sonst aufgeholt. Die Bundles, die in Ihrer 7.x-App wichtig waren, haben wahrscheinlich bereits ein 8.x-Release. Die, die wir am häufigsten auditieren, in grober Reihenfolge der Wichtigkeit:

  • api-platform/core: v4-Serie unterstützt Symfony 8 von Anfang an. v3 nicht.
  • doctrine/doctrine-bundle: aktuelle Version unterstützt Symfony 8 im selben Major.
  • sonata-project/*: das Sonata-Team hängt typischerweise einen Minor hinterher. Pro Bundle prüfen.
  • sentry/sentry-symfony: aktuell halten, die Integration entwickelt sich schnell.
  • friendsofsymfony/*: gemischt. Mehrere haben Maintainer, die weitergezogen sind. Jedes ist eine “upgraden, forken, ersetzen oder entfernen”-Entscheidung.
  • liip/imagine-bundle: unterstützt Symfony 8 im aktuellen Minor, aber das Image-Processor-Backend prüfen.

Ein Bundle-Inventar bauen. Für jeden Eintrag: installierte Version, aktuelle Stable, ob die aktuelle Symfony 8 unterstützt, und der Wartungsstatus des Repositorys. Farbcodieren, wo die Antwort “nein” oder “das Repo hat seit achtzehn Monaten keinen Commit” ist. Das sind die, die das Upgrade von einer Woche zu einem Quartal machen.

Der Cutover selbst

Sobald alles oben erledigt ist, ist der eigentliche Cutover mechanisch. Wir machen ihn immer gleich:

  1. symfony/*-Constraints in composer.json auf 8.0.* bumpen (oder ^8.0, je nach Policy).
  2. composer update laufen lassen. Jede Zeile des Outputs lesen. Konflikte auflösen.
  3. Rector mit dem Symfony-8-Set. Diff prüfen. Bewusst akzeptieren.
  4. Volle Test-Suite. Alles Rote fixen.
  5. Container-Diff gegen den vorherigen Branch generieren. Diff durchgehen.
  6. framework.yaml-Diff gegen ein frisches Projekt generieren. Den Diff durchgehen.
  7. App lokal laufen lassen. Die Haupt-User-Journeys manuell durchklicken. Dinge, die die Test-Suite nicht angefasst hat, fallen hier um.
  8. Auf Staging ausliefern. Eventuelle Lasttests laufen lassen. Journeys nochmal durchgehen.
  9. Auf Produktion in einem angekündigten Off-Hours-Fenster ausliefern. Das vorherige Deployment rollback-bereit halten.
  10. Logs, Error-Rates und p95-Latenz achtundvierzig Stunden beobachten. Das Subtile zeigt sich an Tag zwei.

Wenn die vorherigen Abschnitte ordentlich gemacht wurden, ist das ein Ein-Wochen-Schritt. Wenn sie übersprungen wurden, ist das der mehrmonatige Todesmarsch, der Symfony-Upgrades ihren schlechten Ruf eingebracht hat. Der Todesmarsch ist nicht die Schuld des Frameworks. Er passiert, wenn ein Upgrade als einzelnes Event behandelt wird statt als sichtbare Spitze eines Eisbergs aus kontinuierlicher Hygiene.

Wann externe Hilfe sinnvoll ist

Wir sagen das mit der Voreingenommenheit, eine Symfony-Beratung zu betreiben, also wiegen Sie es entsprechend ab: Es gibt zwei Situationen, in denen externe Hilfe rational ist, und eine, in der sie Verschwendung ist.

Rationaler Fall eins: Sie sind mehr als einen Major zurück, und das Team hat es zweimal versucht und ist gescheitert. Das Muster des Scheiterns ist informativ, und ein strukturiertes Audit fördert den strukturellen Grund zutage, warum das Upgrade immer wieder kippt. Meistens ist die Antwort Bundle-Hygiene, die niemandem gehört, plus Deprecation-Schulden, die nie sichtbar gemacht wurden.

Rationaler Fall zwei: Sie sind im Zeitplan, aber Sie haben einen Termin (Vendor-End-of-Life, eine Payment-Integration, die ein neueres Symfony verlangt, ein Security-Advisory), und Sie können das Upgrade und den Rest der Roadmap nicht zur gleichen Zeit aufnehmen. Ein fokussiertes Team für sechs Wochen gibt Ihnen den Kalender zurück.

Verschwendender Fall: Sie wollen, dass jemand anderes das Upgrade macht, damit Ihr Team die Praktiken nicht lernen muss. Das funktioniert einmal. Beim nächsten Major, zwei Jahre später, sind Sie in derselben Lage, und die nächste Beratung rechnet Ihnen das nochmal in Rechnung. Nutzen Sie das Engagement, um die kontinuierlichen Praktiken zu installieren, sonst kaufen Sie einen Umzugsservice für ein Problem, das eine Umzugsfirma wollte.

Referenzen

Bereit, Ihre Architektur in den Griff zu bekommen?

Buchen Sie ein kostenloses 30-minütiges Gespräch mit Silas. Kein Verkaufsgespräch, nur ein direkter Austausch über Ihre Herausforderungen.

Antwort in der Regel innerhalb von 24 Stunden.

Kostenloses Gespräch buchen