CI/CD-Pipeline-Design für Symfony + Next.js: Die Konfiguration, die schnell ausliefert und grün bleibt

#CI/CD Symfony Next.js
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Eine CI/CD-Pipeline für Symfony und Next.js wird fast nie entworfen. Sie lagert sich an. Jemand ergänzt im ersten Sprint einen PHPUnit-Schritt, ein Kollege schraubt nach einem Produktionsfehler PHPStan dran, ESLint kommt mit dem Frontend-Rewrite, und Playwright taucht in der Woche auf, in der eine Regression an allen vorbeirutscht. Jede Ergänzung ist für sich genommen sinnvoll. Achtzehn Monate später läuft die Pipeline 18 Minuten, jeder Schritt ist sequentiell, Entwickler öffnen einen zweiten PR, während sie auf den ersten warten, und der grüne Haken bedeutet nichts mehr, weil das halbe Team auf Rot merged und danach nachbessert.

Dieser Beitrag wirft diese Geschichte weg und entwirft eine Pipeline für ein Monorepo aus Symfony-Backend und Next.js-Frontend von Grund auf. Das Ziel ist eng und messbar: schnelles Feedback bei jedem Push, ein Haken, der vertrauenswürdig genug ist, um Merges zu gaten, und ein Deploy-Schritt, der deine Datenbank nicht beschädigt, wenn eine Migration fehlschlägt. Alles Folgende ist eine Konfigurationsentscheidung mit angehängter Begründung, keine Best-Practice-Checkliste.

Der Job-Graph ist das Design, alles andere ist Detail

Der teuerste Fehler in CI ist, unabhängige Arbeit sequentiell laufen zu lassen. PHPUnit hängt nicht von ESLint ab. TypeScript-Typprüfung hängt nicht von PHPStan ab. Trotzdem läuft die Standard-Pipeline nacheinander, weil das die Reihenfolge ist, in der jemand die Schritte zufällig ergänzt hat. Das in einen Fan-out-Graphen umzubauen ist meist die größte verfügbare Beschleunigung, und es kostet nichts außer einem Umdenken in deiner Workflow-Datei.

Das richtige mentale Modell hat drei Stufen. Stufe eins ist ein einzelnes schnelles Install-und-Lint-Gate, das bei offensichtlichen Fehlern in unter einer Minute scheitert: Dependency-Install, composer validate, Formatierung und Lint. Stufe zwei ist der parallele Fan-out, in dem die teuren unabhängigen Jobs gleichzeitig laufen: PHPUnit, PHPStan, Rector im Dry-Run-Modus, ESLint, tsc --noEmit und der Next.js-Build. Stufe drei ist die langsame Integrations- und End-to-End-Schicht, also Playwright und alle Doctrine-gestützten Integrationstests, die erst startet, wenn Stufe zwei grün ist, damit du keine Browser-Automatisierung auf einem Branch bezahlst, der schon die Typprüfung nicht besteht.

In GitHub Actions bildet sich das direkt auf Jobs ab, die über needs verbunden sind. Die parallelen Jobs in Stufe zwei teilen kein needs untereinander, also plant der Runner sie gleichzeitig ein. Playwright deklariert needs: [phpunit, phpstan, eslint, typecheck, build] und wartet damit zuerst auf die günstigen Signale. Die Laufzeit der Pipeline wird zur Länge ihres längsten einzelnen Pfads statt zur Summe aller Schritte, und in einem gut partitionierten Monorepo ist dieser Pfad meist der Next.js-Build oder die Playwright-Suite statt etwas, das du weiter kürzen kannst.

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', coverage: none }
      - run: composer validate --strict
      - run: composer install --no-progress --prefer-dist
      - run: vendor/bin/php-cs-fixer fix --dry-run --diff

  phpunit:
    needs: setup
    runs-on: ubuntu-latest
    # ... Service-Container + Fixtures, siehe unten
  phpstan:
    needs: setup
    runs-on: ubuntu-latest
    steps: [ /* checkout, php, composer, vendor/bin/phpstan analyse */ ]
  eslint:
    needs: setup
    runs-on: ubuntu-latest
    steps: [ /* checkout, node, npm ci, npm run lint */ ]

