Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

GitHub Actions for Laravel: Build a CI Pipeline That Catches Breakage (2026)

If you deploy a Laravel app by SSH-ing into your VPS and running git pull by hand, you already know the failure mode: the deploy that worked on your laptop breaks in production, and you find out from a user, not from your tooling. A GitHub Actions Laravel CI pipeline fixes that by running the same checks on every push and every pull request — tests, code style, static analysis, and an asset build — before anything is allowed near production.

Workflow versions verified 24 October 2026 against the action READMEs and current community guides linked below.

What this pipeline checks — and what it deliberately skips

Keep the pipeline boring. Its job is to answer four questions on every change:

  • Do the tests pass against a real MySQL database and Redis, not just SQLite?
  • Is the code formatted to the Laravel standard (Pint)?
  • Does static analysis (Larastan) find type errors the tests missed?
  • Does the frontend build succeed, so a broken Vite change never ships?

That is it. This post does not build a deploy-to-Kubernetes machine or a matrix across five PHP versions. One PHP version — the one production runs — one database, one Redis, and a deploy job that only runs when everything is green. You can add matrix testing later; most solo-dev breakage comes from the basics, not from PHP version drift.

The complete workflow

Create .github/workflows/ci.yml in your repository and paste this in. The sections after it explain each decision, so you can trim what you do not need.

name: Laravel CI

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

jobs:
  tests:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        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: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd="redis-cli ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    env:
      DB_CONNECTION: mysql
      DB_HOST: 127.0.0.1
      DB_PORT: 3306
      DB_DATABASE: testing
      DB_USERNAME: root
      DB_PASSWORD: password
      CACHE_STORE: redis
      REDIS_HOST: 127.0.0.1
      REDIS_PORT: 6379
      QUEUE_CONNECTION: sync
      SESSION_DRIVER: array
      MAIL_MAILER: array

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

      - name: Set up 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: ~/.cache/composer
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-

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

      - name: Prepare environment
        run: |
          cp .env.example .env
          php artisan key:generate

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

      - name: Static analysis with Larastan
        run: vendor/bin/phpstan analyse --memory-limit=1G

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

      - name: Build frontend assets
        run: |
          npm ci
          npm run build

      - name: Run tests
        run: php artisan test

Read the rest of this post before you copy it blindly: the PHP version, the database image, and the Larastan level all need to match your app, and the two most common first-run failures are covered in the troubleshooting section.

Set up PHP to match production, not your laptop

The shivammathur/setup-php@v2 action is the standard way to install PHP on a GitHub runner. Three settings matter:

  • php-version: pin the exact minor version production runs — '8.4' in the example. If production is on 8.3, use '8.3'. The quotes matter in YAML: without them, 8.4 can be parsed as a number and confuse the action.
  • extensions: list what your app actually needs. The example covers the Laravel basics plus mysql and redis for the service containers below. If your app uses GD or intl, add them here — a missing extension is the classic “passes locally, fails in CI” error.
  • coverage: none: turn the coverage driver off unless you publish a coverage report. Xdebug or PCOV slows every run for a report nobody reads on a solo project.

Do not test five PHP versions on day one. A matrix looks impressive and doubles or triples your run time. Match production exactly, and add a matrix only when you actually support multiple versions.

Test against real MySQL and Redis, not just SQLite

SQLite in-memory tests are fast, and for a pure unit-test suite they are fine. But they hide an entire class of real bugs: MySQL-only column behaviour, JSON column quirks, case-sensitivity differences, and anything touching Redis queues or caches. The workflow above uses GitHub’s service containers — throwaway MySQL and Redis instances that start alongside the job:

  • The services block starts mysql:8.0 and redis:7 and maps their ports to the runner.
  • The options health checks (mysqladmin ping, redis-cli ping) mean the job does not start running migrations against a database that is still booting. If you have ever seen a CI failure that passes on re-run, a missing health check is the usual suspect.
  • The env block points Laravel at those containers: DB_HOST: 127.0.0.1, database testing, and Redis on the default port.

Two details people get wrong: the MySQL root password in the service definition and the DB_PASSWORD in the job environment must match, and the database name in MYSQL_DATABASE must match DB_DATABASE. Mismatches here produce “access denied” errors that look like app bugs but are pure config.

Keep QUEUE_CONNECTION: sync and SESSION_DRIVER: array for tests. You want queued jobs to run inline during tests so failures surface immediately, and you do not want test sessions leaking between tests.

Two quality gates that cost almost nothing

Pint: fail on unformatted code, never auto-fix in CI

vendor/bin/pint --test checks formatting without changing any file, and exits non-zero when something would change. That is exactly what you want in CI: the build fails, the developer runs vendor/bin/pint locally, commits, and pushes again. Never let CI auto-fix and commit — an automated commit can stomp on a developer’s local work and trigger a merge-conflict storm. Run Pint first in the step order, because it is the cheapest check: if someone forgot to format, you find out in seconds instead of after a full test run.

