Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

CI/CD Pipeline with GitLab CI for Laravel: Step-by-Step

By Kokil Thapa | Last reviewed: August 2026

Shipping Laravel applications manually via FTP or SSH is a liability for any production system. A properly configured CI/CD pipeline with GitLab CI for Laravel automates testing, asset compilation, and deployment, eliminating human error and ensuring consistent releases. Whether you are running a legal-tech portal or an eCommerce platform, this automation is the difference between fragile deployments and reliable engineering.

How Do You Structure a CI/CD Pipeline with GitLab CI for Laravel?

The most common mistake I see when developers first attempt a CI/CD pipeline setup is treating the build server like a production server. It is not. Your CI runner should never have access to your production database, and it should never run npm install during the actual deployment phase. Instead, structure your pipeline into distinct, isolated stages that pass artifacts forward.

In my experience maintaining multiple Laravel applications on shared infrastructure, the optimal pipeline structure consists of four discrete stages: test, build, staging, and production. This separation ensures that broken code never reaches a build stage, and untested assets never reach production.

TESTPHPUnit + PintBUILDComposer + ViteSTAGINGAuto DeployPRODUCTIONManual TriggerArtifacts passed between stages — no rebuilds
Recommended four-stage CI/CD pipeline with GitLab CI for Laravel: test, build, staging auto-deploy, and manual production release

This architecture assumes you are using Laravel 12.x with PHP 8.4 and Vite 6.x for asset compilation. The key principle is immutability: what gets tested is exactly what gets deployed. By compiling assets once in the build stage and passing them as artifacts, you avoid version drift between environments.

How Do You Configure Testing and Caching in .gitlab-ci.yml?

Your .gitlab-ci.yml file is the single source of truth for your pipeline. For a Laravel developer working with tight budgets and limited CI minutes, efficient caching is non-negotiable. Without it, every pipeline run downloads Composer and npm dependencies from scratch, wasting time and money.

Base Configuration and Cache Strategy

Start by defining global cache keys based on your lock files. This ensures caches invalidate only when dependencies actually change:

<?php
# .gitlab-ci.yml (YAML, not PHP — shown in code block for syntax highlighting)
image: php:8.4-cli

variables:
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
  NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm-cache"

cache:
  key:
    files:
      - composer.lock
      - package-lock.json
  paths:
    - .composer-cache/
    - .npm-cache/
    - vendor/
    - node_modules/

stages:
  - test
  - build
  - staging
  - production

The Test Stage

Your test job should install dependencies, run static analysis, and execute PHPUnit. On real client projects, I always include Laravel Pint for code style enforcement here — catching formatting issues before they reach code review saves significant time:

test:
  stage: test
  script:
    - apt-get update && apt-get install -y git unzip libzip-dev
    - docker-php-ext-install zip pdo_mysql
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-interaction --no-progress
    - ./vendor/bin/pint --test
    - ./vendor/bin/phpunit --coverage-text
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Note the rules block. Running tests on every push to every branch burns CI minutes unnecessarily. Restrict full test suites to merge requests and the default branch. For feature branches, consider a lighter lint-only job.

How Do You Build and Compile Assets Without Rebuilding on Deploy?

This is where many Laravel CI/CD pipelines fail. Developers often run npm run build inside the deploy job, which means the production server needs Node.js installed and the build happens during deployment. This is slow, fragile, and violates the principle of immutable artifacts.

Build Jobcomposer install --no-devnpm ci && npm run buildGenerate manifest.jsonPipeline Artifactsvendor/ (production only)public/build/ (Vite output)public/mix-manifest.jsonbootstrap/cache/Deploy JobDownloads artifactsNo npm / No composerdep deploy production⚠ Production server needs ONLY PHP + Nginx/ApacheNode.js, npm, and Composer are NOT required on the target server
Artifact-based build flow: compile once in CI, deploy pre-built vendor and Vite assets without Node.js on production

The Build Job Configuration

Create a dedicated build job that produces a clean, production-ready artifact bundle:

build:
  stage: build
  image: node:22-bookworm
  script:
    - apt-get update && apt-get install -y git unzip libzip-dev php-cli
    - docker-php-ext-install zip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-interaction --no-progress --no-dev --optimize-autoloader
    - npm ci --cache .npm-cache
    - npm run build
    - php artisan config:cache
    - php artisan route:cache
    - php artisan view:cache
  artifacts:
    paths:
      - vendor/
      - public/build/
      - bootstrap/cache/
    expire_in: 1 week
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Critical details here: --no-dev excludes debugbar and testing packages from production. optimize-autoloader generates the class map for faster autoloading. The cache commands pre-compile configuration, routes, and views so the first production request doesn't pay that cost. These cached files are included in the artifact and transferred to the server during deployment.

How Do You Implement Zero-Downtime Deployment with Deployer 7?

For any business-critical Laravel application — whether it's a legal-tech portal processing client documents or an eCommerce store handling payments — zero-downtime deployment is mandatory. Deployer 7 achieves this through atomic symlink swaps, and it integrates cleanly with GitLab CI artifacts.

Deployer Configuration

Your deploy.php should be configured to accept pre-built artifacts rather than running Composer on the server:

