GitHub Actions for PHP Projects: CI/CD with Composer, Tests, and Deployment

GitHub Actions is the dominant CI/CD platform for PHP projects, and for good reason: it is free for public repositories, tightly integrated with your code history, and the marketplace has ready-made actions for every part of a PHP pipeline. This guide builds a complete, production-grade workflow from scratch: Composer install with caching, PHPStan static analysis, PHPUnit tests with a database, code style checks, and deployment to a remote server via SSH.

All examples use PHP 8.3 and target a Drupal 11 project, but the patterns apply equally to Symfony, Laravel, and standalone PHP applications.


Anatomy of a GitHub Actions Workflow

Workflows live in .github/workflows/ as YAML files. A workflow is triggered by events (push, pull_request, schedule), runs on runners (Ubuntu, Windows, macOS VMs), and is composed of jobs, each containing steps:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-24.04

    steps:
      - uses: actions/checkout@v4
      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install
      - run: vendor/bin/phpunit

That is the minimal structure. The rest of this article builds out a realistic, optimised version of this.


The Full CI Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  PHP_VERSION: '8.3'

jobs:

  # ── Static Analysis ─────────────────────────────────────────────────────
  phpstan:
    name: PHPStan
    runs-on: ubuntu-24.04

    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          extensions: mbstring, xml, ctype, json, zip, pdo, dom
          tools: composer:v2
          coverage: none   # Disable Xdebug/PCOV — not needed for analysis

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --no-progress

  # ── Coding Standards ─────────────────────────────────────────────────────
  phpcs:
    name: PHP Code Sniffer
    runs-on: ubuntu-24.04

    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          tools: composer:v2, cs2pr
          coverage: none

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Run PHP_CodeSniffer
        run: vendor/bin/phpcs --report=checkstyle | cs2pr

  # ── Tests ─────────────────────────────────────────────────────────────────
  phpunit:
    name: PHPUnit (${{ matrix.php }})
    runs-on: ubuntu-24.04

    strategy:
      fail-fast: false
      matrix:
        php: ['8.3', '8.4']   # Test on multiple PHP versions

    services:
      mariadb:
        image: mariadb:10.11
        env:
          MARIADB_ROOT_PASSWORD: root
          MARIADB_DATABASE: drupal_test
          MARIADB_USER: drupal
          MARIADB_PASSWORD: drupal
        ports:
          - 3306:3306
        options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP ${{ matrix.php }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, xml, ctype, json, zip, pdo, pdo_mysql, dom, gd, intl, opcache
          tools: composer:v2
          coverage: xdebug    # Enable Xdebug for coverage reporting

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: vendor
          key: ${{ runner.os }}-php${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Wait for MariaDB
        run: |
          for i in $(seq 1 30); do
            mysqladmin ping -h 127.0.0.1 -u root -proot &>/dev/null && break
            sleep 2
          done

      - name: Run PHPUnit with coverage
        run: |
          vendor/bin/phpunit \
            --coverage-clover coverage/clover.xml \
            --log-junit test-results/junit.xml
        env:
          SIMPLETEST_DB: mysql://drupal:drupal@127.0.0.1/drupal_test
          SIMPLETEST_BASE_URL: http://localhost

      - name: Upload coverage to Codecov
        if: matrix.php == '8.3'
        uses: codecov/codecov-action@v4
        with:
          file: coverage/clover.xml
          token: ${{ secrets.CODECOV_TOKEN }}

      - name: Publish test results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: test-results/junit.xml

PHPStan Configuration

Create phpstan.neon in your project root:

parameters:
  level: 6
  paths:
    - web/modules/custom
  excludePaths:
    - web/modules/custom/*/tests
  phpVersion: 80300

  # Drupal-specific type extensions
  bootstrapFiles:
    - vendor/phpstan/extension-installer/src/Installer.php

includes:
  # If using phpstan-drupal for Drupal-aware analysis
  - vendor/mglaman/phpstan-drupal/extension.neon

Install PHPStan and the Drupal extension:

composer require --dev phpstan/phpstan mglaman/phpstan-drupal phpstan/extension-installer

PHP_CodeSniffer Configuration

Create phpcs.xml:

<?xml version="1.0"?>
<ruleset name="My Project">
  <description>Drupal coding standards</description>

  <file>web/modules/custom</file>

  <exclude-pattern>web/modules/custom/*/tests/*</exclude-pattern>

  <!-- Drupal and DrupalPractice standards -->
  <rule ref="Drupal"/>
  <rule ref="DrupalPractice"/>

  <!-- PHP version -->
  <config name="php_version" value="80300"/>
</ruleset>
composer require --dev drupal/coder squizlabs/php_codesniffer
vendor/bin/phpcs --config-set installed_paths vendor/drupal/coder/coder_sniffer

Caching Composer Packages

The Composer install step is the slowest part of most PHP workflows. The actions/cache@v4 action with a key based on the composer.lock hash means the cache is only invalidated when dependencies actually change:

- name: Cache Composer packages
  uses: actions/cache@v4
  with:
    path: vendor
    key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
    restore-keys: |
      ${{ runner.os }}-composer-

The restore-keys fallback uses an older cache when there is no exact match — still faster than a cold install. Combined with composer install --prefer-dist, this typically reduces a 60-second cold install to under 5 seconds on a cache hit.


Matrix Testing Across PHP Versions

The strategy.matrix key runs the job multiple times with different parameter combinations. For PHP projects, testing across current and upcoming PHP versions catches compatibility issues before they affect production:

strategy:
  fail-fast: false  # Don't cancel all jobs when one fails
  matrix:
    php: ['8.3', '8.4']
    # Optional: also test different dependency versions
    # dependency-version: [prefer-lowest, prefer-stable]

Access the matrix value with ${{ matrix.php }} in any step of that job.


Deployment Workflow

Once CI passes, deploy to the production server via SSH. Store the SSH key in GitHub Secrets (Settings → Secrets and variables → Actions):

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    name: Deploy to production
    runs-on: ubuntu-24.04
    needs: []   # Add CI job names here to require they pass first
    environment:
      name: production
      url: https://example.com

    steps:
      - uses: actions/checkout@v4

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy via rsync
        run: |
          rsync -az --delete \
            --exclude='.git' \
            --exclude='web/sites/default/files' \
            --exclude='web/sites/default/settings.php' \
            --exclude='web/sites/default/settings.local.php' \
            ./ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/var/www/html/

      - name: Run post-deploy commands
        run: |
          ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} '
            cd /var/www/html
            composer install --no-dev --optimize-autoloader --prefer-dist --no-interaction
            vendor/bin/drush updatedb -y
            vendor/bin/drush config:import -y
            vendor/bin/drush cache:rebuild
          '

The deployment uses GitHub Environments, which gives you:

  • A dedicated secrets scope (production secrets separate from CI secrets)
  • Required reviewers — a deployment must be manually approved before it runs
  • Deployment history visible in the GitHub repository UI

Composite Actions for Reuse

If multiple jobs share the same setup steps (PHP install + Composer + cache), extract them into a composite action:

# .github/actions/php-setup/action.yml
name: 'PHP Setup'
description: 'Set up PHP, Composer, and cached vendor directory'

inputs:
  php-version:
    description: 'PHP version to use'
    required: false
    default: '8.3'
  coverage:
    description: 'Coverage driver'
    required: false
    default: none

runs:
  using: composite
  steps:
    - name: Set up PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ inputs.php-version }}
        extensions: mbstring, xml, ctype, json, zip, pdo, pdo_mysql, dom, gd, intl
        tools: composer:v2
        coverage: ${{ inputs.coverage }}

    - name: Cache Composer packages
      uses: actions/cache@v4
      with:
        path: vendor
        key: ${{ runner.os }}-php${{ inputs.php-version }}-composer-${{ hashFiles('composer.lock') }}

    - name: Install dependencies
      run: composer install --prefer-dist --no-progress --no-interaction
      shell: bash

Use it in any workflow:

steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/php-setup
    with:
      php-version: '8.3'
      coverage: xdebug

Workflow Best Practices

  • Pin action versions to a SHA, not just a tag. uses: actions/checkout@v4 uses the tag's current commit, which can change. For security-sensitive actions (deploy), pin to a specific commit SHA: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683.
  • Use GITHUB_TOKEN with minimal permissions. Set permissions: at the workflow level to the minimum required — avoid the default broad write access.
  • Fail fast on static analysis, not on tests. fail-fast: false on the test matrix means you see all PHP version failures at once.
  • Separate CI and deploy workflows. A single ci.yml that runs on every push keeps feedback fast. A separate deploy.yml that triggers only on main keeps deploys deliberate.
  • Never put secrets in workflow YAML. Use ${{ secrets.NAME }}. Secrets are masked in logs automatically.

Summary Checklist

  • Use shivammathur/setup-php to install PHP — it handles extensions, tools, and coverage drivers cleanly.
  • Cache the vendor/ directory with a key based on composer.lock hash — this is the single biggest speedup available.
  • Run PHPStan with mglaman/phpstan-drupal for Drupal-aware type checking.
  • Run PHP_CodeSniffer with the Drupal coding standard and pipe output through cs2pr to annotate PRs inline.
  • Use service containers for MariaDB/Redis in test jobs — faster than setting them up from scratch.
  • Use strategy.matrix to test across PHP 8.3 and 8.4.
  • Deploy via rsync + SSH; run drush updatedb, config:import, and cache:rebuild post-deploy.
  • Use GitHub Environments for production deployments to get required reviewers and deployment history.
  • Extract shared setup steps into composite actions to avoid copy-paste across workflows.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.