Das needs: setup auf jedem Job der zweiten Stufe sieht aus, als würde es serialisieren, aber setup ist ein Gate von unter einer Minute. Eine Minute vorab zu zahlen, um fünf identische composer install-Fehlschläge zu vermeiden, die alle dieselbe fehlende Abhängigkeit melden, ist ein guter Tausch. Es gibt dir außerdem einen klaren Ort, an dem du Caches aufwärmst.

Fixtures entscheiden, ob deine Integrationstests schnell oder flaky sind

Die Datenbankstrategie ist die Stelle, an der die meisten Symfony-Pipelines still Zeit und Verlässlichkeit verlieren. Zwei Fehlermodi dominieren. Der erste ist, vor jeder Testklasse ein vollständiges Fixture-Set über Doctrines ORM-Schicht zu laden, was korrekt, aber langsam ist, weil tausende Inserts durch den Entity Manager laufen. Der zweite ist, eine Datenbank über parallele Testprozesse zu teilen, was schnell ist, bis zwei Tests um dieselbe Zeile rennen und du Fehlschläge bekommst, die sich nur in CI reproduzieren lassen.

Die Konfiguration, die schnell ausliefert und grün bleibt, nutzt einen PostgreSQL-Service-Container, lädt die Fixtures einmal in eine Vorlage und gibt jeder Testtransaktion einen sauberen Stand, indem jeder Test in eine Transaktion gewickelt wird, die zurückgerollt wird. Symfonys dama/doctrine-test-bundle macht genau das: Es beginnt eine Transaktion in setUp und rollt sie in tearDown zurück, sodass die Datenbank zwischen Tests in ihren geseedeten Zustand zurückkehrt, ohne Fixtures erneut auszuführen. Einmal seeden, hunderte Tests gegen den Seed laufen lassen, nie etwas persistieren. Das macht aus einer fixture-lastigen 90-Sekunden-Suite eine von 15 Sekunden und beseitigt die gesamte Klasse reihenfolgeabhängiger Fehlschläge, weil kein Test die Schreibvorgänge eines anderen sehen kann.

  phpunit:
    needs: setup
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: test, POSTGRES_DB: app_test }
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready --health-interval 5s
          --health-timeout 3s --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', coverage: none }
      - run: composer install --no-progress --prefer-dist
      - run: bin/console doctrine:migrations:migrate --no-interaction --env=test
      - run: bin/console doctrine:fixtures:load --no-interaction --env=test
      - run: vendor/bin/phpunit

Beachte, dass wir Migrationen gegen die Testdatenbank ausführen statt doctrine:schema:create. Das ist Absicht. Wenn deine Tests gegen ein per schema:create gebautes Schema laufen, Produktion aber aus Migrationen entsteht, sind deine Migrationen ungetestet und du entdeckst die Abweichung während eines Deploys. Die Migrationskette in CI auszuführen macht die Pipeline zu einem kontinuierlichen Test der Migrationen selbst, was mehr zählt, als es klingt, sobald du beim Deploy-Schritt weiter unten ankommst.

Docker-Layer-Caching ist der Ort, an dem die Minuten wirklich stecken

Teams sind besessen von Testgeschwindigkeit und ignorieren den Image-Build, der häufig der längste einzelne Job ist. Ein naives Dockerfile, das das ganze Projekt kopiert und dann composer install ausführt, installiert bei jedem Push alle Abhängigkeiten neu, weil sich der Cache-Key ändert, sobald irgendeine Quelldatei sich ändert. Die Lösung ist Reihenfolge und Cache-Scoping, kein schnellerer Runner.

