GitHub Actions for your PHP projects
GitHub Actions provides a CI/CD solution built right into your repository. Here is how to set up a complete pipeline.
The main benefit is that there is no infrastructure to maintain: workflows are YAML files placed in .github/workflows/, versioned with the code, and executed on virtual machines provided by GitHub (the runners). Every push or pull request triggers the checks, and their result shows up directly in the review interface. For a PHP/Symfony project, a few dozen lines give you static analysis, tests against a real database and automatic deployment.
The basics
- Workflow: a YAML file describing when to run (
on) and what to do (jobs). - Job: a set of steps executed on the same runner. Jobs in a workflow run in parallel by default, unless
needsenforces an order. - Step: a shell command (
run) or a reusable action (uses), published on the Marketplace or in your own repository. - Service: a side container (MySQL, Redis…) started next to the job for the duration of its run.
Test workflow
This first workflow runs on every push to main and develop, as well as on every pull request targeting main. It starts MySQL 8, installs PHP 8.3 with the required extensions, then runs PHPStan and PHPUnit.
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_mysql, intl
coverage: xdebug
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse src
- name: Run tests
run: php bin/phpunit --coverage-clover coverage.xml
env:
DATABASE_URL: mysql://root:root@127.0.0.1:3306/test
A few details that matter:
shivammathur/setup-phpis the reference action for PHP: it installs the requested version, the extensions, Composer and the code coverage driver.- The
3306:3306port mapping exposes MySQL on the runner, hence the127.0.0.1address inDATABASE_URL. If the job itself ran inside a container (container:), you would use the service name,mysql, as the host. - The
rootpassword is acceptable here: the database is disposable and only exists for the duration of the job. Real secrets, however, must never appear in the YAML.
Automatic deployment
The deployment job goes under jobs:. It waits for the tests to succeed (needs) and only runs on the main branch.
deploy:
needs: tests
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: |
cd /var/www/app
git pull origin main
composer install --no-dev
php bin/console cache:clear
php bin/console doctrine:migrations:migrate -n
This script is deliberately simple. In production, I recommend adding set -e as the first line to stop the deployment at the first error, and --optimize-autoloader to composer install. To avoid the few seconds during which code and dependencies are out of sync, the release-directory strategy (one folder per version and a current symlink) remains the safest.
A production-ready workflow
The following version keeps the same steps while applying best practices: lint and tests in separate jobs running in parallel, a PHP version matrix, Composer caching, waiting for MySQL and minimal permissions.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --prefer-dist --no-progress
- run: vendor/bin/php-cs-fixer fix --dry-run --diff
- run: vendor/bin/phpstan analyse src --error-format=github
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, pdo_mysql, intl
coverage: pcov
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- run: composer install --prefer-dist --no-progress
- run: php bin/phpunit --coverage-clover coverage.xml
env:
DATABASE_URL: mysql://root:root@127.0.0.1:3306/test
What changes compared to the first workflow:
permissions: contents: readreduces theGITHUB_TOKENrights to the bare minimum. A job that needs to comment on a pull request or publish an image will explicitly request additional permissions.concurrencycancels the previous run when a new commit lands on the same branch: there is no point testing code that has already been replaced.- The matrix starts one job per PHP version. With
fail-fast: false, a failure on PHP 8.4 does not stop the other versions, giving you a complete view of compatibility. - The MySQL health check: without the
options, the job may start while MySQL is still initializing its database, and the first tests fail randomly. GitHub now waits until the container reports healthy. - The Composer cache is keyed on the hash of
composer.lock: as long as dependencies do not change, packages are restored instead of downloaded. pcovcollects code coverage much faster than Xdebug, which is only useful if you need its other features.--error-format=githubturns PHPStan errors into annotations displayed directly on the relevant lines of the pull request.
Protecting deployment with an environment
GitHub offers environments: secrets specific to each target and, depending on your GitHub plan, protection rules such as manual approval by designated reviewers. The deployment job simply declares the environment it uses:
deploy:
needs: [lint, tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
concurrency:
group: production
cancel-in-progress: false
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: |
set -e
cd /var/www/app
git pull origin main
composer install --no-dev --optimize-autoloader
php bin/console cache:clear
php bin/console doctrine:migrations:migrate -n
Here, the production concurrency group guarantees that only one deployment runs at a time, without ever cancelling one in progress: interrupting a deployment in the middle of migrations would be far worse than waiting.
Common pitfalls
- Secrets and forks: workflows triggered by a pull request from a fork do not have access to secrets. Tests must therefore work without them.
- Third-party actions: an action referenced by a tag (
@v1) can change without you knowing. For sensitive actions, pin them to a full commit SHA and let Dependabot propose updates. - Status check names: with a matrix, checks are named for example
tests (8.3). Renaming a job or changing the matrix requires updating the branch protection rules. - Deploying from a pull request: the
github.event_name == 'push'condition prevents an unexpected event onmainfrom triggering a production release.
Best practices
- Use Composer dependency caching
- Run test and lint jobs in parallel
- Protect branches with required status checks
- Store secrets in GitHub Secrets
- Restrict the
GITHUB_TOKENpermissions - Use a protected environment for production
With these few rules, every pull request is checked automatically, errors show up directly in code review, and releasing to production becomes a routine operation rather than a dreaded event.