CI failures have a reputation for being cryptic. After years of staring at GitHub Actions logs, I've found that 90% of all failures fall into one of five categories. If you know the categories, you can diagnose any failure in under 10 minutes.
Step 1: Read the Full Log
GitHub Actions collapses log sections by default. Always expand the failing step and scroll to the first error, not the last. PHP and Laravel produce cascading errors — one root cause produces 50 error lines. The one that matters is always at the top.
The Five Failure Classes
Class 1: Missing File or Directory
Symptom: No such file or directory, Please provide a valid cache path, Class not found
Cause: A file exists on your machine but was never committed to git. Common culprits: storage skeleton directories, empty test directories, generated config files.
Fix: Run git status and git ls-files --others --exclude-standard locally. Commit what's missing. For directories, add a .gitignore placeholder so git tracks the folder.
Class 2: Wrong Connection String
Symptom: SQLSTATE[HY000] [2002] Connection refused, cURL error 7: Failed to connect
Cause: CI is trying to connect to a service (database, Redis, Reverb, Pusher, Mailpit) that isn't running in CI.
Fix: Add the service to your workflow services: block, OR set the connection to a CI-appropriate driver:
echo "BROADCAST_CONNECTION=log" >> .env.testing
echo "QUEUE_CONNECTION=sync" >> .env.testing
echo "MAIL_MAILER=array" >> .env.testing
echo "SCOUT_DRIVER=null" >> .env.testing
Class 3: Missing Environment Variable
Symptom: Undefined array key, a 500 where a config value was expected, Target class [X] does not exist
Cause: Your .env.example is missing a key that the application reads. Works locally because your real .env has it; fails in CI because CI starts from .env.example.
Fix: Add the missing key to .env.example with a safe default value.
Class 4: SQL Dialect Mismatch
Symptom: Tests pass locally (SQLite), fail in CI (MySQL). FUNCTION X does not exist, Ambiguous column 'status'
Cause: Raw SQL written against SQLite syntax.
Fix: Replace with MySQL equivalents:
// Wrong (SQLite)
->selectRaw("first_name || ' ' || last_name as full_name")
->selectRaw("strftime('%Y-%m', created_at) as month")
// Correct (MySQL)
->selectRaw("CONCAT(first_name, ' ', last_name) as full_name")
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month")
Class 5: Tooling Version Difference
Symptom: A step that worked last week suddenly fails. Different error messages than your local run.
Cause: A dependency was updated — PHPStan 1.x → 2.x dropped config options; ESLint 9 removed legacy commands; PHP 8.4 changed behaviour of some functions.
Fix: Pin major versions in composer.json/package.json for tools that release breaking changes. Check the changelog when a step breaks with a "deprecated option" or "unknown argument" error.
Reproducing CI Locally
The fastest way to debug CI is to reproduce it locally with the same env vars:
# Create a clean test database
mysql -uroot -p -e "DROP DATABASE IF EXISTS your_app_test; CREATE DATABASE your_app_test;"
# Export the same env vars CI uses
export DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306
export DB_DATABASE=your_app_test DB_USERNAME=root DB_PASSWORD=secret
export QUEUE_CONNECTION=sync BROADCAST_CONNECTION=log
# Run the exact commands CI runs
composer install --no-interaction --prefer-dist
php artisan migrate --env=testing --force
./vendor/bin/pest
If it fails here, iterate fast. If it passes here but fails in CI, the difference is in one of the five classes above.
Reading Exit Codes
| Exit code | Meaning |
|---|---|
0 |
Success |
1 |
Tests ran, some failed |
2 |
Configuration error — Pest: test directory not found; PHPStan: config error |
127 |
Command not found — binary not installed or not in PATH |
130 |
Process killed — usually OOM, increase --memory-limit
|
Exit code 2 from Pest means it never reached your tests. Check that every directory listed in phpunit.xml exists in git and contains at least one test file.
The Diagnostic Checklist
When CI goes red:
- Expand the failing step log and find the first error
- Identify which of the five classes it belongs to
- Check if it's exit code 2 (config) vs 1 (test failure)
- Reproduce locally with the same env vars
- Fix, push, watch CI — don't guess and iterate blindly
With this system you go from red CI to root cause in minutes, not hours. That's the whole job — build the pipeline once, trust it forever.
This is the final post in the series. If you followed from Post 1 you now have: a GitHub Actions workflow running on every push, Pest tests against real MySQL, PHPStan and Pint quality gates, secure secrets management, zero-downtime VPS deployment, and the debugging playbook for when things go wrong. That's a production-grade CI/CD pipeline — the same foundation I put on every serious Laravel project.
Originally published at dineshstack.com — read the full version with code samples and updates there.
Top comments (0)