
August 16, 2026
10 min read
Table of Contents
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.
.gitlab-ci.yml with PHP 8.4, Node 22 LTS, and cached dependencies to achieve reliable automated releases in 2026.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.
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.
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.
| Approach | Build Location | Server Requirements | Deploy Speed | Consistency |
|---|---|---|---|---|
| Artifact-based (recommended) | CI Runner | PHP + Web Server only | ~30 seconds | Guaranteed identical |
| Server-side build | Production Server | PHP + Node + npm + Composer | 3–8 minutes | Risk of version drift |
| Docker container | CI Runner | Docker runtime | ~45 seconds | Guaranteed 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.
OPcache Invalidation After Symlink Swap
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.

