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@v4uses 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_TOKENwith minimal permissions. Setpermissions:at the workflow level to the minimum required — avoid the default broad write access. - Fail fast on static analysis, not on tests.
fail-fast: falseon the test matrix means you see all PHP version failures at once. - Separate CI and deploy workflows. A single
ci.ymlthat runs on every push keeps feedback fast. A separatedeploy.ymlthat triggers only onmainkeeps deploys deliberate. - Never put secrets in workflow YAML. Use
${{ secrets.NAME }}. Secrets are masked in logs automatically.
Summary Checklist
- Use
shivammathur/setup-phpto install PHP — it handles extensions, tools, and coverage drivers cleanly. - Cache the
vendor/directory with a key based oncomposer.lockhash — this is the single biggest speedup available. - Run PHPStan with
mglaman/phpstan-drupalfor Drupal-aware type checking. - Run PHP_CodeSniffer with the Drupal coding standard and pipe output through
cs2prto annotate PRs inline. - Use service containers for MariaDB/Redis in test jobs — faster than setting them up from scratch.
- Use
strategy.matrixto test across PHP 8.3 and 8.4. - Deploy via rsync + SSH; run
drush updatedb,config:import, andcache:rebuildpost-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.