Kopiere zuerst composer.json, composer.lock, package.json und package-lock.json, installiere die Abhängigkeiten und kopiere erst danach den Anwendungscode. Jetzt sind die teuren Dependency-Layer gegen die Lockfiles gecacht und werden nur ungültig, wenn du tatsächlich eine Abhängigkeit änderst. Kombiniere das mit BuildKits registry-gestütztem Cache in GitHub Actions über cache-from und cache-to mit mode=max, und ein Build, der bei jedem Push vier Minuten brauchte, fällt auf etwa dreißig Sekunden, wenn sich die Abhängigkeiten nicht geändert haben, weil der Runner die gecachten Layer zieht statt sie neu zu bauen.

  build-image:
    needs: [phpunit, phpstan, eslint, typecheck]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: registry.example.com/app:${{ github.sha }}
          cache-from: type=registry,ref=registry.example.com/app:buildcache
          cache-to: type=registry,ref=registry.example.com/app:buildcache,mode=max

mode=max ist entscheidend. Der Standardmodus min cacht nur die finalen Image-Layer, nicht die Zwischenstufen des Builds. Ein mehrstufiges Dockerfile, das Frontend-Assets in einer eigenen Stufe kompiliert, verliert damit den Cache genau an der Stelle, die am längsten dauert. Zwischenstufen zu cachen ist genau das, was du in einem Monorepo willst, in dem Frontend-Build und PHP-Dependency-Install getrennte Stufen sind.

Der Deploy-Schritt hat eine Aufgabe: die Datenbank nicht kaputt machen

Alles bis hierher dreht sich um Geschwindigkeit. Beim Deploy-Schritt geht es um Sicherheit, und der riskanteste Moment eines Symfony-Deploys ist das Ausführen der Doctrine-Migrationen. Eine Migration, die eine nullable Spalte hinzufügt, kann gefahrlos vor dem Traffic-Umschalten laufen. Eine Migration, die eine Spalte umbenennt oder löscht, nicht, weil der während eines Rolling Deploys noch laufende alte Code ein Schema abfragt, das es nicht mehr gibt.

Die Konfiguration, die grün bleibt, führt Migrationen als expliziten, gegateten Schritt vor dem Traffic-Umschalten aus, mit einem Stolperdraht, der den Deploy anhält, wenn die Migration fehlschlägt, statt dich mit neuem Code auf altem Schema (oder umgekehrt) zurückzulassen. Führe doctrine:migrations:migrate gegen Produktion aus, prüfe den Exit-Code und schalte den Traffic nur bei Erfolg um. Schlägt es fehl, bedient die alte Version weiterhin Requests gegen das Schema, für das sie gebaut wurde, und du hast nichts verloren außer einem Deploy-Versuch.

  deploy:
    needs: build-image
    if: github.ref == 'refs/heads/master'
    runs-on: ubuntu-latest
    steps:
      - name: Run migrations (tripwire on failure)
        run: |
          if ! bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration; then
            echo "Migration failed. Aborting before traffic switch." >&2
            exit 1
          fi
      - name: Switch traffic
        run: ./deploy/promote.sh ${{ github.sha }}

Die tiefere Disziplin, die das erzwingt, ist das Expand-and-Contract-Migrationsmuster: nie umbenennen oder löschen in einem einzigen Deploy. Zuerst expandieren, also die neue Spalte hinzufügen und in beide schreiben, deployen, backfillen, dann kontrahieren, also die alte Spalte in einem späteren Deploy entfernen, sobald kein laufender Code sie mehr referenziert. Die Pipeline kann dich nicht zwingen, Migrationen so zu schreiben, aber den Traffic hinter einem Migrations-Stolperdraht zu gaten macht die Kosten des Ignorierens sofort sichtbar statt um 3 Uhr nachts. Das ist dieselbe Logik wie beim Ausführen der Migrationen im Test-Job weiter oben: Die Pipeline behandelt deine Migrationskette als erstklassiges Artefakt, getestet bei jedem Push und gegatet bei jedem Deploy. Wenn Migrationssicherheit für dein Team eine wiederkehrende Quelle von Produktionsvorfällen ist, lohnt sich ein fokussierter Blick darauf, wie sich Legacy-Code und Schema gemeinsam entwickeln.

