Introduction
Hey everyone! 👋
Have you ever felt exhausted from having to manually deploy every time there's a code change? Push code to the repository, then log into the server, pull the latest code, run migrations, clear the cache, restart services... and on and on. Once or twice is fine, but doing it repeatedly every day? That gets old fast, right?
That's exactly where CI/CD comes in as a solution. And one of the most popular tools for implementing it is GitHub Actions. In this tutorial, we'll walk through what CI/CD is, why you'd want GitHub Actions, and how to build a complete pipeline for your Laravel project.
Ready? Let's go! 🚀
What Is CI/CD?
Before we jump into implementation, let's cover the fundamentals.
Continuous Integration (CI)
Continuous Integration is the practice where every developer regularly integrates their code into the main branch (typically several times a day). Each integration is then automatically verified — running builds, tests, and linting.
In short, CI ensures the code you write is always in a healthy state and doesn't break anything else. Imagine five developers working on the same project without CI — it would be chaos! 😅
Continuous Delivery / Continuous Deployment (CD)
Continuous Delivery ensures that code which passes CI is always ready to be deployed to production at any time. That means there's an automated pipeline that prepares everything — you just need to click the "deploy" button.
Continuous Deployment takes it a step further: every change that passes CI is automatically deployed to production without any human intervention.
In practice, many teams go with Continuous Delivery (with a manual approval step before deploying) because it's safer. But for smaller projects or side projects, Continuous Deployment can be a great option too.
Why GitHub Actions?
There are plenty of CI/CD tools out there — Jenkins, GitLab CI, CircleCI, Travis CI, and more. But GitHub Actions has some compelling advantages:
- Directly integrated with GitHub — No separate setup needed. If your repo is already on GitHub, just use it.
- Free for public repos — For open source, GitHub Actions is very generous with its free tier.
- Rich marketplace — There are thousands of ready-made actions you can use, from deploying to AWS to sending Slack notifications.
- Easy to learn — The YAML-based syntax is relatively straightforward.
- Multi-platform — Runs on Ubuntu, Windows, and macOS.
Comparison with Other Tools
| Feature | GitHub Actions | GitLab CI | Jenkins | CircleCI |
|---|---|---|---|---|
| Hosted Runner | ✅ | ✅ | ❌ (self-hosted) | ✅ |
| Free Tier | Generous | Moderate | Unlimited (self-hosted) | Limited |
| Marketplace | 20k+ actions | Fewer | 1800+ plugins | Orbs |
| Setup Complexity | Low | Low | High | Medium |
GitHub Actions Fundamentals
Before we create a workflow, it's important to understand the core building blocks of GitHub Actions:
Repository
Workflow files live in the .github/workflows/ folder inside your repository. Each workflow file is written in YAML format.
Workflow
A workflow is an automated process you define. One repository can have many workflows, and each one can be triggered by different events.
Event
An event is what triggers a workflow to run. Examples:
push— When code is pushed to a specific branchpull_request— When a pull request is created or updatedschedule— Based on a cron scheduleworkflow_dispatch— Manual trigger from the GitHub UI
Job
A job is a set of steps that run on the same runner. Multiple jobs can run in parallel or sequentially (depending on dependencies).
Step
A step is an individual instruction within a job. Steps can run commands, use actions, or execute scripts.
Action
An action is a reusable unit you can use to simplify your workflow. For example, actions/checkout@v4 to check out code, or actions/setup-php@v2 to set up PHP.
Runner
A runner is the server that executes the workflow. GitHub provides hosted runners (Ubuntu, Windows, macOS), or you can use a self-hosted runner.
Basic Workflow Structure
Alright, now that we understand the basics, let's look at the simplest possible GitHub Actions workflow:
name: CI Pipeline
# Trigger: when this workflow runs
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Jobs that will execute
jobs:
# Job name
build:
# Runner to use
runs-on: ubuntu-latest
# Steps in this job
steps:
# Step 1: Check out the code
- name: Checkout code
uses: actions/checkout@v4
# Step 2: Run a command
- name: Run a one-line script
run: echo "Hello, GitHub Actions!"Take note of the structure:
name— The workflow name that appears in the Actions tab on GitHubon— The triggers that kick off the workflowjobs— The collection of jobs to executesteps— The steps within each job
Setting Up a PHP Environment for Laravel
Before we dive into a complete Laravel workflow example, let's understand how to set up a PHP environment in GitHub Actions.
Using actions/setup-php
The shivammathur/setup-php action is the most popular way to set up PHP in GitHub Actions. Here's how to use it:
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, ctype, json, bcmath, pdo, sqlite3
tools: composer:v2
coverage: xdebugThe extensions parameter lets you install the PHP extensions you need, and tools lets you install additional tools like Composer.
Using Service Containers
For Laravel, we typically need a database during testing. GitHub Actions provides service containers that we can use:
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=3Service containers run alongside our job and are automatically stopped once the job completes.
Complete CI/CD Workflow for Laravel
Now we'll build a complete CI/CD workflow for a Laravel project. This workflow will:
- Test — Run PHPUnit tests
- Lint — Run PHP CodeSniffer
- Build — Build frontend assets with Vite
- Deploy — Deploy to production
File: .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
PHP_VERSION: '8.2'
NODE_VERSION: '20'
jobs:
# ============================================
# Job 1: Code Quality & Testing
# ============================================
test:
name: Test
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=3
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, sqlite3
tools: composer:v2
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-progress --no-suggest
- name: Copy environment file
run: cp .env.example .env
- name: Generate application key
run: php artisan key:generate
- name: Configure environment
run: |
sed -i 's/DB_DATABASE=laravel/DB_DATABASE=testing/' .env
sed -i 's/DB_USERNAME=root/DB_USERNAME=root/' .env
sed -i 's/DB_PASSWORD=/DB_PASSWORD=password/' .env
- name: Run migrations
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
- name: Run tests
run: php artisan test --coverage --min=80
env:
DB_CONNECTION: mysql
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
fail_ci_if_error: false
# ============================================
# Job 2: Code Linting
# ============================================
lint:
name: Lint
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 }}
tools: composer:v2, phpcs
- 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-progress --no-suggest
- name: Run PHP CodeSniffer
run: vendor/bin/phpcs --standard=PSR12 --report=checkstyle app/ || true
- name: Run PHPStan (static analysis)
vendor/bin/phpstan analyse --memory-limit=2G
# ============================================
# Job 3: Build Assets
# ============================================
build:
name: Build Assets
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- name: Build production assets
run: npm run build
env:
VITE_APP_URL: ${{ secrets.APP_URL }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-assets
path: public/build/
retention-days: 7
# ============================================
# Job 4: Deploy to Production
# ============================================
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [build]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-assets
path: public/build/
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
tools: composer:v2
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
composer install --prefer-dist --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 artifacts to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "public/build/"
target: "/var/www/myapp/public/build/"Breaking Down the Workflow
Let's walk through each section of the workflow above:
Job: Test
This job focuses on running PHPUnit tests. A few things worth noting:
- MySQL Service — We use a MySQL service container for testing since Laravel typically needs a database when running tests.
- Composer Cache — We cache Composer dependencies to speed up build times on subsequent runs.
- Environment Setup — We copy
.env.exampleto.env, generate an application key, and configure the database. - Coverage Report — We use
--coverage --min=80to ensure at least 80% of code is covered by tests.
Job: Lint
This job runs code quality checks:
- PHP CodeSniffer — Ensures code follows the PSR-12 coding standard.
- PHPStan — Performs static analysis to catch potential bugs before the code is even executed.
Job: Build
This job builds frontend assets (CSS, JS) using Vite:
- Node.js Setup — We set up Node.js to run the build command.
- npm ci — Installs dependencies based on
package-lock.jsonfor reproducibility. - Upload Artifacts — Build artifacts are saved so they can be used by the deploy job.
Job: Deploy
This job only runs on the main branch and only on push events:
- Conditional Execution —
if: github.ref == 'refs/heads/main'ensures deploying only happens on the main branch. - Environment Protection —
environment: productionlets you configure approval rules in GitHub. - SSH Deploy — We use
appleboy/ssh-actionto run deployment commands on the server.
Secrets Management
One of the most important aspects of CI/CD is managing secrets (passwords, API keys, SSH keys, etc.). GitHub Actions provides a secure Repository Secrets feature.
Adding Secrets
- Open your repository on GitHub
- Go to Settings → Secrets and variables → Actions
- Click New repository secret
- Enter the secret name and value
Using Secrets in Workflows
steps:
- name: Deploy
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
API_KEY: ${{ secrets.API_KEY }}
APP_URL: ${{ secrets.APP_URL }}
run: |
echo "Deploying to $APP_URL"
# Run your deploy scriptSecurity Tips
- Never hardcode secrets in workflow files. Always use repository secrets.
- Use Environment Secrets for secrets that differ per environment (staging vs production).
- Rotate secrets regularly — Change passwords or API keys periodically for security.
- Use OpenID Connect for authenticating to cloud providers (AWS, GCP, Azure) without storing credentials.
OpenID Connect for AWS
permissions:
id-token: write
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: ap-southeast-1
- name: Deploy to S3
run: aws s3 sync ./public/build/ s3://my-bucket/build/Caching to Speed Up Builds
Slow build times can seriously hurt productivity. GitHub Actions offers several caching strategies:
Cache Composer Dependencies
- 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-restore-keys serves as a fallback when an exact cache match isn't found. This ensures we always get the closest available cache.
Cache Node.js Dependencies
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'The setup-node action has built-in npm dependency caching. Just add cache: 'npm' and all dependencies are cached automatically.
Cache Docker Layers
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=maxWith Docker layer caching, build times can drop dramatically — from minutes down to tens of seconds.
Matrix Builds
Matrix builds let you run workflows across multiple combinations of PHP versions, Node.js versions, or databases in parallel. This is extremely useful for ensuring cross-version compatibility.
Example: Testing Across PHP Versions
jobs:
test:
name: PHP ${{ matrix.php-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: ['8.1', '8.2', '8.3']
database: ['mysql', 'sqlite']
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
# Only start MySQL when matrix database = mysql
if: ${{ matrix.database == 'mysql' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, xml, ctype, json, bcmath, pdo
tools: composer:v2
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Setup SQLite for testing
if: ${{ matrix.database == 'sqlite' }}
run: |
touch database/database.sqlite
cp .env.example .env
sed -i 's/DB_CONNECTION=mysql/DB_CONNECTION=sqlite/' .env
sed -i 's/DB_DATABASE=laravel/DB_DATABASE=database\/database.sqlite/' .env
- name: Setup MySQL for testing
if: ${{ matrix.database == 'mysql' }}
run: |
cp .env.example .env
sed -i 's/DB_DATABASE=laravel/DB_DATABASE=testing/' .env
sed -i 's/DB_USERNAME=root/DB_USERNAME=root/' .env
sed -i 's/DB_PASSWORD=/DB_PASSWORD=password/' .env
- name: Generate application key
run: php artisan key:generate
- name: Run migrations
run: php artisan migrate --force
- name: Run tests
run: php artisan testWith this configuration, tests run across 6 combinations:
- PHP 8.1 + MySQL
- PHP 8.1 + SQLite
- PHP 8.2 + MySQL
- PHP 8.2 + SQLite
- PHP 8.3 + MySQL
- PHP 8.3 + SQLite
Example: Multi-Platform Matrix Build
jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run buildDeployment Workflows for Various Platforms
Deploy to Vercel
name: Deploy to Vercel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
working-directory: ./Deploy to Docker Hub
name: Build and Push Docker Image
on:
push:
tags:
- 'v*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: myusername/myapp
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxDeploy to AWS ECS
name: Deploy to AWS ECS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-southeast-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: myapp
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ecs-task-definition.json
service: myapp-service
cluster: myapp-cluster
wait-for-service-stability: trueReusable Workflows
When you have many projects with similar workflows, reusable workflows can save time and keep things consistent.
Creating a Reusable Workflow
# .github/workflows/reusable-laravel-ci.yml
name: Reusable Laravel CI
on:
workflow_call:
inputs:
php-version:
description: 'PHP version'
required: false
default: '8.2'
type: string
node-version:
description: 'Node.js version'
required: false
default: '20'
type: string
secrets:
SERVER_HOST:
required: true
SERVER_USER:
required: true
SERVER_SSH_KEY:
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ inputs.php-version }}
- run: composer install --prefer-dist --no-progress
- run: php artisan test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ inputs.php-version }}
tools: phpcs
- run: composer install --prefer-dist --no-progress
- run: vendor/bin/phpcs --standard=PSR12 app/Using a Reusable Workflow
# .github/workflows/ci.yml (in the main project)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: ./.github/workflows/reusable-laravel-ci.yml
with:
php-version: '8.2'
secrets: inheritTips and Best Practices
1. Use concurrency to save resources
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueThis cancels any running workflow on the same branch when a new push comes in.
2. Use environments for approval gates
jobs:
deploy:
environment:
name: production
url: https://myapp.comYou can set up approval rules in Settings → Environments → production.
3. Use Dependabot to keep actions updated
Create a file .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"4. Monitor workflow runs
GitHub Actions provides a dashboard for monitoring all workflow runs. Watch for:
- Duration — If workflows start slowing down, there may be a caching issue.
- Failure rate — If failures spike, check for flaky tests.
- Cost — Keep an eye on minute usage, especially for private repos.
5. Don't forget to test your workflow
Before pushing to main, test your workflow on a feature branch first. Create a new branch, make changes to the workflow file, and push. Make sure the workflow runs correctly before merging to main.
Common Troubleshooting
Workflow fails at "composer install"
Make sure the PHP version in your workflow is compatible with the dependencies in composer.json. Use matrix builds to test across multiple PHP versions.
Tests fail due to database issues
Ensure the MySQL service container is fully up before migrations run. Use health check to confirm MySQL is ready to accept connections.
Deploy fails with SSH errors
Verify the SSH key is correct and registered on the server. Also make sure the server IP is added to the firewall allowlist.
Workflow is running slowly
- Use caching for Composer and npm dependencies
- Use
npm ciinstead ofnpm install - Consider using a self-hosted runner for heavy builds
Conclusion
CI/CD with GitHub Actions is a powerful way to automate your development pipeline. By understanding the core concepts of workflows, jobs, steps, and actions, you can build robust and reliable pipelines.
Key takeaways:
- CI/CD isn't just a tool — it's a mindset and way of working that helps teams be more efficient.
- GitHub Actions is incredibly flexible — it can be used for virtually any automation need, not just CI/CD.
- Start simple — You don't need to build a complex workflow right away. Start with just testing, then add steps one at a time.
- Leverage the marketplace — There are plenty of actions already available that you can use out of the box.
- Prioritize security — Always use secrets management and never hardcode credentials.
Now it's your turn to give it a try! Build your first workflow and experience the magic of pushing code and watching everything run automatically. Happy coding! 🎉
This article is part of a DevOps tutorial series. If you have questions or want to request a different topic, feel free to leave a comment below!
More posts
Docker for PHP/Laravel Developers: Complete Guide
· 3 min read
Database Design Patterns Every Developer Should Know
· 8 min read
Microservices vs Monolith: Which One to Choose?
· 11 min read