◀ Back to blog
DevOps

CI/CD with GitHub Actions

Published on 08 Feb 2024· 8 min read
#GitHub Actions#CI/CD#Automatisation

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 needs enforces 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-php is the reference action for PHP: it installs the requested version, the extensions, Composer and the code coverage driver.
  • The 3306:3306 port mapping exposes MySQL on the runner, hence the 127.0.0.1 address in DATABASE_URL. If the job itself ran inside a container (container:), you would use the service name, mysql, as the host.
  • The root password 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: read reduces the GITHUB_TOKEN rights to the bare minimum. A job that needs to comment on a pull request or publish an image will explicitly request additional permissions.
  • concurrency cancels 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.
  • pcov collects code coverage much faster than Xdebug, which is only useful if you need its other features.
  • --error-format=github turns 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 on main from 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_TOKEN permissions
  • 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.