DEV Community

Cover image for CI/CD for Laravel: GitHub Actions, Deploynix API, and Automated Deployments
Deploynix
Deploynix

Posted on • Originally published at deploynix.io

CI/CD for Laravel: GitHub Actions, Deploynix API, and Automated Deployments

Continuous Integration and Continuous Deployment (CI/CD) is the practice of automatically testing and deploying code changes. Instead of manually running tests, SSHing into a server, and triggering a deployment, a CI/CD pipeline handles every step from the moment you push code to the moment it is live in production.

For Laravel applications, this means: push to GitHub, tests run automatically, and if they pass, the application is deployed to your Deploynix server without any manual intervention. If something goes wrong after deployment, rollback is one API call away.

This guide walks through building a complete CI/CD pipeline using GitHub Actions for testing and the Deploynix API for deployment. By the end, you will have a production-ready pipeline that tests your code, deploys on success, and provides a safety net for failures.

The Pipeline Overview

Here is what the complete pipeline does:

  1. Developer pushes code to a feature branch
  2. Tests run automatically on the pull request (GitHub Actions)
  3. Pull request is reviewed and merged to the main branch
  4. Deployment triggers automatically when main is updated (Deploynix API via GitHub Actions)
  5. Health check verifies the deployment succeeded
  6. Rollback triggers automatically if the health check fails

Each step is automated. The developer's only manual action is pushing code and reviewing pull requests.

Part 1: GitHub Actions for Testing

GitHub Actions run in response to repository events (push, pull request, schedule). We will configure a workflow that runs your Laravel test suite on every pull request.

The Test Workflow

Create .github/workflows/test.yml in your repository:

name: Tests

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

      redis:
        image: valkey/valkey:8
        ports:
          - 6379:6379
        options: >-
          --health-cmd="valkey-cli ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, dom, fileinfo, mysql, redis
          coverage: none

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

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

      - name: Copy environment file
        run: cp .env.example .env

      - name: Generate application key
        run: php artisan key:generate

      - name: Run migrations
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan migrate --no-interaction

      - name: Run tests
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
          REDIS_HOST: 127.0.0.1
          REDIS_PORT: 6379
        run: php artisan test --compact

      - name: Run Pint (code style)
        run: vendor/bin/pint --test
Enter fullscreen mode Exit fullscreen mode

What This Workflow Does

Services: Starts MySQL 8.4 and Valkey 8 containers alongside your test runner. This gives your tests a real database and cache server, matching your production environment.

PHP setup: Installs PHP 8.4 with the extensions your Laravel application needs.

Caching: Caches Composer dependencies between runs. The first run downloads everything, subsequent runs restore from cache, saving 30-60 seconds.

Tests: Runs your Pest test suite. The --compact flag provides concise output.

Code style: Runs Laravel Pint in test mode to verify code formatting. This does not fix anything — it just reports whether the code matches the project's style.

Pull Request Protection

Configure branch protection rules on your main branch to require the test workflow to pass before merging:

  1. Go to your repository Settings > Branches
  2. Add a branch protection rule for main
  3. Enable "Require status checks to pass before merging"
  4. Select the "test" job as a required check

Now, no code can be merged to main without passing tests. This is your first safety net.

Part 2: The Deploynix API

Deploynix provides a RESTful API authenticated with Sanctum tokens. You create an API token in the Deploynix dashboard with specific scopes that control what the token can do.

Creating an API Token

  1. Log in to your Deploynix dashboard at deploynix.io
  2. Navigate to your API token settings
  3. Create a new token with the scopes you need:
  • site:deploy — trigger deployments and rollback
  • site:read — read site information
  • server:read — read server information
  • Copy the token — it is shown only once

Storing the Token in GitHub

Add your Deploynix API token as a GitHub repository secret:

  1. Go to your repository Settings > Secrets and variables > Actions
  2. Click "New repository secret"
  3. Name: DEPLOYNIX_API_TOKEN
  4. Value: your API token

Also add your server and site identifiers:

  • DEPLOYNIX_SERVER_ID — your server's ID
  • DEPLOYNIX_SITE_ID — your site's ID

These are visible in the Deploynix dashboard URL when viewing your server or site.

Part 3: The Deployment Workflow

Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

