DEV Community

Cover image for Laravel CI/CD with GitHub Actions: Tests, Code Quality, and Deployment
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel CI/CD with GitHub Actions: Tests, Code Quality, and Deployment

Originally published at hafiz.dev


If you're still deploying Laravel by running git pull on the server and crossing your fingers, this post is for you. And if you've got tests but they only run when you remember to run them locally, this post is for you too.

GitHub Actions gives you a free CI/CD pipeline that runs on every push. For Laravel, a complete pipeline means: style checks, static analysis, your test suite, asset builds, and an automated deploy when everything passes. Set it up once and you never think about it again.

This post builds the complete pipeline from scratch. Every step is explained, the full workflow file appears at the end as a copy-paste block, and the deployment section covers three different approaches depending on how you host.

What the Pipeline Does

Before writing any YAML, here's the full flow:

View the interactive diagram on hafiz.dev

Code quality checks run first. No point running 400 tests if the formatting is broken. Tests run after. Deployment only triggers on the main branch after everything else passes.

Setting Up the Workflow File

GitHub Actions workflows live in .github/workflows/. Create:

.github/
  workflows/
    ci.yml
Enter fullscreen mode Exit fullscreen mode

Start with the trigger and environment:

name: Laravel CI/CD

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

env:
  PHP_VERSION: '8.4'
  NODE_VERSION: '20'
Enter fullscreen mode Exit fullscreen mode

This runs on every push to main or develop, and on every pull request targeting main. Adjust the branches to match your workflow.

Step 1: Checkout and PHP Setup

jobs:
  build-and-test:
    runs-on: ubuntu-latest

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          extensions: mbstring, xml, ctype, json, bcmath, pdo_sqlite
          coverage: none
Enter fullscreen mode Exit fullscreen mode

shivammathur/setup-php is the community standard for PHP in GitHub Actions. Setting coverage: none is important: it skips loading Xdebug, which meaningfully speeds up the setup step. Only enable coverage if you need coverage reports.

pdo_sqlite is in the extensions list because we'll run tests against an in-memory SQLite database, which is faster and simpler than spinning up a MySQL service container.

Step 2: Install Dependencies with Caching

Composer downloads can take a while. Caching the vendor directory means subsequent runs skip the download if composer.lock hasn't changed:

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

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

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install NPM dependencies
        run: npm ci
Enter fullscreen mode Exit fullscreen mode

actions/setup-node@v4 handles npm caching natively when you pass cache: 'npm'. No separate cache step needed.

composer install flags:

  • --no-interaction: prevents prompts that would hang the CI runner
  • --prefer-dist: downloads zip archives instead of git clones, faster
  • --optimize-autoloader: generates an optimized classmap
  • --no-progress: cleaner output in CI logs

Step 3: Prepare the Laravel Environment

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

      - name: Generate application key
        run: php artisan key:generate --env=ci

      - name: Set directory permissions
        run: chmod -R 755 storage bootstrap/cache
Enter fullscreen mode Exit fullscreen mode

Create a .env.ci file in your repo with CI-specific settings. The critical part is pointing the database at SQLite:

APP_ENV=testing
APP_KEY=
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
Enter fullscreen mode Exit fullscreen mode

Using DB_DATABASE=:memory: means no file gets created, no cleanup needed, and tests run significantly faster. For the artisan commands that reference the database during testing, this just works.

Step 4: Code Style with Laravel Pint

      - name: Check code style with Pint
        run: vendor/bin/pint --test
Enter fullscreen mode Exit fullscreen mode

The --test flag is essential here. Without it, Pint would fix style issues and commit them. You don't want your CI runner making commits. With --test, it exits with code 1 if issues are found, failing the build.

Pint runs first because it's the cheapest check. If someone pushes without running pint locally, CI catches it immediately without burning time on tests.

Step 5: Static Analysis with Larastan

Larastan is PHPStan configured for Laravel. It understands facades, magic methods, relationships, and request properties that vanilla PHPStan would flag as errors:

composer require nunomaduro/larastan --dev
Enter fullscreen mode Exit fullscreen mode

Create phpstan.neon in your project root:

includes:
    - vendor/nunomaduro/larastan/extension.neon

