CI/CD Pipeline Design for Symfony + Next.js: The Configuration That Ships Fast and Stays Green
A CI/CD Symfony Next.js pipeline almost never gets designed. It accretes. Someone adds a PHPUnit step during the first sprint, a colleague bolts on PHPStan after a production bug, ESLint arrives with the frontend rewrite, and Playwright shows up the week a regression slips past everyone. Each addition is reasonable in isolation. Eighteen months later the pipeline runs for 18 minutes, every step is sequential, developers open a second PR while waiting for the first, and the green checkmark has stopped meaning anything because half the team merges on red and fixes forward.
This post throws that history out and designs a pipeline for a Symfony backend plus Next.js frontend monorepo from first principles. The goal is narrow and measurable: fast feedback on every push, a checkmark that is trustworthy enough to gate merges, and a deploy step that will not corrupt your database when a migration fails. Everything below is a configuration choice with a reason attached, not a best-practice checklist.
The Job Graph Is the Design, Everything Else Is Detail
The single most expensive mistake in CI is running independent work sequentially. PHPUnit does not depend on ESLint. TypeScript type-checking does not depend on PHPStan. Yet the default pipeline runs them one after another because that is the order someone happened to add them. Reordering this into a fan-out graph is usually the largest speedup available, and it costs nothing but a rethink of your workflow file.
The correct mental model is three tiers. Tier one is a single fast install-and-lint gate that fails in under a minute on obvious mistakes: dependency install, composer validate, formatting, and lint. Tier two is the parallel fan-out where the expensive independent jobs run at once: PHPUnit, PHPStan, Rector in dry-run mode, ESLint, tsc --noEmit, and the Next.js build. Tier three is the slow integration and end-to-end layer, Playwright and any Doctrine-backed integration tests, which only starts once tier two is green so you are not paying for browser automation on a branch that fails type-checking.
In GitHub Actions this maps directly onto jobs connected by needs. The parallel jobs in tier two share no needs between them, so the runner schedules them concurrently. Playwright declares needs: [phpunit, phpstan, eslint, typecheck, build] so it waits for the cheap signals first. The wall-clock time of the pipeline becomes the length of its longest single path, not the sum of every step, and on a well-partitioned monorepo that path is usually the Next.js build or the Playwright suite rather than anything you can trim further.
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, see below
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 */ ]
The needs: setup on every tier-two job looks like it serializes things, but setup is a sub-minute gate. Paying one minute up front to avoid five identical composer install failures reporting the same missing dependency is a good trade. It also gives you one clear place to warm caches.
Fixtures Decide Whether Your Integration Tests Are Fast or Flaky
The database strategy is where most Symfony pipelines quietly bleed time and reliability. Two failure modes dominate. The first is loading a full fixture set through Doctrine's ORM layer before every test class, which is correct but slow because it round-trips thousands of inserts through the entity manager. The second is sharing one database across parallel test processes, which is fast until two tests race on the same row and you get failures that only reproduce in CI.
The configuration that ships fast and stays green uses a PostgreSQL service container, loads fixtures once into a template, and gives each test transaction a clean slate by wrapping every test in a transaction that rolls back. Symfony's dama/doctrine-test-bundle does exactly this: it begins a transaction in setUp and rolls it back in tearDown, so the database returns to its seeded state between tests without re-running fixtures. Seed once, run hundreds of tests against the seed, never persist anything. This turns a 90-second fixture-heavy suite into a 15-second one and removes the entire class of order-dependent failures, because no test can see another test's writes.
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
Note that we run migrations against the test database rather than doctrine:schema:create. This is deliberate. If your tests pass against a schema built from schema:create but production is built from migrations, your migrations are untested and you will discover the drift during a deploy. Running the migration chain in CI makes the pipeline a continuous test of the migrations themselves, which matters more than it sounds when you reach the deploy step below.
Docker Layer Caching Is Where the Minutes Actually Hide
Teams obsess over test speed and ignore the image build, which is frequently the longest single job. A naive Dockerfile that copies the whole project and then runs composer install reinstalls every dependency on every push because the cache key changes whenever any source file changes. The fix is ordering and cache scoping, not a faster runner.
Copy your composer.json, composer.lock, package.json, and package-lock.json first, install dependencies, and only then copy application code. Now the expensive dependency layers are cached against the lockfiles and invalidate only when you actually change a dependency. Pair this with BuildKit's registry-backed cache in GitHub Actions using cache-from and cache-to with mode=max, and a build that took four minutes on every push drops to about thirty seconds when dependencies are unchanged, because the runner pulls the cached layers instead of rebuilding them.
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
The mode=max matters. The default min mode caches only the final image layers, not the intermediate build stages, so a multi-stage Dockerfile that compiles frontend assets in one stage loses its cache on the part that takes longest. Caching intermediate stages is exactly what you want in a monorepo where the frontend build and the PHP dependency install are separate stages.
The Deploy Step Has One Job: Do Not Break the Database
Everything up to here is about speed. The deploy step is about safety, and the single riskiest moment in a Symfony deploy is running Doctrine migrations. A migration that adds a nullable column is safe to run before traffic switches. A migration that renames a column or drops one is not, because the old code still running during a rolling deploy will query a schema that no longer exists.
The configuration that stays green runs migrations as an explicit, gated step before traffic switches, with a tripwire that halts the deploy if the migration fails rather than leaving you with new code pointed at an old schema or vice versa. Run doctrine:migrations:migrate against production, check its exit code, and only proceed to switch traffic on success. If it fails, the old version keeps serving requests against the schema it was built for, and you have lost nothing but a deploy attempt.
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 }}
The deeper discipline this enforces is the expand-and-contract migration pattern: never rename or drop in a single deploy. First expand by adding the new column and writing to both, deploy, backfill, then contract by removing the old column in a later deploy once no running code references it. The pipeline cannot force you to write migrations this way, but gating traffic behind a migration tripwire makes the cost of ignoring it visible immediately rather than at 3 a.m. This is the same reasoning behind running migrations in the test job earlier: the pipeline treats your migration chain as a first-class artifact, tested on every push and gated on every deploy. If migration safety is a recurring source of production incidents for your team, it is worth a focused look at how your legacy code and schema evolve together.
Branch Protection That Does Not Require Babysitting
A pipeline is only a gate if merges cannot bypass it, and the failure mode here is social, not technical. If branch protection requires a human reviewer on every change but the team is three people, review becomes a bottleneck and people start rubber-stamping. The configuration that works blocks merges on the automated signals that machines are good at judging and reserves human review for changes that actually need judgment.
Require the status checks to pass, phpunit, phpstan, eslint, typecheck, and playwright, as a hard gate on master. Require branches to be up to date before merging so you never merge code that passed CI against a stale base. Enable auto-merge so a PR that goes green merges itself without someone watching the tab. What you do not do is require a reviewer count so high that CI becomes a formality people route around. The machine checks correctness; the humans check design, and you only block on the second when the change is large enough to warrant it.
The reason this stays green over time is that the gate is honest. Every check in the required list is one the team believes in, runs fast enough that waiting is reasonable, and fails only for real problems. The moment a required check is flaky or slow, developers learn to ignore it, and an ignored gate is worse than no gate because it launders bad merges with a green checkmark. Pipeline health is therefore a maintenance commitment, not a one-time setup: a flaky Playwright test is a production incident waiting to happen, and it deserves the same urgency. A recurring code quality review of the pipeline itself is a cheap way to keep the signal honest.
Putting It Together
A pipeline that ships fast and stays green is not a longer list of steps. It is four decisions made deliberately. Partition the work into a fan-out graph so wall-clock time is the longest path and not the sum. Seed the test database once and roll back per test so integration tests are fast and deterministic. Order your Dockerfile and scope your cache so dependency layers survive across pushes. Gate the deploy behind a migration tripwire so a failed schema change never reaches live traffic. Branch protection then turns that trustworthy signal into a merge gate that runs itself.
Most teams have all the individual pieces already, scattered across a pipeline that grew by accretion. The value is in the arrangement, and the arrangement is worth revisiting deliberately rather than one panicked step at a time. If your CI has crept past ten minutes and the green checkmark has stopped meaning anything, we help engineering teams redesign the pipeline and the surrounding custom software delivery that depends on it. Reach out at hello@wolf-tech.io or read more at wolf-tech.io.

