GitHub Actions turned CI/CD from a DevOps speciality into something any developer can set up in an afternoon. For PHP projects it means automatic test runs, static analysis, and deployment to staging on every pull request – without maintaining a Jenkins server. I show a practical pipeline for a PHP/Magento project with a test matrix and SSH deployment.
Basic PHP pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, intl, gd, zip, pdo_mysql, bcmath
tools: composer:v2
coverage: xdebug
- name: Cache Composer dependencies
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 --no-interaction --prefer-dist --no-progress
- name: Run PHPUnit
run: vendor/bin/phpunit --coverage-clover coverage.xml
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
Test matrix – multiple PHP versions
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.3', '8.4']
dependency-version: [prefer-stable, prefer-lowest]
fail-fast: false # run all matrix combinations even if one fails
name: PHP ${{ matrix.php-version }} - ${{ matrix.dependency-version }}
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, intl, pdo_mysql, bcmath
- name: Install dependencies
run: composer update --${{ matrix.dependency-version }} --no-interaction
- name: Run tests
run: vendor/bin/phpunit
Deploy to staging via SSH
deploy-staging:
runs-on: ubuntu-latest
needs: test # only deploy after tests pass
if: github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /var/www/magento-staging
# Pull latest code
git fetch origin
git reset --hard origin/develop
# Install dependencies (no dev, optimised autoloader)
composer install --no-dev --optimize-autoloader --no-interaction
# Magento deployment
php bin/magento maintenance:enable
php bin/magento setup:upgrade --keep-generated
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy pl_PL en_US -f --jobs=4
php bin/magento cache:flush
php bin/magento maintenance:disable
echo "Deploy complete: $(date)"
Secrets management in GitHub Actions
# Store secrets in GitHub repository settings: # Settings -> Secrets and variables -> Actions -> New repository secret # STAGING_HOST - staging server IP or domain # STAGING_USER - SSH username (e.g. deploy) # STAGING_SSH_KEY - private SSH key content (generate a dedicated deploy key) # Generate deploy key: ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N "" # Add public key to staging server: ~/.ssh/authorized_keys # Add private key content to GitHub Secrets as STAGING_SSH_KEY
Conditional jobs and notifications
notify:
runs-on: ubuntu-latest
needs: [test, deploy-staging]
if: always() # run even if previous jobs failed
steps:
- name: Notify on failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: "Pipeline failed for ${{ github.ref }} by ${{ github.actor }}"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Summary
GitHub Actions requires no external infrastructure – the YAML workflow file lives in the repository alongside the code. The test matrix catches regressions across PHP versions in parallel. SSH deployment gives a repeatable, automated process that runs the same commands every time. Start small – a single job with composer install and phpunit – and add steps incrementally. The feedback loop from “push to PR merged” should be under 5 minutes for a typical PHP module.