parameters:
    paths:
        - app
    level: 5
    ignoreErrors:
        - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
Enter fullscreen mode Exit fullscreen mode

Level 5 is a solid starting point. It catches undefined method calls and type mismatches without being so strict that you spend more time on type annotations than features. In the workflow:

      - name: Run static analysis
        run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress
Enter fullscreen mode Exit fullscreen mode

--memory-limit=512M prevents PHPStan from hitting PHP's memory limit on large codebases.

Step 6: Run the Test Suite

      - name: Run tests
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ':memory:'
        run: vendor/bin/pest --parallel
Enter fullscreen mode Exit fullscreen mode

Passing DB_CONNECTION and DB_DATABASE as env vars here ensures they override whatever's in your .env.ci. The --parallel flag runs test files concurrently across available CPU cores. On a 4-core GitHub Actions runner, parallel mode typically cuts test suite time by 50-60%.

If you're still on PHPUnit, replace pest with phpunit.

Step 7: Build Frontend Assets

      - name: Build assets with Vite
        run: npm run build
Enter fullscreen mode Exit fullscreen mode

This step serves two purposes. It catches import errors or missing dependencies that would break the frontend. And in some deployment setups, you'll want to upload the built assets rather than building on the server.

Deployment Options

This is where setups diverge. The approach depends on how you host. Three options, in order of complexity.

Option A: Laravel Forge (Simplest)

Forge has a deploy hook, a URL you trigger to run your deploy script. Copy it from your Forge site's Deployments tab and store it as a GitHub secret:

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

    steps:
      - name: Trigger Forge deployment
        run: curl -s "${{ secrets.FORGE_DEPLOY_HOOK }}"
Enter fullscreen mode Exit fullscreen mode

The needs: build-and-test line means this job only runs if the previous job passed. if: github.ref == 'refs/heads/main' restricts deployment to the main branch. PRs run tests but don't deploy.

This is the lowest-friction option. Forge handles the deploy script, zero-downtime switching, and restart management on the server side.

Option B: SSH Deployment

For VPS deployments not managed by Forge, use appleboy/ssh-action:

      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan queue:restart
Enter fullscreen mode Exit fullscreen mode

Add these secrets to your GitHub repository under Settings > Secrets and variables > Actions:

  • SSH_HOST: your server's IP or domain
  • SSH_USER: the deploy user (create a dedicated non-root user)
  • SSH_PRIVATE_KEY: the private key whose public key is in the server's authorized_keys

php artisan migrate --force is required in non-interactive environments. Without --force, Laravel prompts for confirmation before running migrations in production. The queue restart command signals workers to gracefully restart after code is updated, so they pick up the new code rather than continuing to run old code.

Option C: Scotty (SSH Task Runner)

If you prefer defining your deploy steps as reusable scripts rather than inline YAML, Scotty pairs well with this setup. Scotty uses plain bash syntax and gives you better deploy output than raw SSH scripts. The Scotty vs Envoy comparison covers when it's worth the switch.

You'd SSH into the server and run your Scotty deploy task:

      - name: Deploy with Scotty
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            ./vendor/bin/scotty run deploy
Enter fullscreen mode Exit fullscreen mode

Managing Secrets and Environment Variables

GitHub Secrets are encrypted environment variables stored at the repository level. They're never exposed in logs, even if a step tries to print them. Add them under Settings > Secrets and variables > Actions.

For a typical Laravel CI/CD setup, you'll need:

Secret Used in
FORGE_DEPLOY_HOOK Forge webhook URL to trigger deployment
SSH_HOST Server IP or hostname for SSH deployment
SSH_USER SSH username
SSH_PRIVATE_KEY Private key content (the full key, not a path)

For SSH_PRIVATE_KEY, copy the full content of your private key file (typically ~/.ssh/id_rsa or ~/.ssh/id_ed25519). Paste the entire thing into the secret value, including the -----BEGIN and -----END lines.

One mistake that trips people up: the .env.example file in your repo gets copied to .env.ci during the workflow, but any variables that are genuinely secret (API keys, payment credentials) should not be in .env.example. Use GitHub Secrets for those and inject them as environment variables in the relevant step:

      - name: Run tests
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ':memory:'
          STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }}
        run: vendor/bin/pest --parallel