Branch Protection, die kein Babysitting braucht

Eine Pipeline ist nur dann ein Gate, wenn Merges sie nicht umgehen können, und der Fehlermodus ist hier sozial, nicht technisch. Wenn Branch Protection für jede Änderung einen menschlichen Reviewer verlangt, das Team aber aus drei Leuten besteht, wird Review zum Flaschenhals und man fängt an, Abnicken statt Prüfen. Die Konfiguration, die funktioniert, blockiert Merges anhand der automatisierten Signale, die Maschinen gut beurteilen können, und reserviert menschliches Review für Änderungen, die wirklich Urteilsvermögen brauchen.

Verlange, dass die Status-Checks phpunit, phpstan, eslint, typecheck und playwright bestehen, als hartes Gate auf master. Verlange, dass Branches vor dem Merge aktuell sind, damit du nie Code mergst, der gegen eine veraltete Basis grün war. Aktiviere Auto-Merge, damit ein PR, der grün wird, sich selbst mergt, ohne dass jemand den Tab beobachtet. Was du nicht tust: eine so hohe Zahl an Reviewern verlangen, dass CI zur Formalie wird, die man umgeht. Die Maschine prüft Korrektheit, die Menschen prüfen Design, und du blockierst nur auf Zweiteres, wenn die Änderung groß genug ist, um es zu rechtfertigen.

Der Grund, warum das über die Zeit grün bleibt, ist, dass das Gate ehrlich ist. Jeder Check in der Pflichtliste ist einer, an den das Team glaubt, läuft schnell genug, dass Warten zumutbar ist, und schlägt nur bei echten Problemen fehl. In dem Moment, in dem ein Pflicht-Check flaky oder langsam wird, lernen Entwickler ihn zu ignorieren, und ein ignoriertes Gate ist schlimmer als gar keins, weil es schlechte Merges mit einem grünen Haken wäscht. Pipeline-Gesundheit ist deshalb eine Wartungsverpflichtung, kein einmaliges Setup: Ein flaky Playwright-Test ist ein Produktionsvorfall im Wartezustand und verdient dieselbe Dringlichkeit. Ein regelmäßiger Code-Quality-Review der Pipeline selbst ist ein günstiger Weg, das Signal ehrlich zu halten.

Alles zusammengesetzt

Eine Pipeline, die schnell ausliefert und grün bleibt, ist keine längere Liste von Schritten. Sie besteht aus vier bewusst getroffenen Entscheidungen. Partitioniere die Arbeit in einen Fan-out-Graphen, damit die Laufzeit der längste Pfad ist und nicht die Summe. Seede die Testdatenbank einmal und rolle pro Test zurück, damit Integrationstests schnell und deterministisch sind. Ordne dein Dockerfile und begrenze deinen Cache so, dass Dependency-Layer über Pushes hinweg überleben. Gate den Deploy hinter einem Migrations-Stolperdraht, damit eine fehlgeschlagene Schema-Änderung nie echten Traffic erreicht. Branch Protection macht aus diesem vertrauenswürdigen Signal dann ein Merge-Gate, das sich selbst betreibt.

Die meisten Teams haben alle Einzelteile bereits, verstreut über eine Pipeline, die durch Anlagerung gewachsen ist. Der Wert liegt in der Anordnung, und die Anordnung ist es wert, bewusst überdacht zu werden statt einen panischen Schritt nach dem anderen. Wenn deine CI über zehn Minuten hinausgekrochen ist und der grüne Haken nichts mehr bedeutet: Wir helfen Engineering-Teams, die Pipeline und die davon abhängige individuelle Softwareauslieferung neu zu entwerfen. Melde dich unter hello@wolf-tech.io oder lies mehr auf wolf-tech.io.