TL;DR
This article provides a complete GitHub Actions workflow for Laravel: parallel testing with MySQL and Redis, PHP CS Fixer for code style, PHPStan for static analysis, security scanning, and zero-downtime deployment. Copy, adapt, deploy.
Why GitHub Actions?
I've used Jenkins, GitLab CI, CircleCI, and Travis CI over the years. GitHub Actions isn't necessarily the "best" - but for projects already hosted on GitHub, it's the most practical choice. Tight integration, no additional service to manage, and generous free tier for private repos.
What follows is a workflow I've refined over dozens of Laravel projects. It's not theoretical - this runs in production every day, catching bugs before they reach users.
The complete workflow
Let's start with the full workflow file, then break down each section:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
tests:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']
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
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql, bcmath, redis
coverage: xdebug
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Copy environment file
run: cp .env.ci .env
- name: Generate application key
run: php artisan key:generate
- name: Run migrations
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
- name: Run tests
run: php artisan test --parallel --coverage-clover coverage.xml
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
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: coverage.xml
fail_ci_if_error: false
code-style:
name: Code Style
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: php-cs-fixer
- name: Run PHP CS Fixer
run: php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php
static-analysis:
name: Static Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse --memory-limit=2G
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Security check for PHP dependencies
uses: symfonycorp/security-checker-action@v5
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [tests, code-style, static-analysis, security]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.PRODUCTION_SSH_KEY }}
script: |
cd /var/www/myapp
php artisan down --retry=60
git pull origin main
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
php artisan up
Breaking it down
Matrix testing
The test job runs on multiple PHP versions simultaneously:
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']
Setting fail-fast: false ensures all matrix jobs complete even if one fails. This is useful for seeing the full picture - maybe your code works on 8.3 but breaks on 8.2.
Service containers
GitHub Actions makes it easy to spin up MySQL and Redis as service containers:
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
options: --health-cmd="mysqladmin ping" --health-interval=10s
The health check ensures MySQL is ready before tests run. Without it, you'll see intermittent "connection refused" failures.
Dependency caching
Composer installation is one of the slowest steps. Caching cuts this from 2 minutes to seconds:
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
The cache key includes the composer.lock hash, so the cache invalidates automatically when dependencies change.
Parallel testing
Laravel's parallel testing feature can dramatically speed up your test suite:
- name: Run tests
run: php artisan test --parallel --coverage-clover coverage.xml
On a suite with 500+ tests, parallel testing typically reduces runtime by 60-70%.
The environment file
Create a dedicated CI environment file at .env.ci:
# .env.ci
APP_NAME=Laravel
APP_ENV=testing
APP_DEBUG=true
LOG_CHANNEL=stderr
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=testing
DB_USERNAME=root
DB_PASSWORD=password
CACHE_DRIVER=redis
QUEUE_CONNECTION=sync
SESSION_DRIVER=array
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Key settings:
LOG_CHANNEL=stderr- Logs appear in GitHub Actions outputQUEUE_CONNECTION=sync- Jobs run immediately in testsSESSION_DRIVER=array- No session storage needed
PHP CS Fixer Configuration
Create .php-cs-fixer.php in your project root:
<?php
$finder = PhpCsFixer\Finder::create()
->in([
__DIR__ . '/app',
__DIR__ . '/config',
__DIR__ . '/database',
__DIR__ . '/routes',
__DIR__ . '/tests',
])
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'not_operator_with_successor_space' => true,
'trailing_comma_in_multiline' => true,
'phpdoc_scalar' => true,
'unary_operator_spaces' => true,
'binary_operator_spaces' => true,
'blank_line_before_statement' => [
'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
],
'phpdoc_single_line_var_spacing' => true,
'phpdoc_var_without_name' => true,
'class_attributes_separation' => [
'elements' => [
'method' => 'one',
],
],
'method_argument_space' => [
'on_multiline' => 'ensure_fully_multiline',
'keep_multiple_spaces_after_comma' => true,
],
'single_trait_insert_per_statement' => true,
])
->setFinder($finder);
PHPStan Configuration
Create phpstan.neon:
includes:
- ./vendor/larastan/larastan/extension.neon
parameters:
paths:
- app
- config
- database
- routes
level: 6
ignoreErrors:
# Add patterns for known false positives
excludePaths:
- app/Console/Kernel.php
checkMissingIterableValueType: false
Start with level 5 or 6. Going straight to level 9 on an existing project will generate thousands of errors.
Larastan
Install Larastan (composer require --dev larastan/larastan) for Laravel-specific PHPStan rules. It understands Eloquent, facades, and other Laravel patterns that would otherwise confuse PHPStan.
Zero-downtime deployment
The deployment script in the workflow uses php artisan down for simplicity. For true zero-downtime deployments, consider this alternative approach:
#!/bin/bash
# deploy.sh - Zero-downtime deployment script
set -e
DEPLOY_PATH="/var/www/myapp"
RELEASES_PATH="$DEPLOY_PATH/releases"
SHARED_PATH="$DEPLOY_PATH/shared"
CURRENT_PATH="$DEPLOY_PATH/current"
RELEASE=$(date +%Y%m%d%H%M%S)
echo "Creating release directory..."
mkdir -p "$RELEASES_PATH/$RELEASE"
echo "Cloning repository..."
git clone --depth 1 git@github.com:user/repo.git "$RELEASES_PATH/$RELEASE"
echo "Linking shared files..."
ln -nfs "$SHARED_PATH/.env" "$RELEASES_PATH/$RELEASE/.env"
ln -nfs "$SHARED_PATH/storage" "$RELEASES_PATH/$RELEASE/storage"
echo "Installing dependencies..."
cd "$RELEASES_PATH/$RELEASE"
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
echo "Running migrations..."
php artisan migrate --force
echo "Caching configuration..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
echo "Switching to new release..."
ln -nfs "$RELEASES_PATH/$RELEASE" "$CURRENT_PATH"
echo "Restarting queue workers..."
php artisan queue:restart
echo "Cleaning old releases..."
ls -1dt "$RELEASES_PATH"/* | tail -n +6 | xargs rm -rf
echo "Deployment complete!"
Secrets configuration
Configure these secrets in your GitHub repository settings:
PRODUCTION_HOST- Your server's IP or hostnamePRODUCTION_USER- SSH usernamePRODUCTION_SSH_KEY- Private SSH key (without passphrase)
Generate a dedicated deployment key:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
Add the public key to ~/.ssh/authorized_keys on your server, and the private key as a GitHub secret.
Workflow optimisation tips
1. Run jobs in parallel
The code-style, static-analysis, and security jobs don't depend on each other. They run in parallel automatically, reducing total CI time.
2. Skip unnecessary runs
Add path filters to skip CI for documentation-only changes:
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
3. Use concurrency controls
Prevent multiple deployments from running simultaneously:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
What this catches
With this workflow in place, every pull request is automatically checked for:
- Test failures across PHP versions
- Code style violations
- Type errors and potential bugs (PHPStan)
- Known security vulnerabilities in dependencies
Problems are caught before code review even begins. Reviewers can focus on architecture and logic instead of spotting typos and style issues.
Need help setting up CI/CD for your Laravel project? I've implemented these workflows for teams of all sizes. Let's talk.