<?php
namespace Deployer;

require 'recipe/laravel.php';

set('application', 'laravel-app');
set('repository', 'git@gitlab.com:your-org/laravel-app.git');
set('php_version', '8.4');

// Skip composer install on server — we use CI artifacts
set('composer_action', 'skip');

host('production')
    ->set('hostname', 'your-server-ip')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/var/www/laravel-app')
    ->set('branch', 'main');

// Upload CI artifacts instead of building on server
task('deploy:upload_artifacts', function () {
    upload('vendor/', '{{release_path}}/vendor/');
    upload('public/build/', '{{release_path}}/public/build/');
    upload('bootstrap/cache/', '{{release_path}}/bootstrap/cache/');
});

// Replace default deploy:vendors with artifact upload
after('deploy:update_code', 'deploy:upload_artifacts');

// Reload PHP-FPM after symlink swap for opcache invalidation
task('deploy:fpm_reload', function () {
    run('sudo systemctl reload php8.4-fpm');
});

after('deploy:symlink', 'deploy:fpm_reload');

desc('Deploy project');
task('deploy', [
    'deploy:prepare',
    'deploy:unlock',
    'deploy:lock',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:writable',
    'deploy:migrate',
    'deploy:publish',
    'deploy:fpm_reload',
    'deploy:unlock',
]);

The Deploy Job

Your GitLab CI deploy job simply downloads the build artifact and runs Deployer:

deploy_production:
  stage: production
  image: deployer/deployer:7.x
  dependencies:
    - build
  script:
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - dep deploy production --no-interaction
  environment:
    name: production
    url: https://yourdomain.com
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The when: manual directive is intentional. Production deployments should require explicit human approval. Staging can be automatic; production should not be. Store $SSH_PRIVATE_KEY and $SSH_KNOWN_HOSTS as masked CI/CD variables in GitLab settings, never in your repository.

ApproachBuild LocationServer RequirementsDeploy SpeedConsistency
Artifact-based (recommended)CI RunnerPHP + Web Server only~30 secondsGuaranteed identical
Server-side buildProduction ServerPHP + Node + npm + Composer3–8 minutesRisk of version drift
Docker containerCI RunnerDocker runtime~45 secondsGuaranteed identical

For most Nepal-based clients where server resources are constrained and budget matters, the artifact-based approach delivers the best balance of speed, reliability, and cost. Docker adds operational complexity that many small teams cannot sustain long-term.

What Are Common CI/CD Pitfalls Specific to Laravel in 2026?

After years of debugging deployment failures across dozens of production Laravel applications, certain problems recur with predictable regularity. Understanding these before they hit your pipeline saves hours of 2 AM troubleshooting.

Deployment Failed?Error occurs AFTER symlink swap?YESNOOPcache / FPM IssueReload php-fpm after deployBuild / Dependency IssueCheck lock files + cache keysStale Cron Paths?Use {{current_path}} in crontabPermission Errors?Verify writable_dirs + ownerEnv Variables Missing?Node Version Mismatch?
Diagnostic decision tree for common Laravel CI/CD pipeline failures: OPcache, permissions, cron paths, and environment issues

Deployer swaps the current symlink atomically, but PHP-FPM continues serving opcached files from the old release path. If you don't reload PHP-FPM after deployment, users will see a mix of old and new code until the opcache naturally expires. Always include a systemctl reload php8.4-fpm step in your deploy task. This is safe — reload sends SIGUSR2 to workers, which gracefully finish current requests before restarting. It does not drop connections.

Stale Cron Job Paths

Laravel's scheduler entry in crontab typically references /var/www/app/current/artisan schedule:run. After a Deployer release, the current symlink points to a new directory, but if your crontab uses a hardcoded absolute path from a previous manual setup, scheduled jobs silently stop executing. Always verify your crontab references the symlink path, and test it immediately after your first automated deployment. I've seen this cause missed email notifications and unpaid invoice reminders on legal-tech portals — the kind of silent failure that erodes client trust over weeks before anyone notices.

Environment Variable Drift

Your CI runner has different environment variables than your production server. Never assume APP_ENV, DB_DATABASE, or payment gateway keys exist in both places. The .env file should live in Deployer's shared directory (shared/.env) and be symlinked into each release. Your CI build job should use a separate .env.ci for testing, and your deploy job should never modify the production .env. If you need to update environment variables, do it directly on the server or through a secrets management tool — not through CI.

Vite Manifest Mismatches

Laravel's Vite integration relies on public/build/manifest.json to resolve asset URLs. If your build job generates this file but your deploy job accidentally overwrites the public/build/ directory with stale content, you'll get 404 errors on CSS and JavaScript. Ensure your artifact upload in Deployer uses exact path matching and that no other task touches public/build/ after the artifact upload completes.

Ready to Automate Your Laravel Deployments?

A well-configured CI/CD pipeline with GitLab CI for Laravel transforms deployment from a stressful manual ritual into a routine, repeatable process. Start with the four-stage structure outlined here, implement artifact-based builds, and add zero-downtime deployment with Deployer 7. Monitor your first few dozen deploys closely — the pitfalls described above tend to surface early, and fixing them once prevents recurring pain. If you need help setting up or debugging your Laravel CI/CD pipeline, reach out through my contact page to discuss your specific infrastructure and requirements.

