GitHub Actions CI — Fast Feedback, No Duplicates
Series: Every Laravel Project Should Have These Building Blocks
Part: 24 of 35 Level: Intermediate Prerequisites: None
What You’ll Learn
- The two-pipeline structure:
ci.ymlandquality.yml - Why separating fast feedback from deep analysis matters
- The optimized
ci.yml— tests + style, under 3 minutes - The optimized
quality.yml— Larastan + security audit - Composer and PHP caching strategies
- Common mistakes in Laravel CI setups
Two Pipelines, Two Purposes
Most teams start with one CI file that does everything: lint, test, static analysis, security audit. The problem: when tests fail, you wait 4 minutes before seeing a failure that a 10-second linter run would have caught first. When Larastan fails on a trivial type error, the same failure blocks the PR whether it’s a typo or a logic flaw.
Two pipelines solve this:
ci.yml — Fast feedback (< 3 minutes). Runs on every push to every branch. Tests + code style. If this fails, you don’t merge.
quality.yml — Deep analysis (< 5 minutes). Runs on push to main and on PRs. Larastan + security audit. Fails don’t block immediately — they’re signals for improvement.
ci.yml — Tests and Style
# .github/workflows/ci.yml
name: CI
on:
push:
branches: ["*"]
pull_request:
jobs:
tests:
name: 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=5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: pdo, pdo_mysql, bcmath, json
tools: composer:v2
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate --ansi
- 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 --compact --parallel
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
style:
name: Code Style
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
tools: composer:v2
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Check code style
run: vendor/bin/pint --test
quality.yml — Static Analysis and Security
# .github/workflows/quality.yml
name: Quality
on:
push:
branches: [main]
pull_request:
jobs:
analyse:
name: Static Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
tools: composer:v2
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Run Larastan
run: vendor/bin/phpstan analyse --memory-limit=512M
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
tools: composer:v2
coverage: none
- name: Install production dependencies only
run: composer install --no-interaction --no-dev --prefer-dist
- name: Run security audit
run: composer audit
The Most Common CI Mistakes
1. Larastan in Both Files
Running Larastan in ci.yml AND quality.yml doubles your CI minutes for zero benefit. Put it in quality.yml only.
2. Self-Copying .env
# ❌ This copies a file to itself — a no-op
- run: cp .env.testing .env.testing
# ✅ Copy example and generate a key
- run: |
cp .env.example .env
php artisan key:generate --ansi
3. Missing runner.os in Cache Key
# ❌ Cache collides across different OS runners
key: composer-${{ hashFiles('**/composer.lock') }}
# ✅ OS-specific cache key
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
4. Hardcoded DB Credentials in Matrix
Use GitHub’s services block for the database container, not hardcoded passwords in env files. The service container’s credentials are never committed to the repository.
5. coverage: xdebug When You Don’t Need Coverage
Xdebug slows test execution by 2–5×. Unless you’re generating coverage reports, use coverage: none.
PHP Version Matrix (When Needed)
If you need to test on multiple PHP versions:
jobs:
tests:
strategy:
matrix:
php: ["8.3", "8.4"]
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
For most app codebases (not packages), testing on one PHP version is enough.
Key Takeaways
- Two pipelines:
ci.ymlfor fast feedback (tests + style),quality.ymlfor deep analysis (Larastan + security). - Run
ci.ymlon every push. Runquality.ymlon PRs and merges to main. - Include
runner.osin every Composer cache key to prevent cross-OS collisions. coverage: noneunless you’re actively measuring coverage — it makes tests 2–5x faster.- Use
composer install --no-devin the security audit job — it’s the production dependency set you actually care about. --parallelonphp artisan testcan halve test suite time on multi-core runners.
Tips and Gotchas
⚠️ Warning: Don’t run all quality checks in a single job. A single 8-minute job that combines tests + Larastan + security audit means every push waits 8 minutes for feedback. Split fast checks (tests, Pint) into one job and slow checks (Larastan, audit) into another. Fail fast on the critical path.
💡 Tip: Use
concurrencyin your workflow to cancel in-progress runs when a new push arrives on the same branch. This saves CI minutes and prevents stale feedback on abandoned commits.
🔥 Expert Note: Cache Composer dependencies with
actions/cachekeyed oncomposer.lock. A cache hit cuts install time from ~60s to ~5s on every run. Similarly cache the NPMnode_moduleskeyed onpackage-lock.json.
Further Reading
- GitHub Actions Docs
- GitHub Actions: Caching Dependencies
- GitHub Actions: Concurrency
- Larastan GitHub
← Notifications and Mail | Next: Static Analysis with Larastan →