Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

CI/CD Explained With a Simple Laravel Pipeline on GitHub Actions

About Post

Here's a deployment process I'd bet many teams still run: someone finishes a feature, merges it, connects to the server, runs git pull, remembers composer install, half-remembers the migrations, and forgets to restart the queue workers. Everything is fine until the day the person who knows the steps is on holiday.

CI/CD sounds like enterprise jargon, but the core idea is small: let a machine run the same checks and the same deployment steps, in the same order, every time. You don't need Kubernetes for that. You need one YAML file and a deploy script.

Let's build a simple, real pipeline for a Laravel 12 app with GitHub Actions, one step at a time.

CI and CD in one sentence each

  • Continuous Integration (CI): every change is automatically built and tested when it's pushed, so problems are found while they're still small and fresh.
  • Continuous Delivery/Deployment (CD): code that passes the checks can be released, either automatically or with one click, through a scripted process.

CI answers "is this change safe?". CD answers "how does it get to production without anyone typing commands from memory?".

Step 1: a branching flow that the pipeline can guard

A pipeline needs a clear gate to stand at. A simple flow works for most teams:

  1. Work happens on short-lived feature branches.
  2. Every change goes into main through a pull request.
  3. The pull request runs the CI checks, and a teammate reviews the code.
  4. Merging to main deploys.

Then turn on branch protection for main in GitHub: require the CI checks to pass and require at least one approving review. Now "I'll just push a quick fix to main" is impossible by design, not by willpower. This is the way we work at Kate as well: Git branching, pull-request reviews and an automated pipeline in front of production.

Step 2: the CI workflow

Create .github/workflows/ci.yml:

name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none
      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env && php artisan key:generate
      - name: Code style
        run: vendor/bin/pint --test
      - name: Static analysis
        run: vendor/bin/phpstan analyse
      - name: Tests
        run: php artisan test

That's a complete, working CI pipeline. On every pull request and every push to main, GitHub starts a fresh Linux machine, installs the PHP version you choose, installs your dependencies and runs three kinds of checks. Use the same PHP version as production, so the pipeline catches version problems before your server does.

Step 3: understand the three checks

Each check catches a different kind of problem, and they run from cheapest to most expensive:

  • Code style (Pint). Laravel ships with Pint. --test fails the build instead of fixing files, so style discussions disappear from code review. Run vendor/bin/pint locally to fix.
  • Static analysis (PHPStan with Larastan). Finds bugs without running the code: calling a method that doesn't exist, passing the wrong type, a possibly-null value used as an object. Install with composer require --dev larastan/larastan, add a phpstan.neon, and start at a low level. Raise it over time.
  • Tests (PHPUnit or Pest). The behaviour checks. Laravel's default phpunit.xml runs tests against an in-memory SQLite database, which is fast and fine for many apps.

Two gotchas worth knowing. If your app relies on MySQL-specific behaviour (JSON columns, full-text search, strict modes), add a MySQL service container to the job so tests run on the real engine. And if your feature tests render Blade views that use @vite, either add a npm ci and npm run build step, or call $this->withoutVite() in those tests, because there's no built manifest on a fresh runner.

Step 4: deploy only what passed

Now add a second job to the same file. It only runs on pushes to main, and only after the test job succeeds:

  deploy:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Trigger deployment
        run: curl -fsS -X POST "${{ secrets.DEPLOY_HOOK_URL }}"

needs: test is the important line: no green tests, no deploy. The environment: production setting lets you add protection rules in GitHub, such as a required manual approval, if you want delivery instead of fully automatic deployment.

The deploy itself can be triggered in several ways: a deploy hook from a service like Laravel Forge, an SSH step that runs a script on the server, or a container build pushed to a registry. The hook keeps this example simple and keeps server credentials out of the workflow. Whichever you choose, keep secrets in GitHub's encrypted secrets, never in the YAML.

Step 5: a deploy script, not a memory

Whatever triggers it, the server should run the same steps every time. A simplified version:

set -e
cd /var/www/app
git pull origin main
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan migrate --force
php artisan optimize
php artisan queue:restart

set -e stops the script at the first failing command, so a failed install never continues into migrations. php artisan optimize caches config, routes, views and events. queue:restart tells workers to finish their current job and reload the new code; forgetting it is the classic reason a fixed bug "keeps happening" in background jobs.

This script updates the app in place, so there's a short window while files change. Tools like Envoyer, Deployer or a symlink-based release folder give you zero-downtime deploys when you need them.

The principle: anything you have to remember to do during a deployment, you'll eventually forget. Put it in the pipeline or the script, and let the machine be the one with the good memory.

What you get

  • Every pull request shows a green tick or a red cross before anyone reviews it.
  • Style and simple type bugs never reach code review.
  • Nothing reaches production without passing the checks.
  • Deployments are boring, repeatable, and possible for anyone on the team.

Start with the CI file today; it takes minutes. Add the deploy job once you trust your tests. Then improve one piece at a time: caching Composer dependencies, running tests in parallel, adding a staging environment.

What does your deploy process look like right now? And what's the one step you'd most like to stop doing by hand?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close