CI/CD for PHP Projects: Getting Started

11 min read 2,115 words
CI/CD for PHP Projects: Getting Started with GitHub Actions featured image

What CI/CD Means for PHP Projects

Manual deployments slow down development teams and introduce unnecessary risk. Every time you upload files via FTP, run database migrations by hand, or forget to clear the cache after an update, something can go wrong. Continuous Integration and Continuous Delivery address this by automating the build, test, and deployment process so that code changes move from your development environment to production with minimal manual intervention.

Continuous Integration (CI) means developers merge code changes to a shared repository frequently throughout the day. Each merge automatically triggers a build and runs a test suite, catching integration problems early before they compound. Continuous Delivery (CD) extends this by automatically preparing every change that passes the CI pipeline for deployment to a staging or production environment, making releases a routine event rather than a stressful one.

For PHP projects specifically, CI/CD automates running PHPUnit tests, enforcing coding standards, bundling assets, and deploying changes to your server. This automation reduces the risk of human error during deployment and makes it practical to ship smaller changes more often rather than bundling everything into large, risky releases. Development teams that adopt these workflows typically spend less time on deployment tasks and more time building useful features.

Why PHP Projects Benefit from Automated Pipelines

PHP has a mature ecosystem of testing tools, static analyzers, and deployment options. A properly configured pipeline can run PHPUnit tests, enforce PSR-12 coding standards, check types with PHPStan, and deploy to your server automatically after each merge. Without automation, these steps are easy to skip when deadlines press, which leads to declining code quality over time.

Automated pipelines also make code refactoring safer. When every change triggers a full test suite, you catch breaking changes immediately rather than discovering them after deploying to production. This confidence lets developers make smaller, incremental improvements instead of avoiding necessary changes that feel too risky to touch manually.

If your team works across multiple branches before merging, having a clear Git branching strategy for your web development workflow helps the CI pipeline run predictably and avoids merge conflicts that slow down automated testing.

GitHub Actions: Built-In CI/CD for GitHub Repositories

GitHub Actions is GitHub's integrated CI/CD platform available with every repository. It is free for public repositories and offers a monthly allowance of minutes for private repositories. Because it integrates directly with GitHub, setup is more straightforward compared to third-party CI services that require configuring webhooks, managing access tokens, and maintaining external infrastructure.

A GitHub Actions workflow is defined by a YAML file placed in the .github/workflows/ directory of your repository. Each workflow responds to events like pushes, pull requests, or scheduled times. You can have multiple workflows for different purposes, such as one dedicated to testing and another handling deployment.

The PHP Workflow File Structure

A basic PHP CI workflow runs tests on every push and pull request. The example below sets up PHP 8.2, installs dependencies with Composer, runs PHPUnit tests, checks code style with PHP CodeSniffer, and uploads coverage reports.

name: PHP CI

on:
  push:
    branches:
      - main
  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.2'
          extensions: pdo_mysql, zip, intl
          coverage: xdebug

      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: vendor
          key: composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: composer-

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

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

      - name: Check code style
        run: ./vendor/bin/phpcs --standard=PSR12 src/

      - name: Upload coverage report
        uses: actions/upload-artifact@v3
        with:
          name: coverage
          path: coverage/

The caching step is important for performance. By caching the vendor directory between runs, you avoid downloading and installing dependencies on every pipeline execution. This can reduce workflow runtime from several minutes down to under a minute in many cases.

Running Multiple PHP Versions

Testing your code against multiple PHP versions helps ensure compatibility across the versions your application supports. Use a matrix strategy to run the test suite against several PHP versions in parallel, catching version-specific issues before users encounter them in production.

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

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          extensions: pdo_mysql, zip, intl

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

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

The matrix strategy creates separate test jobs for each PHP version automatically. If one version fails, the others continue running, giving you a clear picture of where compatibility problems exist. For Laravel applications, this approach is particularly useful because Laravel's requirements and supported features change between PHP versions.

Automating Deployment on Merge

Add a deployment job that runs only when tests pass and changes are merged to the main branch. The needs: test condition ensures deployment only happens after a successful test run, preventing broken code from reaching your server.

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to server
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan cache:clear

The secrets referenced in the workflow are stored securely in your repository settings and are not visible in logs or error messages. Add your server hostname, username, and SSH private key as repository secrets before using this workflow. Navigate to Settings, then Secrets and Variables, then Actions in your repository to add these values.

Setting Up SSH Access for Deployments

Generate a dedicated deployment key pair for the server rather than using a personal account key. Using a separate key limits the impact if credentials are compromised and makes it easier to revoke access when team members leave the project.

ssh-keygen -t ed25519 -f deploy_key -N ""
ssh-copy-id -i deploy_key.pub your_user@server_host

Add the private key as a repository secret named SERVER_SSH_KEY in GitHub. Add the public key to the server's authorized_keys file. Consider restricting the key's access on the server using command options if multiple projects share the same server, ensuring each deployment key can only access its specific directory.

Running Database Migrations Safely