Frequently Asked Questions

You need PHP 8.2 or higher, Node.js 22 LTS for asset compilation, and Composer 2.x. The runner requires at least 2GB RAM to handle parallel testing and dependency installation without timing out during pipeline execution.

Define image: php:8.4-cli in your default block, then add services like mysql:8.0 or redis:7.x as needed. Specify before_script commands to install system dependencies via apt-get, enable PHP extensions, and run composer install --no-interaction --prefer-dist to prepare the environment before executing test or build stages.

Yes, GitLab offers 400 compute minutes monthly for free-tier private repositories, which suffices for small-to-medium Laravel applications. Self-managed runners on your own infrastructure provide unlimited minutes but require Ubuntu 22/24 server administration and maintenance overhead.

Never commit .env to version control. Store sensitive values as masked CI/CD variables in GitLab project settings under Settings > CI/CD > Variables. In your pipeline, create .env dynamically using echo "$ENV_CONTENTS" > .env during before_script, ensuring secrets exist only at runtime and are never exposed in job logs or artifacts.

Docker containers run as root by default, creating files owned by UID 0 that conflict with subsequent non-root operations. Add chmod -R 775 storage bootstrap/cache in before_script, or configure the container user explicitly. On shared runners this is less common, but self-hosted executors with volume mounts frequently encounter this when cache directories persist between jobs.

Use cache:key based on composer.lock and package-lock.json hashes to invalidate only when dependencies change. Cache vendor/ and node_modules/ paths separately across stages. This reduces typical Laravel pipeline time from eight minutes to under three on subsequent runs, since dependency resolution and npm install are skipped entirely when locks remain unchanged.

Install chromium and chromium-driver via apt-get in before_script, set APP_URL to http://localhost:8000, and start php artisan serve --host=0.0.0.0 --port=8000 & before running dusk. Configure DuskTestCase to use ChromeOptions with --headless, --disable-gpu, and --no-sandbox flags. Without these flags, Dusk crashes inside Docker containers lacking display servers.

Yes. Add a deploy stage that runs only on main branch after test and build stages pass. Use script: dep deploy production within the job, storing SSH keys and deploy credentials as protected CI/CD variables. I use this exact pattern across multiple sister sites sharing one pipeline, achieving zero-downtime symlinked releases with automatic rollback if any post-deploy health check fails.

Use RefreshDatabase trait in PHPUnit tests instead of running migrate:fresh globally. Configure DB_CONNECTION=sqlite and DB_DATABASE=:memory: in your CI environment variables so tests use isolated in-memory databases. Never allow test jobs to connect to staging or production databases; this prevents accidental data loss and ensures test isolation regardless of pipeline execution order.

MySQL takes several seconds to initialize, and Laravel jobs often start querying before the database accepts connections. Add a wait-for-it.sh script or use GitLab's built-in service health checks with variables like MYSQL_ROOT_PASSWORD and POSTGRES_HOST_AUTH_METHOD. Alternatively, add sleep 10 or retry logic in before_script. In practice, most flaky Laravel CI failures trace back to premature database connections rather than actual code defects.

Split into parallel jobs using rules and needs keywords so independent test suites run concurrently. Extract slow integration tests into separate stages triggered only on merge requests, not every push. Pre-build Docker images with PHP extensions and base dependencies cached, reducing per-job setup from ninety seconds to ten. On real client projects, this approach cut average feedback time from twelve minutes to four without sacrificing coverage.

Build in CI and pass compiled assets as artifacts to the deploy stage. Production servers should lack Node.js entirely to reduce attack surface and resource contention. Run npm ci && npm run build in a dedicated build job, then archive public/build/ as an artifact consumed downstream. This guarantees deterministic builds and prevents server-side memory exhaustion during deployments on modest EC2 instances.

Enable verbose output by adding -vvv flags to composer and artisan commands temporarily. Use artifacts:paths to preserve log files, screenshots from Dusk, and test reports even on failure. For persistent issues, add an interactive debugging job using tmate or similar tools to SSH into the running container. Most opaque failures I have encountered involved missing system libraries or mismatched PHP extension versions that only surfaced under CI-specific conditions.

Mask all secret variables, restrict protected branches to prevent unauthorized deploys, and audit third-party GitHub Actions or Docker images before use. Rotate SSH deploy keys quarterly. Never cache .env or credential files. Scan dependencies with composer audit in a dedicated job. Treat pipeline configuration as production code requiring review, because compromised CI grants full repository and server access.

GitLab CI offers superior integrated container registry, native multi-project pipelines, and self-managed runner flexibility crucial for Nepal-based teams avoiding international bandwidth costs. GitHub Actions has larger marketplace ecosystem and faster macOS runners. For Laravel specifically, both work equally well, but GitLab's built-in issue tracking, merge request workflows, and environment management reduce tool fragmentation for agencies managing multiple client projects on shared infrastructure.

Share this article

Quick Contact Options
Choose how you want to connect me: