Tools: Laravel CI/CD with GitHub Actions: Tests, Code Quality, and Deployment (2026)

Tools: Laravel CI/CD with GitHub Actions: Tests, Code Quality, and Deployment (2026)

What the Pipeline Does

Setting Up the Workflow File

Step 1: Checkout and PHP Setup

Step 2: Install Dependencies with Caching

Step 3: Prepare the Laravel Environment

Step 4: Code Style with Laravel Pint

Step 5: Static Analysis with Larastan

Step 6: Run the Test Suite

Step 7: Build Frontend Assets

Deployment Options

Option A: Laravel Forge (Simplest)

Option B: SSH Deployment

Option C: Scotty (SSH Task Runner)

Managing Secrets and Environment Variables

Adding a Status Badge

Branch Strategy

The Complete Workflow File

Put It in Place 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. 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. GitHub Actions workflows live in .github/workflows/. Create: Start with the trigger and environment: This runs on every push to main or develop, and on every pull request targeting main. Adjust the branches to match your workflow. 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. Composer downloads can take a while. Caching the vendor directory means subsequent runs skip the download if composer.lock hasn't changed: actions/setup-node@v4 handles npm caching natively when you pass cache: 'npm'. No separate cache step needed. composer install flags: Create a .env.ci file in your repo with CI-specific settings. The critical part is pointing the database at SQLite: 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. 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. Larastan is PHPStan configured for Laravel. It understands facades, magic methods, relationships, and request properties that vanilla PHPStan would flag as errors: Create phpstan.neon in your project root: 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: --memory-limit=512M prevents PHPStan from hitting PHP's memory limit on large codebases. 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. 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. This is where setups diverge. The approach depends on how you host. Three options, in order of complexity. 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: 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. For VPS deployments not managed by Forge, use appleboy/ssh-action: Add these secrets to your GitHub repository under Settings > Secrets and variables > Actions: 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. 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: 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: 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: 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. 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: 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. 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: And the deployment job's if condition handles the rest: Here's the full .github/workflows/ci.yml for copy-pasting: 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

.github/ workflows/ ci.yml .github/ workflows/ ci.yml .github/ workflows/ ci.yml name: Laravel CI/CD on: push: branches: [main, develop] pull_request: branches: [main] env: PHP_VERSION: '8.4' NODE_VERSION: '20' name: Laravel CI/CD on: push: branches: [main, develop] pull_request: branches: [main] env: PHP_VERSION: '8.4' NODE_VERSION: '20' 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 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 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: 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: 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: 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: 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 APP_ENV=testing APP_KEY= DB_CONNECTION=sqlite DB_DATABASE=:memory: CACHE_DRIVER=array SESSION_DRIVER=array QUEUE_CONNECTION=sync MAIL_MAILER=array APP_ENV=testing APP_KEY= DB_CONNECTION=sqlite DB_DATABASE=:memory: CACHE_DRIVER=array SESSION_DRIVER=array QUEUE_CONNECTION=sync MAIL_MAILER=array APP_ENV=testing APP_KEY= DB_CONNECTION=sqlite DB_DATABASE=:memory: CACHE_DRIVER=array SESSION_DRIVER=array QUEUE_CONNECTION=sync MAIL_MAILER=array - name: Check code style with Pint run: vendor/bin/pint --test - name: Check code style with Pint run: vendor/bin/pint --test - name: Check code style with Pint run: vendor/bin/pint --test composer require nunomaduro/larastan --dev composer require nunomaduro/larastan --dev composer require nunomaduro/larastan --dev includes: - vendor/nunomaduro/larastan/extension.neon parameters: paths: - app level: 5 ignoreErrors: - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#' includes: - vendor/nunomaduro/larastan/extension.neon parameters: paths: - app level: 5 ignoreErrors: - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#' includes: - vendor/nunomaduro/larastan/extension.neon parameters: paths: - app level: 5 ignoreErrors: - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#' - name: Run static analysis run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress - name: Run static analysis run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress - 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: Run tests env: DB_CONNECTION: sqlite DB_DATABASE: ':memory:' run: vendor/bin/pest --parallel - name: Run tests env: DB_CONNECTION: sqlite DB_DATABASE: ':memory:' run: vendor/bin/pest --parallel - name: Build assets with Vite run: npm run build - name: Build assets with Vite run: npm run build - name: Build assets with Vite 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: Trigger Forge deployment run: curl -s "${{ secrets.FORGE_DEPLOY_HOOK }}" 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 }}" 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 }}" - 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 - 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 - 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 - 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 - 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 - 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 - name: Run tests env: DB_CONNECTION: sqlite DB_DATABASE: ':memory:' STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }} run: vendor/bin/pest --parallel - name: Run tests env: DB_CONNECTION: sqlite DB_DATABASE: ':memory:' STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }} run: vendor/bin/pest --parallel - name: Run tests env: DB_CONNECTION: sqlite DB_DATABASE: ':memory:' STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }} run: vendor/bin/pest --parallel ![Laravel CI](https://github.com/{owner}/{repo}/actions/workflows/ci.yml/badge.svg) ![Laravel CI](https://github.com/{owner}/{repo}/actions/workflows/ci.yml/badge.svg) ![Laravel CI](https://github.com/{owner}/{repo}/actions/workflows/ci.yml/badge.svg) on: push: branches: [main, develop] pull_request: branches: [main] on: push: branches: [main, develop] pull_request: branches: [main] on: push: branches: [main, develop] pull_request: branches: [main] if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push' 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 }}" 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 }}" 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 }}" - --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 - 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