concurrency:
  group: deployment
  cancel-in-progress: false

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

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, dom, fileinfo, mysql
          coverage: none

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

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

      - name: Copy environment file
        run: cp .env.example .env

      - name: Generate application key
        run: php artisan key:generate

      - name: Run migrations
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan migrate --no-interaction

      - name: Run tests
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan test --compact

  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: success()

    steps:
      - name: Trigger deployment
        run: |
          RESPONSE=$(curl -s -w "\n%{http_code}" \
            -X POST \
            -H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN }}" \
            -H "Accept: application/json" \
            -H "Content-Type: application/json" \
            "https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID }}/sites/${{ secrets.DEPLOYNIX_SITE_ID }}/deploy")

          HTTP_CODE=$(echo "$RESPONSE" | tail -1)
          BODY=$(echo "$RESPONSE" | head -n -1)

          echo "HTTP Status: $HTTP_CODE"
          echo "Response: $BODY"

          if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
            echo "Deployment trigger failed with HTTP $HTTP_CODE"
            exit 1
          fi

          echo "Deployment triggered successfully"

      - name: Wait for deployment
        run: |
          echo "Waiting 60 seconds for deployment to complete..."
          sleep 60

      - name: Health check
        run: |
          MAX_ATTEMPTS=6
          ATTEMPT=0

          while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
            HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
              --max-time 10 \
              "https://your-app-domain.com/up")

            echo "Health check attempt $((ATTEMPT + 1)): HTTP $HTTP_CODE"

            if [ "$HTTP_CODE" = "200" ]; then
              echo "Health check passed"
              exit 0
            fi

            ATTEMPT=$((ATTEMPT + 1))
            sleep 10
          done

          echo "Health check failed - application may be down"
          exit 1

  notify:
    runs-on: ubuntu-latest
    needs: [test, deploy]
    if: always()

    steps:
      - name: Notify on success
        if: needs.deploy.result == 'success'
        run: |
          echo "Deployment succeeded for commit ${{ github.sha }}"
          # Add Slack/Discord/email notification here

      - name: Notify on failure
        if: needs.deploy.result == 'failure' || needs.test.result == 'failure'
        run: |
          echo "Pipeline failed for commit ${{ github.sha }}"
          # Add Slack/Discord/email notification here
Enter fullscreen mode Exit fullscreen mode

Key Design Decisions

Concurrency control: The concurrency block ensures only one deployment runs at a time. If you push twice quickly, the second push waits for the first deployment to complete rather than creating a race condition.

Tests run again before deploy: Even though tests ran on the pull request, they run again on the merge to main. This catches issues from merge conflicts or from changes that are individually correct but conflict when combined.

Health check: After deployment, the pipeline hits a health endpoint to verify the application is responding. Laravel provides a /up health endpoint by default. If the health check fails, the pipeline reports failure and you can investigate.

Notification job: Runs regardless of success or failure (if: always()) and sends the appropriate notification based on the outcome.

Part 4: Automated Rollback

If a deployment causes issues, you want to rollback quickly. Here is a workflow you can trigger manually:

Create .github/workflows/rollback.yml:

name: Rollback

on:
  workflow_dispatch:
    inputs:
      deployment_id:
        description: 'Deployment ID to rollback to (leave empty for previous)'
        required: false
        type: string

jobs:
  rollback:
    runs-on: ubuntu-latest

    steps:
      - name: Trigger rollback
        run: |
          echo "Triggering rollback..."

          RESPONSE=$(curl -s -w "\n%{http_code}" \
            -X POST \
            -H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN }}" \
            -H "Accept: application/json" \
            -H "Content-Type: application/json" \
            -d '{"release": "${{ github.event.inputs.deployment_id }}"}' \
            "https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID }}/sites/${{ secrets.DEPLOYNIX_SITE_ID }}/rollback")

          HTTP_CODE=$(echo "$RESPONSE" | tail -1)
          BODY=$(echo "$RESPONSE" | head -n -1)

          echo "HTTP Status: $HTTP_CODE"
          echo "Response: $BODY"

          if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
            echo "Rollback failed"
            exit 1
          fi

          echo "Rollback triggered successfully"

      - name: Health check after rollback
        run: |
          sleep 30

          HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
            --max-time 10 \
            "https://your-app-domain.com/up")

          if [ "$HTTP_CODE" = "200" ]; then
            echo "Application is healthy after rollback"
          else
            echo "Warning: Application returned HTTP $HTTP_CODE after rollback"
            exit 1
          fi
