A Laravel CI pipeline that actually catches things
Cloud & DevOps · Aug 2026 · 16 min read
Most CI runs tests and stops. Here is the full pipeline: static analysis, real service containers, migration-safety checks, boundary rules, and a nine-minute budget.
Most Laravel CI configurations run phpunit and call it done. That catches the bugs you already thought to write a test for, which is the smallest category of bug there is. This is the pipeline I actually use, in the order the stages run, with the reasoning for each — and a hard rule that the whole thing finishes inside ten minutes.
Step 0: order stages by how fast they say no
The single biggest quality-of-life change in CI is not what you run, it is the order. Put the cheap checks first so a formatting mistake fails in forty seconds rather than after the eight-minute test suite. Feedback speed determines whether people run it locally too.
1. lint + format ~20s fails on style, cheapest possible
2. static analysis ~90s fails on types, no app boot
3. boundaries + audit ~30s architecture and CVEs
4. tests (matrix) ~6m the expensive one, last
5. migration safety ~60s only on branches that touch db/
# stages 1-3 run in parallel with each other, and all of them
# gate stage 4. total wall clock: about nine minutes.
Step 1: static analysis at a level that hurts slightly
PHPStan at level 5 finds typos. Level 8 finds bugs. The mistake is turning it on at level 8 across a legacy codebase, drowning in ten thousand errors, and switching it off a week later. Generate a baseline, commit it, and forbid it from growing.
includes:
- phpstan-baseline.neon # existing debt, frozen
parameters:
level: 8
paths: [src, app]
checkMissingIterableValueType: true
# generate once:
$ vendor/bin/phpstan --generate-baseline
# then in CI, fail if the baseline grew:
$ vendor/bin/phpstan analyse --error-format=github
$ git diff --exit-code phpstan-baseline.neon || {
echo 'baseline grew — fix it or argue for it in review'; exit 1; }
New code is written at level 8 from day one; old code is fixed when it is touched. The baseline shrinks quietly over a year without anybody running a migration project, and nobody ever has to negotiate about whether types matter.
Step 2: test against real services, not fakes
SQLite in memory is fast and it lies. It has different transaction semantics, different JSON support, no strict mode by default, and it will happily accept a migration MySQL rejects. Use service containers; they cost about thirty seconds of startup.
services:
mysql:
image: mysql:8.0
env: { MYSQL_ROOT_PASSWORD: root, MYSQL_DATABASE: testing }
options: >-
--health-cmd="mysqladmin ping" --health-retries=5
rabbitmq:
image: rabbitmq:3-management
options: >-
--health-cmd="rabbitmq-diagnostics -q ping" --health-retries=5
strategy:
matrix:
php: ['8.3', '8.4'] # the version you run, and the next one
# and turn on the strictness production has:
# SET sql_mode = 'STRICT_ALL_TABLES,NO_ZERO_DATE'
The second matrix entry is the cheapest upgrade insurance available. When the next PHP release lands you already know whether your suite passes, instead of discovering it during an urgent security patch.
Step 3: check the things tests do not
Three checks that each caught something real for me in the last year, and that together add under a minute.
# known CVEs in your lockfile
$ composer audit --locked
# packages you require but never import (and vice versa)
$ vendor/bin/composer-unused
# module boundaries — see the modular monolith walkthrough
$ vendor/bin/deptrac analyse --fail-on-uncovered
# and the one people forget: does the image actually build?
$ docker build --target runtime -t ci-check .
# a green suite and a broken Dockerfile is a fun deploy
Step 4: migration safety, automatically
During a rolling deploy both the old and new code run against the new schema. A migration that drops or renames a column breaks every request served by a container that has not been replaced yet. This is a rule you can check mechanically.
CHANGED=$(git diff --name-only origin/main...HEAD -- database/migrations)
[ -z "$CHANGED" ] && exit 0
# operations that are unsafe mid-rolling-deploy
if grep -nE 'dropColumn|renameColumn|->change\(\)' $CHANGED; then
echo 'unsafe against currently-running code.'
echo 'expand -> deploy -> backfill -> contract, or label
the PR migration-reviewed to override.'
exit 1
fi
# also: run every migration forwards on a copy of prod schema
mysql testing < schema/production-structure.sql
php artisan migrate --force # fails on real data shape
Running migrations against a dump of the production structure — structure only, no data — is the check that catches the migration which works perfectly on a freshly built test database and fails on the table that has an index your local copy never had.
Step 5: coverage on the diff, not the codebase
A global coverage threshold produces one of two outcomes: it is low enough to be meaningless, or it blocks unrelated work because somebody touched a legacy file. Measure coverage of the lines the pull request changed instead. That number is both fair and actionable.
$ php artisan test --coverage-clover=coverage.xml
$ diff-cover coverage.xml --compare-branch=origin/main \
--fail-under=80
Total lines added: 212
Covered by tests: 189 (89%)
Missing: src/Billing/Internal/LedgerPoster.php:41-58
# 'you did not test the thing you just wrote' is a fair review
# comment. 'global coverage fell 0.2%' is not.
Step 6: keep it under ten minutes
A pipeline over ten minutes stops being feedback and becomes something people work around — they push and switch tasks, and the context is gone by the time it fails. Treat the budget as a requirement, and when it is breached, fix the pipeline rather than raising the number.
Cache the composer and npm directories keyed on the lockfile hash. This is usually two minutes on its own. Split the suite across parallel runners by timing data, not alphabetically — paratest or a simple shard-by-duration split. Run the expensive browser tests only on pull requests targeting main, not on every push to a feature branch. Track the p50 duration over time. It creeps, and it creeps in increments too small to notice in any single pull request.
CI that takes twenty minutes does not get run before pushing. It gets worked around, and then it is just a slow way to find out you were wrong.
None of this is exotic, and that is deliberate. The value is not in any one check but in the fact that all of them run, on every change, without anyone remembering to. The pipeline is the only reviewer that never gets tired at 7pm on a Friday.
References
Takeaways
Order stages by how fast they can say no; style failures should not wait for the test suite. PHPStan at level 8 with a frozen baseline, and fail the build if the baseline grows. Test against real MySQL and broker containers — SQLite in memory is fast and it lies. Check migration safety mechanically, measure coverage on the diff, and defend a ten-minute budget.
All notes · Shehzad Aslam