GitHub Actions CI/CD Pipeline for PHP: A Practical Setup Guide

Setting up a CI/CD pipeline for a PHP application can feel like a significant upfront investment, but it pays off quickly. Once automated, every code push triggers a predictable sequence of tests, analysis, and deployment steps that would otherwise require manual attention. This removes the risk of forgetting a step, skipping a test, or making inconsistent changes across environments.

This guide walks through building a complete GitHub Actions pipeline for PHP projects. It covers testing with PHPUnit, running multiple PHP versions in parallel, static analysis with PHPStan, database setup for integration tests, dependency caching to speed up builds, and automated deployment when code merges to the main branch.

If you are new to CI/CD concepts, it is worth understanding the difference between continuous integration and continuous delivery. CI runs tests and checks on every push. CD extends this to automatically deploy changes to a staging or production environment when conditions are met. GitHub Actions handles both.

Why GitHub Actions Works Well for PHP Projects

GitHub Actions is built directly into GitHub, which means no external CI server to configure or pay for. Repository owners get 2,000 minutes of free CI/CD per month for private repositories, and unlimited minutes for public repositories. For most small-to-medium PHP projects, this free tier is sufficient.

The workflow syntax uses YAML, which is readable and version-controlled alongside your code. The community has contributed hundreds of pre-built actions for PHP setup, Composer caching, deployment, and more. This reduces the amount of custom scripting required.

One practical benefit is that GitHub Actions jobs run in fresh virtual machines each time. There is no risk of lingering state from a previous run affecting the current test results. Each pipeline execution starts clean.

The Basic CI Workflow Structure

A workflow file lives at .github/workflows/ci.yml inside your repository. This file defines what triggers the pipeline, what jobs run, and what steps each job performs.

Start with a simple workflow that runs on every push and every pull request:

name: CI

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mysql, curl, zip, gd, intl
          coverage: xdebug

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ runner.php-version }}-${{ hashFiles('*/composer.lock') }}

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

      - name: Run PHPUnit tests
        run: vendor/bin/phpunit --testdox

The shivammathur/setup-php action handles PHP installation and extension configuration. Setting coverage: xdebug enables code coverage reporting if your phpunit.xml.dist is configured to use it.

The cache step stores Composer downloaded packages between runs. When composer.lock does not change, the cached dependencies are restored instead of downloaded again. This significantly reduces build time for projects with many dependencies.

Setting Up a Database for Integration Tests

Many PHP applications rely on a database. Unit tests can often mock the database layer, but integration tests typically need a real database connection. GitHub Actions supports service containers for this purpose.

Add a MariaDB or MySQL service to the workflow:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mysql, curl, zip

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

      - name: Create test database
        run: mysql -h 127.0.0.1 -u root -proot -e "CREATE DATABASE IF NOT EXISTS testdb;"

      - name: Run migrations
        run: php artisan migrate --force

      - name: Run tests
        run: vendor/bin/phpunit --testdox

The health check option tells GitHub Actions to wait until MySQL is ready before proceeding to the steps. The database starts in the background, and the health check polls until it responds to mysqladmin ping.

Replace the database credentials with your own values, and store sensitive credentials as GitHub secrets rather than hardcoding them in the workflow file. Using secrets keeps passwords and API tokens out of your repository history.

Testing Across Multiple PHP Versions

PHP projects often need to support multiple PHP versions simultaneously. A matrix strategy runs the same test job across different PHP versions in parallel:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php-version: ['8.1', '8.2', '8.3']

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          extensions: mysql, curl, zip, gd, intl

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

      - name: Run tests
        run: vendor/bin/phpunit --testdox

Setting fail-fast: false ensures all PHP versions are tested even if one version fails early. This gives you a complete picture of compatibility rather than stopping at the first failure.

Adjust the PHP version list to match the versions your application officially supports. Removing unsupported versions from the matrix reduces unnecessary build time.

Adding Static Analysis with PHPStan

Tests verify that your code behaves correctly, but they do not catch type mismatches, undefined variables, or incorrect method calls. PHPStan performs static analysis to find these issues without running the code.

Install PHPStan as a development dependency:

composer require --dev phpstan/phpstan

Create a configuration file at phpstan.neon in your project root:

parameters:
  level: 5
  paths:
    - app
  excludePaths:
    - app/Providers

Start at level 5 and increase it gradually as you fix reported issues. Higher levels enforce stricter type checking but require more initial effort to satisfy.

Add the PHPStan step to your workflow after Composer installation:

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

PHPStan runs quickly and catches issues that unit tests miss. It is especially useful in larger codebases where refactoring can introduce subtle type errors.

Caching Dependencies to Speed Up Builds

Composer downloads packages on every pipeline run by default. With a cache step, subsequent runs restore the downloaded packages instead of downloading them again. This can cut build time significantly for projects with many dependencies.

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ runner.php-version }}-${{ hashFiles('*/composer.lock') }}
          restore-keys: |
            composer-${{ runner.php-version }}-