Database migrations are among the most consequential parts of deployment. They modify the database schema, and mistakes can cause data loss or application downtime. Build in checks and backup steps before running migrations automatically. For Laravel applications, the --pretend flag shows the SQL that would execute without running it, letting you review changes beforehand.

script: |
  cd /var/www/myapp
  # Create a database backup before migrations
  mysqldump -u root -p database_name > backup_$(date +%Y%m%d_%H%M%S).sql
  # Run migrations
  php artisan migrate --force
  php artisan cache:clear

Always test migrations on a staging environment that mirrors production before running them in production. Even minor schema changes can interact with existing data in unexpected ways. If your application uses a managed database service, check whether they provide point-in-time recovery options as an additional safety measure.

Adding Code Quality Checks

Beyond tests, enforcing code quality standards automatically in the CI pipeline helps maintain consistency across your codebase. PHPStan and Psalm perform static analysis to find type errors, undefined variables, and potential bugs before they reach production. These tools catch problems that unit tests might miss, such as type mismatches or accessing properties that may not exist on an object.

  - name: Run PHPStan static analysis
    run: ./vendor/bin/phpstan analyse src/ --level=max

PHPStan requires configuration to define which rules to enforce. Starting at level 0 and gradually increasing the strictness is usually more practical than enabling all checks at once, which often produces thousands of errors in legacy codebases. Set a threshold in your configuration that fails the build if too many new errors are introduced by changes.

Notifying on Build Failures

Add notifications so your team learns immediately when a build fails rather than discovering problems hours later. Slack integration works well for this purpose, sending alerts to a dedicated channel where the whole team can see them and respond quickly.

  - name: Notify Slack on failure
    if: failure()
    uses: slackapi/slack-github-action@v1
    with:
      channel-id: 'CI-ALERTS'
      slack-message: "Build failed on ${{ github.repository }}: ${{ github.event.head_commit.message }}"
    env:
      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

The if: failure() condition ensures notifications only fire when something goes wrong, not on every successful build. You can also add notifications for success events if your team prefers to confirm deployments completed without checking the GitHub interface directly.

Connecting CI/CD to Broader Deployment Practices

GitHub Actions fits into a larger approach to deployment automation. If you are interested in extending this setup to manage infrastructure alongside application code, exploring GitOps practices can help. GitOps treats your Git repository as the source of truth for both application code and infrastructure configuration, which pairs naturally with GitHub Actions workflows.

For teams that want to understand the full GitOps approach and how it works with GitHub Actions specifically, there is a practical guide to setting up a GitOps deployment pipeline that covers the concepts and implementation details.

For teams that prefer writing custom deployment scripts rather than relying solely on YAML configuration, bash scripts can encapsulate complex deployment logic that you can then invoke from your CI/CD pipeline. This approach keeps your deployment process version-controlled and testable alongside your application code.

If you need to handle more complex deployment scenarios, learning how to write a bash script for application deployment can help you create reusable, maintainable deployment processes that integrate smoothly with your CI/CD pipeline.

Frequently Asked Questions

Do I need a separate staging environment before deploying to production?
Having a staging environment that mirrors your production setup is strongly recommended. Staging lets you catch problems that only appear with real data or specific server configurations before they affect users. If budget or complexity is a concern, even a minimal staging setup with limited traffic can catch most deployment issues before they reach production.
How long should a CI/CD pipeline take to complete?
A typical PHP CI pipeline for running tests and static analysis usually completes in 2 to 5 minutes when caching is configured properly. Pipelines that include deployment add another 1 to 3 minutes depending on your server location and the complexity of your deployment steps. If your pipeline takes significantly longer, review what steps run on every commit versus what could be cached or moved to a nightly schedule to reduce wait times during active development.
Can I use GitHub Actions for private repositories without exceeding free limits?
GitHub's free tier includes 2,000 minutes per month for private repositories, which is usually sufficient for small teams or personal projects. Monitor your usage in the GitHub Actions tab of your repository settings. If you consistently exceed the limit, consider optimizing your workflow to run less frequently for certain tasks or investing in a paid plan with more minutes.
What happens if a deployment fails halfway through?
Pipeline execution stops when a step fails, but any steps already completed cannot be automatically rolled back. Design your deployment scripts to be idempotent, meaning they can run safely multiple times, and keep database migrations separate from file deployments when possible. If a deployment fails, manually verify the server state before restarting the pipeline to avoid compounding problems from a partially completed run.
Should I deploy from feature branches before merging to main?
For most projects, deploying only after merging to the main branch keeps things simple and reduces the risk of incomplete features reaching production. However, some teams use feature branch deployments to staging environments for preview purposes. This approach works well for client projects where stakeholders need to review changes before they go live.
How do I secure sensitive data in my CI/CD pipeline?
Never store passwords, API keys, or other secrets directly in your workflow files or repository code. Use GitHub repository secrets for anything sensitive, and restrict who can view or modify secrets in your repository settings. For credentials that change frequently, consider using secrets management services that your pipeline can retrieve dynamically at runtime rather than storing them as static values.