Back to blog

#GitLab CI#DevOps#PHP#Pest#PHPStan

Fast, boring CI for a Laravel monolith

What actually makes a GitLab CI pipeline for a large PHP codebase quick and trustworthy: lockfile-keyed caches, cheap checks first, sharded Pest runs and migrations that don't break running code.

At Workpay I spent a good part of my time on our GitLab CI/CD setup. We raised deployment efficiency by about 40% and made Pest, Pint and PHPStan part of every merge request. Payroll is an unforgiving domain. A bug doesn’t show up as a broken button; it shows up as someone’s salary being wrong. So I wanted the pipeline fast, but more than that I wanted it boring: the same result every time, for reasons everyone on the team understood.

None of what follows is exotic. It’s the order I’d do things in again, and the mistakes I’d skip.

Measure first

Before changing anything, find out where the minutes go. GitLab shows every job’s duration and how long it sat in the queue. Pull a couple of weeks of pipelines and write down three numbers: time spent installing dependencies, time spent running tests, and time spent waiting for a runner.

Those numbers do two jobs. They tell you where to start, and they let you prove later that your changes helped. Teams skip this step and end up tuning the part of the pipeline that was already fine.

Cache on the lockfile, and pin your images

Most PHP pipelines reinstall the same Composer packages on every run. GitLab can key a cache on the contents of a file, so the cache only changes when composer.lock changes:

stages: [check, test]

default:
  # Our own image with PHP extensions and Composer baked in.
  # Pin the tag. Never build on :latest.
  image: registry.example.com/ci/php:8.3
  interruptible: true
  cache:
    key:
      files: [composer.lock]
    paths: [vendor/]
  before_script:
    - composer install --prefer-dist --no-interaction --no-progress

Two things matter here. First, a cache is best effort. Runners lose it, keys rotate, and the job must still pass from a cold start. composer install against a warm vendor/ takes seconds, and against an empty one it just takes longer. It never takes a different path.

Second, pin the image. A shared build host that someone patches by hand, or a floating :latest tag, is how you get failures that have nothing to do with the code in the merge request. If the PHP version or an extension changes, that should be a commit you can see and revert.

interruptible: true is a small one with a big effect. When someone pushes again, GitLab cancels the pipeline that’s now out of date instead of finishing it.

Run the cheap checks first

Formatting and static analysis take seconds and catch a surprising share of real bugs. Run them as their own jobs so they fail fast and loudly:

pint:
  stage: check
  script: vendor/bin/pint --test

phpstan:
  stage: check
  script: vendor/bin/phpstan analyse --memory-limit=1G

On a codebase that’s never had static analysis, PHPStan will report thousands of errors on day one. Don’t try to fix them all before turning it on. Generate a baseline:

vendor/bin/phpstan analyse --generate-baseline

That writes the current errors to phpstan-baseline.neon, and from then on PHPStan only fails on new ones. The old errors become a backlog you burn down when you’re in that code anyway. This is the difference between “we added static analysis” and “we added static analysis and turned it off two weeks later”.

Make the tests parallel, then shard them

The test suite is usually the biggest number from the first step. Laravel can run tests across several processes with php artisan test --parallel, and it handles the hard part for you: it creates and migrates a separate database for each process, named with a token suffix such as testing_test_1 and testing_test_2. You don’t need to swap database connections by hand in setUp(). If you see code like that in a test base class, it’s usually a workaround for a problem the framework already solved.

In CI you can go a step further and split the suite across several jobs. GitLab’s parallel keyword starts N copies of a job and sets CI_NODE_INDEX and CI_NODE_TOTAL in each one, and Pest’s --shard option takes exactly that pair:

tests:
  stage: test
  needs: []          # start with the checks, not after them
  parallel: 4
  services: [mysql:8.0]
  variables:
    MYSQL_DATABASE: testing
    MYSQL_ROOT_PASSWORD: secret
    DB_HOST: mysql
  script:
    - vendor/bin/pest --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

Splitting by file count leaves you waiting on whichever shard got the slow files. Recent Pest versions can balance shards by measured run time: run pest --update-shards once, commit the generated tests/.pest/shards.json, and later shard runs use it.

The first parallel run will probably fail in ways the sequential run never did. Tests that share a static cache, hard-code an ID, or write to the same file on disk will start stepping on each other. Fix those tests. They were already order-dependent; running them one at a time was hiding it.

Deploy without breaking the code that’s still running

A fast pipeline that ships a broken release is worse than a slow one. The failure I care most about in deployment is the database migration.

During any rolling deploy, old and new versions of the app run side by side for a while. If the new release runs a migration that renames or drops a column, every request still served by the old version fails until the rollout finishes. The fix is a discipline usually called expand and contract. Say you’re replacing a free-text currency column with a three-letter currency_code:

  1. Expand. Add currency_code as a nullable column. Deploy code that writes to both columns but still reads currency.
  2. Backfill. Copy existing rows in small batches from a queued job, outside the deploy.
  3. Switch. Deploy code that reads currency_code.
  4. Contract. In a later release, drop currency.

The backfill is plain Laravel:

DB::table('invoices')
    ->whereNull('currency_code')
    ->chunkById(1000, function ($invoices) {
        foreach ($invoices as $invoice) {
            DB::table('invoices')
                ->where('id', $invoice->id)
                ->update(['currency_code' => strtoupper(substr($invoice->currency, 0, 3))]);
        }
    });

It’s four deploys instead of one, and that’s the point. Each step is small, safe to roll back, and never leaves running code pointed at a column that isn’t there.

Treat the pipeline as a product

The last lesson is about ownership rather than YAML. Pipelines rot the same way code does: someone adds a slow job “temporarily”, a flaky test gets a retry instead of a fix, and six months later nobody trusts a red build. Give the pipeline an owner, agree on a time budget, and treat going over it like a failing test.

When CI is fast and predictable, behaviour changes. People open smaller merge requests because getting one through is cheap, reviews get easier, and the risk in each deploy goes down. That’s the real return, more than any single number on a dashboard.