The cache key includes the PHP version and the hash of composer.lock. When the lock file changes, a new cache entry is created. When only application code changes, Composer uses the cached dependencies and skips downloading.

If you use npm or yarn for frontend assets, cache those dependencies too. Each cache step should have a unique path.

Automating Deployment on Merge to Main

Once tests pass reliably, automated deployment removes the manual steps between merging code and updating the live site. A deployment job runs only when the test job succeeds and the change targets the main branch.

  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy to server
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
        run: |
          eval "$(ssh-agent -s)"
          echo "$DEPLOY_KEY" | tr -d '\r' | ssh-add -
          ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts

          rsync -avz --delete -e "ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key" \
            --exclude='.git' --exclude='.github' \
            ./ ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/

          ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key \
            ${DEPLOY_USER}@${DEPLOY_HOST} \
            "cd ${DEPLOY_PATH} && composer install --no-dev --optimize-autoloader --no-interaction && php artisan migrate --force"

          echo "Deployed successfully"

The needs: test dependency ensures deployment only proceeds if tests pass. The if condition restricts deployment to the main branch, preventing accidental deployments from feature branches.

Before running the pipeline, configure the required secrets in your repository settings under Settings, then Secrets and variables, then Actions. Add DEPLOY_KEY, DEPLOY_HOST, DEPLOY_USER, and DEPLOY_PATH with values specific to your server.

Consider whether a full GitOps approach suits your deployment workflow. Managing infrastructure as code and using declarative deployment pipelines can make the process more auditable and repeatable over time.

Security Considerations for CI/CD Pipelines

Automating deployment introduces new security considerations. The pipeline has access to deployment credentials, which means the repository security settings directly affect server access.

  • Limit repository access: Only grant write access to trusted collaborators. Each contributor with push access can modify workflow files.
  • Use short-lived credentials: Where possible, use deployment tokens that expire rather than static SSH keys.
  • Audit workflow changes: Review pull requests that modify workflow files with the same scrutiny as application code changes.
  • Separate environments: Avoid deploying directly to production from the same pipeline that runs tests. Use separate jobs or separate workflows for staging and production with different secrets.
  • Store secrets securely: GitHub Actions secrets are encrypted at rest and masked in logs, but avoid printing sensitive environment variables.

No pipeline configuration guarantees complete security. The security of your deployment depends on access controls, credential management, monitoring, and regular review of your setup.

Common Mistakes to Avoid

Several issues come up frequently when teams first set up GitHub Actions for PHP:

  • Missing composer.lock: Without the lock file, Composer generates a different vendor directory on each run, breaking the cache strategy. Commit composer.lock to version control.
  • Ignoring flaky tests: Tests that pass sometimes and fail other times indicate an underlying problem. Flaky tests erode confidence in the pipeline and should be fixed or isolated.
  • Not using fail-fast correctly: The default fail-fast: true stops other matrix jobs when one fails. For thorough testing across PHP versions, disable it.
  • Hardcoding credentials: Never put passwords, API keys, or tokens directly in workflow files. Use GitHub secrets and reference them with ${{ secrets.SECRET_NAME }}.
  • Skipping the health check: Database service containers need time to start. The health check option prevents race conditions where tests run before the database is ready.

Extending the Pipeline

Once the basic pipeline runs reliably, consider adding more checks:

  • Code quality tools: Add PHP CodeSniffer to enforce coding standards across the team.
  • Security scanning: Use tools like Composer Audit or OWASP Dependency-Check to identify known vulnerabilities in dependencies.
  • Browser testing: For Laravel Dusk tests, add a service container step to run headless Chrome.
  • Notification integrations: Send pipeline status to Slack or email when builds fail.

If your application uses Docker containers, evaluate whether containerizing the application and deploying with Docker improves your deployment consistency. Understanding when Docker makes sense for web applications helps inform this decision.

Version Control Workflows Matter Too

The pipeline only runs on code that reaches the repository. Establishing a clear git branching strategy ensures that feature branches are tested before merging and that the main branch stays deployable at all times.

Consider adopting a workflow where feature branches are created for each change, tested via pull request, reviewed by at least one other person, and then merged to main only when the pipeline passes. This catches issues before they reach the deployment stage.

Building a Reliable Deployment Process

A well-configured GitHub Actions pipeline transforms deployment from a manual, error-prone process into a routine operation that runs consistently every time code merges to main. The key is starting simple, adding checks gradually, and treating the pipeline configuration with the same care as application code.

Focus first on reliable tests that give you confidence in the code. Once the test suite is stable, add static analysis, then caching, then deployment automation. Each layer adds value without introducing unnecessary complexity.

If you are working through setting up this pipeline for a specific project and need a practical review of your current configuration, gather your workflow file, phpunit.xml.dist, and a note of what you want the pipeline to accomplish. That context helps identify what is working and what needs adjustment.