The CI file in the repository I set up on Monday is 80 lines of YAML and I typed almost none of it. I pasted it out of the previous project, which got it from the one before that. It has been mutating slowly since we moved off a self-hosted runner, and by now I know what every line is for, which is more than I could honestly say about the build job it replaced. Twice this year someone has asked me for “your standard PHP workflow”, so here it is with the reasoning attached.
The file
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
tests:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2']
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- uses: actions/checkout@v3
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: intl, pdo_mysql, mbstring, bcmath, zip
ini-values: memory_limit=512M
coverage: none
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Locate Composer cache
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Restore Composer cache
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php }}-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Wait for the database
run: |
for i in $(seq 1 30); do
php -r 'try { new PDO("mysql:host=127.0.0.1;port=3306", "root", "root"); } catch (PDOException $e) { exit(1); }' && exit 0
sleep 1
done
echo "database never accepted a connection" >&2
exit 1
- name: Static analysis
run: vendor/bin/phpstan analyse --no-progress
- name: Tests
run: vendor/bin/phpunit
env:
DATABASE_URL: mysql://root:root@127.0.0.1:3306/app_test?serverVersion=8.0
APP_SECRET: ${{ secrets.CI_APP_SECRET }}
One job, one matrix, no deployment. The workflow syntax reference is the document I still open most often, usually to remind myself which keys live on the job and which live on the step.
Note the runner label. I pin ubuntu-22.04 instead of ubuntu-latest. They point at the same image today, and that is exactly the problem: the day ubuntu-latest moves, it moves on every branch at once, including the release branch you were not touching. Pinning means the upgrade is a pull request with a diff.
What triggers it, and what cancels it
Both triggers are there on purpose, and they do different jobs. On pull_request, GITHUB_REF is set to refs/pull/PULL_REQUEST_NUMBER/merge and GITHUB_SHA is the merge commit, so actions/checkout tests the merged result rather than the head branch on its own. That is the check I actually want to gate merges on. On push, GITHUB_SHA is the tip commit pushed to the ref, with no merge simulation, which is what I want as the record of whether main is green. Both of those are spelled out in the events reference, and the merge-branch behaviour is the single most useful thing on that page.
The branches: [main] filter on push is what stops every run happening twice. Without it, pushing to a branch that already has an open pull request triggers both events and you pay for two runs of the same code. Also worth knowing before you debug it: workflows do not run on pull_request activity at all if the pull request has a merge conflict, so a check stuck in “Expected” is sometimes a conflict, not a broken workflow.
The concurrency block was the highest-value four lines we ever added. Only one run per group may be in progress, and cancel-in-progress: true kills the one already running when a new one is queued. The group expression matters. Using github.workflow keeps it scoped to this workflow, because concurrency group names are shared across the repository and a bare ${{ github.ref }} will happily cancel a different workflow’s job. The github.head_ref || github.ref fallback exists because head_ref is only defined on pull_request events; without the fallback the expression breaks on push. The concurrency documentation has that fallback as a worked example, which tells you how often people get it wrong.
I measured this for a month before and after. On a busy feature branch, roughly a third of our runs were superseded within two minutes by the next push. We were paying for all of them.
PHP setup, and why coverage is off
I use setup-php and have never had a reason to look for an alternative. Two inputs carry most of the weight.
extensions takes a comma-separated list, and I list them explicitly rather than trusting whatever the image happens to ship, because intl in particular is the one that fails in CI and nowhere else. coverage: none disables both Xdebug and PCOV. This is not a micro-optimisation: the action’s own README notes that Xdebug is enabled by default on the Ubuntu images, and switching it off when you are not generating coverage reports is a straight win. On our suite it took the PHPUnit step from 3 minutes 10 to 1 minute 55. When we do want coverage it is a separate job with coverage: pcov, running once on the lowest supported PHP version, not on every matrix leg.
The matrix is two entries. In September 2023 that means 8.1 and 8.2, the two branches in active support, because production is on 8.2 and one client dashboard has not moved off 8.1 yet. fail-fast: false is deliberate: the default cancels every other leg as soon as one fails, and when the 8.1 leg dies I want to know whether 8.2 died too. Two entries is also where I stop. A matrix of PHP versions times dependency strategies times database versions is how a five-minute pipeline becomes a thirty-minute pipeline, and library maintainers need that. Application repositories mostly do not.
Cache the Composer cache, not vendor
The caching step is two steps, and the first one exists because the cache directory is not a fixed path. composer config cache-files-dir prints it, and writing dir=... into $GITHUB_OUTPUT makes it available as steps.composer-cache.outputs.dir. That pattern is straight out of the setup-php README, which carries it verbatim.
Caching the Composer cache rather than vendor/ is the part people argue with me about, so: the setup-php README says plainly not to cache the vendor directory with actions/cache, as that has side effects. The reason is that vendor/ is a build output, not a download. It contains the autoloader Composer generated for one specific PHP version and one specific set of platform requirements, and restoring it without running composer install skips every platform check and every plugin. The cache directory, by contrast, holds the distribution zips keyed by package version. Restoring it means composer install still runs, still validates, still writes a correct autoloader, and just does not touch the network.
Our numbers: cold, composer install takes about 68 seconds for 94 packages. Warm, it is 9 to 11 seconds. Across two matrix legs that is roughly two minutes of billed runner time per run, and we merge somewhere around 30 pull requests a week.
The key includes matrix.php because a cache shared between PHP versions will serve you distributions resolved under the wrong platform config. restore-keys is a prefix-matched fallback, so when composer.lock changes you still get yesterday’s cache and only download the packages that actually moved. Two limits from the cache README worth holding in your head: a repository gets 10 GB of caches with older entries evicted once you hit it, and any cache not accessed in the last week is evicted anyway. So a rarely touched repository is effectively always cold, and that is fine.
The database that says yes before it means it
The service container is the one part of this file that has produced real flakiness. GitHub creates a fresh container per service, and the health check in options is passed through to Docker, which is why the syntax is docker create flags rather than YAML keys. Steps wait for the service to be healthy. In theory that is the whole problem solved.
In practice we saw this maybe one run in forty:
SQLSTATE[HY000] [2002] Connection refused
The cause is in the image’s own entrypoint. On first start the MySQL image initialises the data directory, and to do that it brings up a temporary server with --daemonize --skip-networking on a Unix socket. Read docker-entrypoint.sh and the function is called docker_temp_server_start. A health check of mysqladmin ping runs inside the container and talks to that socket, so it reports healthy while port 3306 is not listening to anything. The container is genuinely up. The server you want is not.
Hence the explicit wait step, which does not trust the health check and instead opens a real PDO connection from the runner over TCP, up to 30 times, one second apart. It uses PHP because setup-php has already installed pdo_mysql by that point, so there is no assumption about which client binaries the runner image ships. It has failed exactly once since we added it, and that time the database really was broken.
The two secrets in the file are worth a sentence. secrets.GITHUB_TOKEN goes to setup-php so that resolving tool versions does not get rate limited by the API. CI_APP_SECRET is a repository secret. And the constraint that shapes everything else: with the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. Our repositories are private and nobody forks them, so I have the luxury of ignoring it. If yours are public, every step that needs a secret has to survive not having one.
What is not in here
No deployment. Deployment lives in its own workflow with its own trigger and its own environment, because the thing I want from CI is a fast honest answer about a diff, and the thing I want from deployment is an audit trail and a human approval. Putting them in one file means every change to the test matrix is a change to the release path.
No actions/upload-artifact, no coverage upload, no Slack notification, no code style job. Those all exist in some of our repositories and none of them belong in the file you copy into a new one on day one. The version pins are actions/checkout@v3 (v3.6.0 is the current release), actions/cache@v3 and shivammathur/setup-php@v2, floating on the major tag, which is a deliberate trade: I accept minor changes I did not read in exchange for not maintaining three Dependabot pull requests a month per repository.
The one thing I have not solved is the matrix duplicating the wait step’s work. Both legs boot their own MySQL container and both pay the initialisation cost, roughly 12 seconds each. Sharing a service across matrix legs is not something the services keyword can do, and the alternative, a single job that switches PHP versions internally, is worse in every other way. So I pay the 24 seconds.
Sources
- Workflow syntax for GitHub Actions: job, step, matrix and
serviceskeys, includingfail-fastand the fact thatservices.<id>.optionsis passed todocker create. - Events that trigger workflows:
pull_requestsetsGITHUB_REFto the merge branch andGITHUB_SHAto the merge commit;pushuses the tip commit; no runs on conflicted pull requests. - Using concurrency: one run per group,
cancel-in-progress, and thegithub.head_ref || github.run_idfallback pattern I adapted. - Using secrets in GitHub Actions: secrets other than
GITHUB_TOKENare withheld from workflows triggered by forks. - shivammathur/setup-php: the
extensions,ini-valuesandcoverageinputs, the note that Xdebug is on by default on the Ubuntu images, the Composer cache-directory recipe, and the warning against cachingvendor. - actions/cache v3.3.2 README:
restore-keysprefix matching, the 10 GB per repository limit, and eviction of caches untouched for a week. - docker-library/mysql entrypoint:
docker_temp_server_startruns the initialisation server with--daemonize --skip-networkingon a socket, which is why a ping-based health check can pass before TCP is listening. - actions/checkout v3.6.0: the release the
v3tag points at as of this writing.