
September 08, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
A GitLab CI CD for PHP Projects Real World Setup is not a single YAML file pasted from a tutorial. It is a pipeline that mirrors how your app actually ships: Composer install on the right PHP version, static analysis, database-backed tests, asset build, and a zero-downtime deploy to a VPS. On production Laravel applications I maintain, GitLab CI runs on every push to main and blocks bad releases before they touch Apache and PHP-FPM. This guide walks through a pipeline you can adapt for Laravel 12/13, Symfony, or WordPress — with the gotchas I hit on real client projects.
.gitlab-ci.yml with staged jobs — lint, test, build, deploy — running inside official PHP Docker images, caching vendor/, and calling Deployer or rsync over SSH only after tests pass on protected branches.What does a real-world GitLab CI CD pipeline for PHP projects look like?
Most PHP teams need four stages, not twelve. Keep the graph readable. A reviewer should understand the flow in ten seconds.
Think in terms of gates. Each stage either passes or stops the pipeline. Nothing deploys unless every prior gate is green.
The baseline stages I use on Laravel apps:
- lint — Laravel Pint, PHPStan, and sometimes
composer validate --strict - test — PHPUnit with a MySQL or MariaDB service container
- build —
npm ciandnpm run buildwhen Vite compiles frontend assets - deploy — Deployer 7 over SSH, manual approval on production
Merge requests run lint and test only. Production deploy runs on protected branches after a maintainer clicks approve. That split saves runner minutes and reduces accidental deploys.
How do you write a .gitlab-ci.yml file for a Laravel PHP project?
Start with a working skeleton. Adjust PHP version, extensions, and deploy target. This example targets Laravel 12 on PHP 8.4 with Composer 2.10.
Base variables and cache
image: php:8.4-cli
variables:
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
MYSQL_DATABASE: laravel_test
MYSQL_ROOT_PASSWORD: secret
cache:
key:
files:
- composer.lock
paths:
- vendor/
- .composer-cache/
stages:
- lint
- test
- build
- deploy
Pin the PHP image tag. php:8.4-cli is explicit. Avoid floating tags like latest. Your local PHP 8.5 install and CI PHP 8.4 will disagree on deprecations if versions drift.
Lint job with Pint and PHPStan
lint:pint:
stage: lint
before_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 --2
- composer install --no-interaction --prefer-dist --no-progress
script:
- vendor/bin/pint --test
- vendor/bin/phpstan analyse --memory-limit=512M
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
Install system packages in before_script, not in every job's script block. Better yet, build a custom CI image once and reuse it. On budget-sensitive Nepal client projects, a custom image pays back within weeks of saved runner time.
Test job with MySQL service
test:phpunit:
stage: test
services:
- name: mysql:8.4
alias: mysql
variables:
DB_HOST: mysql
DB_USERNAME: root
DB_PASSWORD: secret
APP_ENV: testing
before_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 --2
- composer install --no-interaction --prefer-dist --no-progress
- cp .env.testing .env
- php artisan key:generate
- php artisan migrate --force
script:
- vendor/bin/phpunit --colors=never
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
Wait for MySQL before migrating. A common failure is SQLSTATE[HY000] [2002] Connection refused on the first pipeline run. Add a retry loop:
before_script:
- |
for i in $(seq 1 30); do
php -r "new PDO('mysql:host=mysql;dbname=laravel_test','root','secret');" && break
echo "Waiting for MySQL..."
sleep 2
done
For faster suites, see parallel test runs with ParaTest. Large legal-tech portals with hundreds of feature tests benefit from sharding across multiple CI jobs.
Build job with Node.js 26 LTS and Vite 8
build:assets:
stage: build
image: node:26-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
script:
- npm ci
- npm run build
artifacts:
paths:
- public/build/
expire_in: 1 hour
rules:
- if: $CI_COMMIT_BRANCH == "main"
Two schools of thought exist for frontend assets on VPS deploys. Some teams commit built assets to Git. Others build in CI and pass artifacts to the deploy job. I often commit built assets when the production server has no Node installed — a pattern I use on sister sites sharing a Deployer 7 pipeline. CI still validates the build step so broken Vite configs never reach production.
Deploy job with Deployer 7
deploy:production:
stage: deploy
image: php:8.4-cli
before_script:
- apt-get update && apt-get install -y git unzip rsync openssh-client
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --2
- composer global require deployer/deployer:^7.0
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- dep deploy production -o branch=$CI_COMMIT_SHA
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
needs:
- job: test:phpunit
- job: build:assets
artifacts: true
Store SSH_PRIVATE_KEY, DEPLOY_HOST, and database credentials as masked CI/CD variables. Never commit deploy keys. GitLab's protected variable scope limits secrets to protected branches only. Full VPS walkthrough: deploy a Laravel app with GitLab CI/CD to a VPS.
Which GitLab CI runner setup works best for PHP deployments?
GitLab.com shared runners work for lint and test. Deploy jobs often need a self-hosted runner or a dedicated deploy token with SSH access to your server.
My default for small teams: shared runners for merge request pipelines, one self-hosted runner on the same Ubuntu 24 VPS or a sibling EC2 instance for deploy. Register it with a tag like deploy:
deploy:production:
tags:
- deploy
script:
- dep deploy production
Lock the runner to protected branches. Disable untagged job execution. Read self-hosted CI runners setup and security before exposing a runner to public merge requests from forks.
For server prep — PHP-FPM pools, UFW, fail2ban — see the Ubuntu server setup for PHP apps in 2026 guide. CI assumes the target server already runs the correct PHP version.
How do GitLab CI CD stages differ between Laravel, Symfony, and WordPress?
The pipeline shape stays similar. Commands change. Here is a practical comparison for PHP 8.3+ projects in 2026.
| Concern | Laravel 12/13 | Symfony 8.1 | WordPress 7.1 |
|---|---|---|---|
| Dependency install | composer install | composer install | composer install if theme uses Composer |
| Static analysis | Pint + PHPStan + Larastan | PHP-CS-Fixer + PHPStan | PHPCS with WordPress ruleset |
| Tests | PHPUnit + artisan migrate | PHPUnit + bin/console doctrine:migrations:migrate | PHPUnit for custom plugins; often lint-only |
| Asset build | Vite 8 via npm 12 | Webpack Encore or Vite | npm build for block themes; often none |
| Deploy | Deployer + php artisan migrate --force | Deployer + warmup cache | rsync + wp cache flush via WP-CLI |
| Min PHP | 8.2 (L12) / 8.3 (L13) | 8.4.1 | 7.2.24+ (8.3 recommended) |
Symfony 8.1 needs PHP 8.4.1 minimum. Do not reuse a Laravel pipeline's php:8.3-cli image without bumping the tag. WordPress WooCommerce 11.1 shops often skip PHPUnit in CI and rely on PHPCS plus a staging smoke test — acceptable for theme-only changes, risky for custom payment plugins.
On WooCommerce projects like international florist eCommerce builds, I add a staging deploy on every merge to develop and keep production manual. Payment gateway callbacks are too fragile for auto-deploy without human eyes on the diff.
What are the most common GitLab CI CD failures on PHP projects?
These failures appear repeatedly across client repos. Most are environment mismatches, not application bugs.
Missing PHP extensions
composer install fails with "ext-intl is missing" or "ext-gd is missing". The fix is explicit extension install in before_script or a custom Docker image with intl, gd, bcmath, and redis pre-installed. Match production. If your VPS runs PHP 8.4 with php8.4-intl, CI must too.
Stale Composer cache
GitLab cache is pull-only by default on some configurations. A corrupted vendor/ cache causes bizarre autoload errors. Bust cache by changing the cache key suffix when debugging:
cache:
key: "composer-v2-$CI_COMMIT_REF_SLUG"
Environment file drift
Tests pass locally because your .env has Redis and Mailhog configured. CI has neither. Use .env.testing with QUEUE_CONNECTION=sync, CACHE_STORE=array, and MAIL_MAILER=array. Commit that file. Do not rely on CI variables for every Laravel config key.
Deploy succeeds but site shows old code
PHP opcache serves stale bytecode after symlink swap. Your Deployer recipe must reload PHP-FPM:
task deploy:reload_php:
desc: Reload PHP-FPM after deploy
script:
- sudo systemctl reload php8.4-fpm
I've seen this on multiple EC2-hosted legal-tech portals. The pipeline is green. The homepage still shows yesterday's Blade template. Opcache invalidation is the fix.
Cron still points at old release path
Deployer symlinks current to the new release. Crons hardcoded to a timestamped release directory break silently. Use the shared path:
* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1
Document this in your deploy README. Future you — or the client's next developer — will need it.
How do you add security scanning and quality gates to a PHP GitLab pipeline?
Shipping fast without scanning is how compromised vendor/ packages reach production. Add two lightweight jobs that rarely slow pipelines down.
Composer audit job
security:audit:
stage: lint
script:
- composer audit --locked
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Composer 2.10 ships composer audit against the Packagist advisory database. It catches known CVEs in locked dependencies. Deeper setup notes live in the dependency vulnerability scanning guide.
Coverage gate
script:
- vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml
- |
COVERAGE=$(php -r "
\$xml = simplexml_load_file('coverage.xml');
\$m = \$xml->project->metrics;
echo round((float)\$m['coveredstatements'] / (float)\$m['statements'] * 100, 1);
")
echo "Coverage: ${COVERAGE}%"
php -r "exit((float)'$COVERAGE' < 70 ? 1 : 0);"
Publish the Clover report to GitLab's coverage badge. Track trend over time. See code coverage gates in CI for MR comment integration.
Validate your pipeline YAML before pushing. Paste the file into the JSON formatter when converting GitLab API responses, or use GitLab's CI lint endpoint at /ci/lint in your project settings. Official reference: GitLab CI/CD YAML syntax documentation.
Key Takeaways
- Structure PHP pipelines as lint → test → build → deploy with manual production approval on protected
main. - Pin Docker images to explicit PHP versions (8.4 or 8.5) and match your production VPS — version drift causes false greens.
- Cache
vendor/keyed oncomposer.lock, wait for MySQL in test jobs, and commit a dedicated.env.testing. - Use Deployer 7 with PHP-FPM reload and symlinked
currentpaths so opcache and cron stay correct after deploy. - Add
composer auditand optional coverage gates on merge requests before they reach production. - Hybrid runners — shared for tests, self-hosted tagged runner for deploy — balance cost and SSH access.
People Also Ask
Does GitLab CI support PHP 8.5 in 2026?
Yes. Use the official php:8.5-cli Docker image in your .gitlab-ci.yml file. Install required extensions in before_script or build a custom image. Laravel 13 requires PHP 8.3 minimum; PHP 8.5 works for greenfield apps. Laravel 12 supports PHP 8.2 and runs fine on 8.4 in production.
Should I run npm build on the server or in GitLab CI?
Build in CI when possible. It keeps Node.js off the production VPS and guarantees assets compile before deploy. If your server has no Node — common on Rs 3,000–5,000/month (~USD 22–37) shared VPS plans in Nepal — build in CI and either pass artifacts to Deployer or commit public/build/ after CI validates the build.
How is GitLab CI different from GitHub Actions for PHP?
Both run containerised jobs from YAML. GitLab bundles registry, CI, and merge requests in one product. Self-hosted runners are first-class. GitHub Actions has a larger marketplace. For monorepos already on GitLab with Deployer deploys, staying on GitLab CI avoids runner duplication. See the full comparison in GitHub Actions vs GitLab CI in 2026.
What GitLab CI/CD variables do PHP deploys need?
At minimum: SSH_PRIVATE_KEY (file-type, masked), DEPLOY_HOST, and DEPLOY_USER. Laravel apps also need production APP_KEY on the server itself — not in CI logs. Mark database URLs and API keys as protected and masked. Scope variables to protected branches so fork MRs cannot exfiltrate secrets.
Ship PHP with confidence
A working GitLab CI CD for PHP Projects Real World Setup turns every push into an audited, tested, repeatable release. Start with lint and test on merge requests. Add Deployer deploy once the test job is stable. Add security audit next — not after your first production incident.
If you want this pipeline configured on your Laravel app, WordPress stack, or legal-tech portal, I offer full setup including VPS hardening and runner registration. Review similar work in the Court Marriage In Nepal portfolio case or explore Linux system administration services and ongoing support and maintenance. For greenfield apps, see custom software development in Nepal.
Upgrading an older app first? Read the Laravel 12 migration guide before changing PHP versions in CI. Planning zero-downtime releases? Blue-green deployment pairs well with manual production gates.
Need hands-on help wiring GitLab CI to your stack? Contact us with your repo structure and deploy target — I'll reply with a concrete stage plan, not a sales brochure.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