Install it with composer require laravel/pint --dev. On a legacy codebase, run Pint once locally and commit the formatting as its own separate commit before you switch the CI check on, or your first green build will drown in diff noise.

Larastan: catch what tests cannot

Install larastan/larastan as a dev dependency and add a phpstan.neon at your project root:

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

parameters:
    level: 5
    paths:
        - app

Start at the highest level your codebase already passes — level 5 in the example — and raise it over time. A level that fails on day one and stays red teaches everyone to ignore CI, which is worse than no check at all. Larastan understands Laravel’s magic — Eloquent model properties, facades, and container bindings — so it finds type errors in code your tests never execute.

Build the frontend in CI, even if you deploy assets separately

The npm ci + npm run build steps exist for one reason: a broken Vite import, a missing dependency, or a TypeScript error should fail the pull request, not the deploy. Use npm ci rather than npm install — it installs exactly what package-lock.json pins, so CI and local builds cannot drift apart. If your app has no frontend build step, delete these two steps; do not leave a no-op npm run build that fails on repos without a build script.

Make it a merge gate, not a suggestion

A pipeline nobody is forced to satisfy is decoration. On GitHub, go to your repository’s Settings, then Branches, add a branch protection rule for main, and turn on “Require status checks to pass before merging”, selecting the tests job. From then on, a pull request with a red check cannot merge from the web UI. Two companion settings are worth turning on in the same rule:

  • Require branches to be up to date before merging — so a green check on stale code cannot merge over a conflicting change.
  • Require a pull request before merging — so nobody pushes straight to main and skips the pipeline entirely.

Expect one week of grumbling while everyone learns to run Pint before pushing. That is the pipeline working.

Deploy only from green builds

Once the test job is trustworthy, add a second job that deploys — and make it depend on the first:

  deploy:
    needs: tests
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Deploy to VPS
        run: |
          echo "Your deploy commands here — SSH, rsync, or a deploy hook"

The two lines that matter are needs: tests (the deploy job never starts unless tests pass) and if: github.ref == 'refs/heads/main' (merges to other branches never deploy). For the actual deploy step, reuse whatever you already do by hand — an SSH command, an rsync, or a webhook to a deploy tool — and keep deployment secrets in the repository’s Settings under Secrets and variables, never in the workflow file. If you want the full VPS deploy walkthrough, it pairs with the blog’s Docker deployment guide linked below.

Five mistakes that make Laravel CI slow or useless

  1. Caching the vendor directory. Cache Composer’s cache directory (~/.cache/composer), keyed on composer.lock — not vendor/ itself. Caching vendor/ through the cache action is explicitly discouraged by the setup-php docs and causes stale-autoloader weirdness.
  2. Running tests before the cheap checks. Order steps cheapest-first: Pint, then Larastan, then migrations, then the build, then tests. Every minute a doomed run spends before failing is a minute nobody gets back.
  3. Testing on a different PHP version than production. A passing build on 8.4 means nothing if production runs 8.3 and your code uses an 8.4-only function. Pin the version.
  4. Skipping the health checks on service containers. Without them, the first step that touches the database races the container’s boot and fails intermittently — the flakiest failure in Laravel CI.
  5. Committing a real .env for CI. Copy .env.example and generate a key in the workflow, as above. A committed .env with real credentials is a security incident waiting for a public fork; a committed .env with dummy values drifts out of sync with .env.example within a month.

Troubleshooting the first red build

“Access denied for user ‘root'” — the MySQL service password and the job’s DB_PASSWORD do not match, or the database name differs. Check the two blocks against each other character by character.

“No application encryption key has been specified” — the php artisan key:generate step is missing or ran before .env existed. Keep the copy-then-generate order from the workflow above.

MySQL connection refused on the first run only — the health check options are missing or malformed. The options block must be a single folded string (note the >- in YAML); a broken fold silently drops the health check.

Pint fails on hundreds of files — that is a legacy codebase, not a broken pipeline. Run vendor/bin/pint locally once, commit the formatting separately, and re-run.

Tests pass locally but fail in CI — check the PHP version first, then the extensions list, then whether your tests depend on local files, local Redis data, or seeded state that does not exist on a fresh runner. CI is a fresh machine every time; anything your tests assume about your laptop is a bug.

What to add next, in order

Once this pipeline is green for a month, add in this order: a scheduled weekly run (so dependency rot shows up even without pushes), a second job that runs your browser tests if you have them, and only then a PHP version matrix if you genuinely support more than one version. Resist the urge to add all three on day one — a pipeline you trust beats a pipeline that impresses.

Further Reading & References

Leave a Comment