Web Development 5-8 minutes

CI/CD with GitHub Actions for Laravel: Tests, Pint, and Automated Deploys

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
CI/CD with GitHub Actions for Laravel: Tests, Pint, and Automated Deploys

Every git push can become a mini-release: tests, code quality checks, and a deploy to production without anyone opening a terminal. GitHub Actions for Laravel is the de facto standard for CI/CD in PHP projects in 2026, and this tutorial builds the full pipeline step by step.

What a CI/CD Pipeline Is and Why Your Laravel Project Needs It

A continuous integration and deployment pipeline chains automated tasks that run on a clean machine every time the repository receives new code: install dependencies, run tests, check style, verify types, and build assets. If everything is green, it deploys. The benefit is not just saving time: code that reaches production has passed the same checks on every commit, which eliminates the classic "it works on my machine" problem.

GitHub Actions has an extra advantage over external CI services: workflows live inside the repository itself, versioned with the code, and the Marketplace offers ready-made actions for every stage of the pipeline. For a typical Laravel 13 project, the whole flow fits in a single YAML file.

The Workflow Skeleton: on, Jobs, and Steps

A workflow lives in .github/workflows/ci.yml and has three levels: on declares the events that trigger it, jobs groups independent jobs that run in parallel, and each job contains an ordered list of steps executed inside a runner. GitHub runners are ephemeral machines with Linux, macOS, or Windows; for Laravel, ubuntu-latest covers 99% of cases.

Triggers: push and pull_request

The most common trigger combines push and pull_request: the pipeline runs both when code is pushed to the main branch and when someone opens a pull request, which is exactly where catching a broken test before merging pays off. You can narrow it down to specific branches to avoid burning minutes on working branches:

name: CI
on:
  push:
    branches: [main]
  pull_request:

Installing PHP with shivammathur/setup-php

The shivammathur/setup-php action is the standard way to prepare PHP on GitHub Actions. It supports multiple platforms, any released PHP version, and fine-grained environment configuration: extensions, php.ini settings, code coverage, and tools like Composer or Xdebug. For Laravel, the extensions you almost always need are mbstring, intl, pdo_mysql, and redis if you use queues with Redis.

Extensions, php.ini, and Code Coverage

Everything is declared in the action's with block. For example, coverage: xdebug enables Xdebug only when the job needs coverage, and options like ini-values let you adjust memory_limit without touching anything locally. The result is a runner with a PHP environment practically identical to production.

Composer with Cache: From Minutes to Seconds

The slowest step in any PHP pipeline is composer install, because it downloads hundreds of packages from Packagist on every run. The fix is caching the vendor directory between runs: GitHub Actions stores the files in its 10 GB per-repository cache and restores them when the key matches.

Caching vendor Against composer.lock with actions/cache

The key is built from the hash of composer.lock:

- uses: actions/cache@v4
  with:
    path: vendor
    key: composer-${{ hashFiles('composer.lock') }}

As long as the lockfile does not change, composer install restores vendor from cache and downloads nothing from the network. In real 2026 measurements, caching dependencies cut a PHP workflow from 30 to 20 seconds on a small project, and on heavier pipelines the average saving was around 4.5 minutes per run. Caching Composer's own internal cache with the same strategy is also worth it.

Quality in the Pipeline: Pint, Larastan, and Tests

The heart of the pipeline is quality checking. Laravel Pint fixes code style following the framework's official preset, and Larastan (PHPStan on top of Laravel) catches type errors and wrong container usage that tests never see. Both run in strict mode: vendor/bin/pint --test fails if anything is unformatted, and vendor/bin/phpstan analyse fails on any error level.

Pest and PHPUnit: php artisan test

Tests run with php artisan test, which works the same with Pest and PHPUnit. The important part is preparing the database first: migrating and seeding on every run guarantees tests always execute against a known state. If you use Pest's parallel testing, the pipeline can split tests across multiple processes to cut total time.

MySQL and Redis as Container Services

No external database is needed: GitHub Actions can spin up services as containers inside the job itself using the services key. A MySQL 8.0 container with a health check, exposed on port 3306, plus Redis on 6379, is enough for tests to use the same technologies as production:

services:
  mysql:
    image: mysql:8.0
    env:
      MYSQL_DATABASE: testing
      MYSQL_ALLOW_EMPTY_PASSWORD: yes
    ports: ['3306:3306']
    options: >-
      --health-cmd="mysqladmin ping"
      --health-interval=10s
      --health-timeout=5s
      --health-retries=5

Building Assets: Node and Vite in the Workflow

Laravel 13 uses Vite to compile JavaScript and CSS, so the pipeline needs a Node job. The typical pattern is actions/setup-node with npm caching, followed by npm ci and npm run build. A good practice is uploading the compiled assets as an artifact (actions/upload-artifact) so the deploy job downloads them and production never has to rebuild.

Deploying: Forge, Deployer, or SSH

When tests and build pass, it is deploy time. There are three common routes. Laravel Forge exposes a deployment endpoint that its CLI can call with a token; Deployer defines servers in a deploy.php file and runs tasks over SSH; and for simple setups, a direct SSH git pull plus php artisan migrate --force works just as well. In all three cases, the deploy job should only run on the main branch after a push, never on pull requests:

deploy:
  needs: [tests, build]
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: forge deploy
      env:
        FORGE_API_TOKEN: ${{ secrets.FORGE_API_TOKEN }}

Secrets: Production Keys Without Exposing Them

Credentials live in the repository's Settings > Secrets and variables and are injected with ${{ secrets.NAME }}. They are never written in the YAML or in history: GitHub masks them in logs and keeps them inaccessible from pull requests on forks, which is the most common CI attack vector.

PHP Matrix: Testing Multiple Versions at Once

With strategy.matrix, the same job runs with several configurations in parallel. Declaring a matrix with PHP 8.3, 8.4, and 8.6 immediately warns you if a dependency update breaks compatibility with any version, something almost nobody checks locally. The cost is more CI minutes, so many projects limit the matrix to tests and run Pint and Larastan on a single version.

Free Plan Limits and Optimization

GitHub Actions is free for public repositories, with unlimited minutes. On private repositories, the free plan includes a monthly quota of minutes and storage for artifacts and cache, expandable by paying. To stretch it: cache Composer and npm, avoid running the pipeline on every working branch, use paths to skip jobs when only documentation changes, and upgrade the runner image when times start to grow.

Conclusion

With a single YAML file, your Laravel project moves from depending on anyone's memory to a reproducible flow: PHP with extensions, cached Composer, Pint, Larastan, tests on a real MySQL, and automated deploys to production. The first pipeline costs a morning, and from then on every push delivers with the same guarantee. If you want to go deeper into the ecosystem, the blog has guides on Pest 3, Laravel 13's new features, and local development with Laravel Sail that fit right into this flow.

Categories