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:
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
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'
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
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
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
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
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
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
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#'
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
--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
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
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 }}"
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
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'sauthorized_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
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
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:

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]
And the deployment job's if condition handles the rest:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
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 }}"
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)