Enter fullscreen mode Exit fullscreen mode

This workflow is triggered manually from the GitHub Actions tab. You can optionally specify a deployment ID to rollback to, or leave it empty to rollback to the previous deployment.

Part 5: Environment-Specific Pipelines

Most teams need separate pipelines for staging and production. Here is how to structure that:

Staging (Deploy on Push to main)

Staging deployments happen automatically on every push to main. This gives your team a preview environment that always reflects the latest merged code.

# In deploy.yml, modify the deploy job:
deploy-staging:
  runs-on: ubuntu-latest
  needs: test
  if: success()
  environment: staging
  steps:
    - name: Deploy to staging
      run: |
        curl -s -X POST \
          -H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN_STAGING }}" \
          -H "Accept: application/json" \
          "https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID_STAGING }}/sites/${{ secrets.DEPLOYNIX_SITE_ID_STAGING }}/deploy"
Enter fullscreen mode Exit fullscreen mode

Production (Deploy on Release Tag)

Production deployments are triggered by creating a release tag:

name: Deploy Production

on:
  release:
    types: [published]

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy to production
        run: |
          curl -s -X POST \
            -H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN_PRODUCTION }}" \
            -H "Accept: application/json" \
            "https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID_PRODUCTION }}/sites/${{ secrets.DEPLOYNIX_SITE_ID_PRODUCTION }}/deploy"
Enter fullscreen mode Exit fullscreen mode

Use GitHub Environments to configure separate secrets for staging and production, and to add approval requirements for production deployments.

Part 6: Best Practices

Keep Tests Fast

Slow tests kill CI/CD adoption. If your test suite takes 15 minutes, developers stop waiting for it and merge without checking results. Target under 5 minutes for your full suite.

  • Run tests in parallel (Pest supports this with --parallel)
  • Use SQLite in-memory databases for tests that do not need MySQL-specific features
  • Mock external API calls instead of hitting real endpoints
  • Cache Composer and npm dependencies between runs

Use Separate API Tokens

Create separate Deploynix API tokens for staging and production, each with only the scopes needed. If your staging token is compromised, it cannot affect production.

Monitor Deployment Frequency

Track how often you deploy. Healthy teams deploy daily or more. If deployment frequency drops, investigate — it usually means the pipeline is slow, flaky, or someone is afraid of deployments.

Do Not Skip Tests

It is tempting to add a "deploy without tests" shortcut for urgent fixes. Do not. Urgent fixes deployed without tests are how you turn one incident into two.

Tag Production Releases

Use semantic versioning (v1.2.3) for production releases. This gives you a clear history of what was deployed when, and makes rollback to a specific version straightforward.

The Complete Flow

Here is what the developer experience looks like with this pipeline in place:

  1. Developer creates a feature branch and pushes code
  2. Tests run automatically on the pull request
  3. If tests fail, the developer is notified and fixes the issue
  4. Pull request is reviewed and approved by a team member
  5. Pull request is merged to main
  6. Tests run again on the merge commit
  7. If tests pass, the Deploynix API is called to trigger deployment
  8. The pipeline monitors deployment status until completion
  9. A health check verifies the application is responding
  10. The team is notified of the result

The entire process from merge to live deployment takes 3-5 minutes, with zero manual steps after the merge.

Conclusion

CI/CD is not optional for professional Laravel applications. The combination of GitHub Actions for testing and the Deploynix API for deployment gives you a complete, automated pipeline that catches bugs before they reach production and deploys changes without manual intervention.

The pipeline described in this guide — test on PR, deploy on merge, health check after deployment, manual rollback as a safety net — covers the needs of the vast majority of Laravel applications. Start with this foundation, customize the health checks and notifications for your specific needs, and add staging/production environment separation when your team is ready.

The goal of CI/CD is not automation for its own sake. It is confidence. Confidence that your tests catch regressions, confidence that deployments are consistent, and confidence that you can rollback quickly if something goes wrong. With this pipeline in place, deploying becomes a non-event — which is exactly what it should be.

Top comments (0)