Enter fullscreen mode Exit fullscreen mode

Never commit real secrets to your repo. Even in private repositories. The Composer dependency audit post covers how supply chain attacks target credentials left in repositories. The same principle applies to your CI configuration.

Adding a Status Badge

Once your workflow is running, you can add a status badge to your README.md. It shows the current state of your main branch pipeline:

![Laravel CI](https://github.com/{owner}/{repo}/actions/workflows/ci.yml/badge.svg)
Enter fullscreen mode Exit fullscreen mode

Replace {owner} and {repo} with your GitHub username and repository name. The badge updates automatically after each run. Green means everything passed, red means something failed. Useful at a glance and signals to contributors that the project takes CI seriously.

Branch Strategy

If you're building a SaaS product on Laravel, a working CI/CD pipeline from the start saves significant pain later. The SaaS with Laravel and Filament guide covers the broader architecture, and this pipeline slots in as the deployment layer on top of it.

A pipeline that runs identically on every branch isn't optimized. Here's a sensible split:

On pull requests (any branch → main): Run Pint, Larastan, and tests. Block merging if anything fails. No deployment.

On push to main: Run everything. Deploy only if all checks pass.

On push to develop: Run checks and tests. No deployment (or deploy to a staging environment if you have one).

The workflow trigger at the top handles this:

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
Enter fullscreen mode Exit fullscreen mode

And the deployment job's if condition handles the rest:

if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Enter fullscreen mode Exit fullscreen mode

The Complete Workflow File

Here's the full .github/workflows/ci.yml for copy-pasting:

name: Laravel CI/CD

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

env:
  PHP_VERSION: '8.4'
  NODE_VERSION: '20'

jobs:
  build-and-test:
    runs-on: ubuntu-latest

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          extensions: mbstring, xml, ctype, json, bcmath, pdo_sqlite
          coverage: none

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

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

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install NPM dependencies
        run: npm ci

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

      - name: Generate application key
        run: php artisan key:generate --env=ci

      - name: Set directory permissions
        run: chmod -R 755 storage bootstrap/cache

      - name: Check code style with Pint
        run: vendor/bin/pint --test

      - name: Run static analysis
        run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress

      - name: Run tests
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ':memory:'
        run: vendor/bin/pest --parallel

      - name: Build assets
        run: npm run build

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

    steps:
      - name: Deploy
        # Choose one of the deployment options above and add it here
        run: curl -s "${{ secrets.FORGE_DEPLOY_HOOK }}"
Enter fullscreen mode Exit fullscreen mode

FAQ

Do I need a paid GitHub plan to use GitHub Actions?

No. GitHub Actions is free for public repositories and includes 2,000 minutes per month for private repositories on free plans. Most Laravel projects fit comfortably within that limit. The ubuntu-latest runner costs 1 minute per minute of usage.

What if I don't have Larastan set up yet?

Remove the static analysis step and add it back once you've configured phpstan.neon. Don't skip Pint. It takes 10 seconds to set up and pays off immediately.

Can I run tests against MySQL instead of SQLite?

Yes. Add a MySQL service container to your job, then update the database env vars. The tradeoff is slower pipelines (MySQL startup adds 15-30 seconds) and the added complexity of service container health checks. SQLite in-memory is the right default for most apps.

Why npm ci instead of npm install?

npm ci installs exactly what's in package-lock.json and fails if there are any discrepancies. npm install can update lockfiles silently. In CI you want reproducibility, so npm ci is correct.

My tests pass locally but fail in CI. Where do I start?

Nine times out of ten it's an environment difference. Check: missing PHP extensions, .env.ci values not matching what tests expect, or missing APP_KEY. Add a debug step early in the workflow that runs php artisan about, which surfaces environment details quickly.

Put It in Place

The workflow file goes in .github/workflows/ci.yml. Add .env.ci to your repo with your CI-specific values. Add secrets to your repository settings. Push to a branch, open a pull request, and watch the checks run.

After that, every PR gets a green or red status before it's merged. Every push to main deploys automatically when it passes. You stop thinking about deployment and start thinking about what you're building.

If you're setting this up for the first time and hit a wall, get in touch and we can work through it together.

Top comments (0)