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. Whether you are working on a Laravel application, a Symfony project, or a custom PHP codebase, the principles covered here apply broadly.
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 to verify that new code does not break existing functionality. CD extends this to automatically deploy changes to a staging or production environment when conditions are met. GitHub Actions handles both stages within a single platform, which simplifies the overall architecture for small teams and individual developers.
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 to run comprehensive test suites and static analysis on every push.
The workflow syntax uses YAML, which is readable and version-controlled alongside your code. Unlike XML-based configuration formats, YAML flows naturally and is easier to review in pull requests. 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 and means you can often find a tested solution for common tasks rather than writing scripts from scratch.
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 with a clean environment, which means test failures reflect actual code problems rather than accumulated side effects from previous builds.
When setting up your pipeline, it helps to document the expected workflow and build steps somewhere accessible. Creating a practical IT support runbook library for your project can help team members understand what the pipeline does, how to interpret failures, and what to do when something goes wrong.
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. The file name does not matter as long as it sits in that directory and has a .yml or .yaml extension.
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 extensions listed should match those required by your application. Common extensions include mysql for database connections, curl for HTTP requests, zip for archive handling, and intl for internationalisation support.
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. The first run populates the cache, and subsequent runs benefit from the restored packages until the lock file changes.
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 to verify that queries, migrations, and data handling work as expected. GitHub Actions supports service containers for this purpose, which means you can spin up a database alongside your test job without external infrastructure.
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. Without this check, tests might run before the database is available, causing connection failures.
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. You can reference secrets with ${{ secrets.SECRET_NAME }} syntax in your workflow steps.
Important: The example above uses a simple password for illustration. In production workflows, always use strong passwords and store them as GitHub secrets. Avoid printing environment variables that contain credentials in your workflow steps.
Testing Across Multiple PHP Versions
PHP projects often need to support multiple PHP versions simultaneously, particularly when building packages or libraries that other projects depend on. A matrix strategy runs the same test job across different PHP versions in parallel, giving you confidence that your code works across the range of versions you intend to support.
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. When you see which PHP versions pass and which fail, you can investigate version-specific issues separately.
Adjust the PHP version list to match the versions your application officially supports. Removing unsupported versions from the matrix reduces unnecessary build time and keeps focus on the versions you actually maintain. For example, if you have already dropped PHP 7.4 support in your application, there is little value in testing against it in the pipeline.
Adding Static Analysis with PHPStan
Tests verify that your code behaves correctly under specific conditions, but they do not catch type mismatches, undefined variables, or incorrect method calls. These issues can sit undetected in the codebase until a specific code path runs in production. PHPStan performs static analysis to find these issues without running the code, catching potential bugs early in the development process.
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. Level 0 disables almost all rules and is useful for legacy projects. Level 8 is the maximum and includes very strict checks that even well-typed code may struggle to pass without careful annotation. Most projects benefit from working through levels 5 to 7 over time.
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. Adding it to the pipeline means every pull request gets type safety checks automatically, without manual effort from developers.
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, particularly when the vendor directory contains dozens of packages with complex dependency trees.
- 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 because the dependencies have potentially changed. When only application code changes, Composer uses the cached dependencies and skips downloading.
The restore-keys field provides a fallback strategy. If the exact cache key is not found, GitHub Actions looks for any cache entry with the PHP version prefix and uses that as a starting point. This means a new cache entry is created only when the lock file changes, and partial cache hits still speed up the build.
If you use npm or yarn for frontend assets, cache those dependencies too. Each cache step should have a unique path to avoid conflicts. The principle remains the same: cache the downloaded packages between runs to avoid repeated network requests.
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. This means code cannot reach production unless it has passed all checks.
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. Together, these conditions create a gate that protects production from untested code.
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. A GitOps workflow treats deployment configuration as part of the codebase, versioned alongside application code, and deployed through the same review process.
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. Understanding these risks helps you configure the pipeline responsibly.
- Limit repository access: Only grant write access to trusted collaborators. Each contributor with push access can modify workflow files, which means they can change what commands run during CI/CD.
- Use short-lived credentials: Where possible, use deployment tokens that expire rather than static SSH keys. Short-lived credentials reduce the window of risk if credentials are exposed.
- Audit workflow changes: Review pull requests that modify workflow files with the same scrutiny as application code changes. A workflow change that introduces a malicious step can compromise servers.
- 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. Even masked values can sometimes be recovered from log artifacts.
Note: No pipeline configuration guarantees complete security. The security of your deployment depends on access controls, credential management, monitoring, and regular review of your setup. Treat the pipeline configuration with the same care you would treat server access credentials.
Common Mistakes to Avoid
Several issues come up frequently when teams first set up GitHub Actions for PHP. Being aware of these pitfalls helps you avoid them in your own setup.
- Missing
composer.lock: Without the lock file, Composer generates a different vendor directory on each run, breaking the cache strategy. The cache key depends on the lock file hash, so without it, every run downloads fresh dependencies. Always commitcomposer.lockto version control. - Ignoring flaky tests: Tests that pass sometimes and fail other times indicate an underlying problem, such as race conditions, reliance on execution order, or external service dependencies. Flaky tests erode confidence in the pipeline and should be fixed or isolated from the main test suite.
- Not using fail-fast correctly: The default
fail-fast: truestops other matrix jobs when one fails. For thorough testing across PHP versions, disable it withfail-fast: falseso you see all results before investigating. - Hardcoding credentials: Never put passwords, API keys, or tokens directly in workflow files. Use GitHub secrets and reference them with
${{ secrets.SECRET_NAME }}. Hardcoded credentials end up in repository history, even if you remove them later. - 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, causing spurious connection failures.
- Not handling migration failures: Database migrations can fail for various reasons. A deployment that copies new code but fails to run migrations leaves the application in an inconsistent state. Build your deployment script to handle migration failures gracefully.
Extending the Pipeline
Once the basic pipeline runs reliably, consider adding more checks to improve code quality and catch issues earlier. Each addition costs some build time but provides value in catching problems before they reach production.
- Code quality tools: Add PHP CodeSniffer to enforce coding standards across the team. Consistent formatting and style reduce unnecessary diffs in code review and help maintain a uniform codebase.
- Security scanning: Use tools like Composer Audit or OWASP Dependency-Check to identify known vulnerabilities in dependencies. Vulnerable packages in your dependency tree can expose your application to risk even if your own code is secure.
- Browser testing: For Laravel Dusk tests, add a service container step to run headless Chrome. Browser tests catch issues that unit tests miss, such as JavaScript errors, DOM manipulation problems, and rendering issues.
- Notification integrations: Send pipeline status to Slack or email when builds fail. Quick notification means developers can respond faster to failures rather than discovering them later through user reports.
If your application uses Docker containers, evaluate whether containerizing the application and deploying with Docker improves your deployment consistency. Containerisation encapsulates dependencies and environment configuration, making deployments more predictable across different server environments.
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. Without a solid version control workflow, the pipeline cannot do its job effectively.
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. The pipeline acts as a gatekeeper, but the workflow determines what gets to the gate.
When planning your project, involving stakeholders in business analysis for IT projects helps ensure that technical decisions align with business goals. The pipeline supports the development process, but the process itself needs to be designed with clear requirements and expectations.
Related practical reading
These related guides can help you connect this topic with the wider website, server, security, and support decisions around it.
- GraphQL in PHP vs REST: When GraphQL Is the Better Choice - useful background for related development decisions
- PHP 8.4: Property Hooks and Asymmetric Visibility - useful background for related development decisions
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. Rushing to add every possible check before the basics work leads to overwhelming failure messages that obscure real problems.
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. Describing the expected behaviour and comparing it against actual pipeline results reveals gaps more clearly than debugging in isolation.