
September 09, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
A broken GitLab CI YAML Deep Dive for PHP Projects starts with copy-paste configs that never match your stack. Your pipeline passes locally but fails on the runner. Composer downloads 200 MB every job. Tests pass while production still serves stale opcache. This guide walks through a production-grade .gitlab-ci.yml for PHP 8.5, Laravel 13, and Deployer 7 — the same pattern I use on sister sites sharing one GitLab CI pipeline. If you want a faster intro first, read the GitLab CI/CD real-world setup for PHP walkthrough, then return here for the full YAML mechanics.
vendor/ by lock hash, running PHPUnit and static analysis, building assets with Node 26 LTS, and deploying via SSH with Deployer — all in one version-controlled .gitlab-ci.yml file.What is GitLab CI YAML and how does it work for PHP projects?
GitLab CI reads a single file at your repository root: .gitlab-ci.yml. GitLab parses it on every push. Each top-level key defines either global defaults or a job. Jobs run inside Docker images on GitLab Runners.
For PHP, the runner image must match production PHP extensions. A job that works on php:8.5-cli may fail on your VPS if you rely on intl, redis, or gd and never install them in CI.
The mental model is simple. GitLab builds a directed acyclic graph from your needs and stage keys. Jobs in the same stage run in parallel when runners are free. Failed jobs block downstream stages unless you set allow_failure: true.
Official reference: the GitLab CI/CD YAML syntax documentation lists every keyword. PHP teams rarely need all of them. Focus on stages, image, services, cache, artifacts, rules, and variables.
Minimum viable skeleton
Start with this base. It validates YAML structure and runs Composer install on every branch.
stages:
- validate
- test
- build
- deploy
default:
image: php:8.5-cli
before_script:
- apt-get update -qq && apt-get install -y -qq 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 --version=2.10.0
variables:
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
cache:
key:
files:
- composer.lock
paths:
- vendor/
- .composer-cache/
composer:install:
stage: validate
script:
- composer install --no-interaction --prefer-dist --no-progress
artifacts:
paths:
- vendor/
expire_in: 1 hour
This skeleton alone saves minutes per pipeline when vendor/ is cached correctly. On shared hosting budgets around Rs 3,000/month (~USD 22), faster pipelines mean fewer runner minutes billed on GitLab.com.
How do you structure a .gitlab-ci.yml file for Laravel?
Laravel 13 requires PHP 8.3 or higher. Pin PHP 8.5 in CI even if production still runs 8.4. Catch deprecation warnings before they hit the server. Laravel 12 remains supported until February 2027, but new projects should target 13.x in 2026.
For a step-by-step Laravel-focused pipeline, see the GitLab CI pipeline for Laravel step-by-step guide. This section goes deeper into YAML composition patterns.
Stage design for Laravel apps
A production Laravel pipeline needs at least five concerns separated into stages or jobs:
- Validate — Composer install,
php artisan config:clear, route list sanity check - Test — PHPUnit, optional Pest, parallel test splits for large suites
- Analyse — PHPStan or Larastan at level 5+
- Build —
npm ciandnpm run buildvia Vite 8.x - Deploy — SSH + Deployer 7, or rsync for simpler stacks
Keep analyse in its own job with allow_failure: false once your codebase is clean. I have seen teams keep analysis optional for months. Debt compounds fast.
Full Laravel 13 job example
phpunit:
stage: test
services:
- name: mysql:8.4
alias: mysql
variables:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: testing
DB_CONNECTION: mysql
DB_HOST: mysql
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: secret
needs:
- job: composer:install
artifacts: true
script:
- cp .env.testing .env
- php artisan key:generate
- php artisan migrate --force
- php artisan test --parallel
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
The services block spins up MySQL 8.4 beside your PHP container. GitLab DNS resolves the alias mysql to that service container. PostgreSQL 18 projects swap the service image and env vars accordingly.
Redis 8.10 caching tests need a redis:8 service entry with alias: redis. Match what you run in production. A passing CI suite against SQLite while production uses MySQL has burned me more than once.
YAML anchors reduce duplication
Large pipelines repeat the same before_script blocks. YAML anchors fix that without external templates.
.php-base: &php-base
image: php:8.5-cli
before_script:
- apt-get update -qq && apt-get install -y -qq git unzip libzip-dev
- docker-php-ext-install zip pdo_mysql bcmath
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
phpunit:
<<: *php-base
stage: test
Hidden jobs start with a dot. They do not run alone. Child jobs merge the anchor with <<: *php-base. Validate your merged YAML with the JSON and YAML formatter tool before pushing. A single indentation error fails the entire pipeline parse step.
WordPress and Symfony variants
WordPress 7.1 pipelines skip artisan commands. Replace them with PHPCS against your theme and plugin paths. WooCommerce 11.1 projects add a job that boots WordPress test suite against a MySQL service.
Symfony 8.1 requires PHP 8.4.1 minimum. Use php:8.5-cli and run bin/phpunit plus vendor/bin/phpstan analyse. The YAML structure stays identical. Only scripts change.
What are the best GitLab CI cache and artifact strategies for PHP?
Cache and artifacts solve different problems. Teams confuse them constantly. Cache speeds up repeated runs. Artifacts pass build output between jobs in the same pipeline.
Composer itself maintains a download cache separate from vendor/. Set COMPOSER_CACHE_DIR to a path inside $CI_PROJECT_DIR so GitLab can cache it.
Cache key design
Never use a static cache key for PHP dependencies. When composer.lock changes, stale vendor code causes phantom passes or cryptic autoload errors.
cache:
key:
files:
- composer.lock
paths:
- vendor/
- .composer-cache/
policy: pull-push
Use policy: pull on deploy jobs that never install dependencies. Use pull-push on the install job. This avoids race conditions when multiple branches push simultaneously.
For frontend assets, cache node_modules/ keyed on package-lock.json. Node 26 LTS with npm 12 respects lockfiles via npm ci. Never run npm install in CI.
Artifact paths that matter
vendor/— pass from install job to test and analyse jobspublic/build/— pass from Vite build to deploy jobcoverage/— Cobertura XML for GitLab coverage reportsstorage/logs/— only on failure, viaartifacts:when: on_failure
Set expire_in: 1 hour on large artifacts. Old artifacts fill runner disk space. I have seen self-hosted runners stop accepting jobs when /var fills up silently.
Coverage gates deserve their own policy. Read code coverage gates in CI for threshold patterns that block merges below 70% line coverage on critical modules.
| Feature | Cache | Artifacts |
|---|---|---|
| Scope | Cross-pipeline, keyed by branch or file hash | Single pipeline run only |
| Typical PHP paths | vendor/, .composer-cache/, node_modules/ | public/build/, coverage XML, compiled config |
| Survives job failure | Yes — next run reuses cache | Configurable via when: key |
| Best for | Speeding Composer and npm installs | Passing built assets to deploy job |
| Common mistake | Static cache key ignores lockfile changes | Deploy job rebuilds assets instead of reusing them |
How do you deploy PHP applications with GitLab CI?
Deploy is where YAML meets infrastructure. I deploy Laravel apps with Deployer 7 over SSH from a dedicated CI job. The same pattern runs on legal-tech sister sites such as Notary Kathmandu and Translation Nepal through one shared GitLab project pipeline.
Alternative: build a Docker image and push to a registry. That suits container orchestration. Most Nepal SMB clients still run Apache plus PHP-FPM on a single Ubuntu VPS. SSH deploy stays the practical default.
Deploy job with Deployer 7
deploy:production:
stage: deploy
image: php:8.5-cli
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
before_script:
- apt-get update -qq && apt-get install -y -qq rsync openssh-client
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer global require deployer/deployer:^7.0
- export PATH="$PATH:$HOME/.composer/vendor/bin"
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- dep deploy production --branch=$CI_COMMIT_SHA
needs:
- job: npm:build
artifacts: true
- job: phpunit
Store SSH_PRIVATE_KEY as a masked, protected variable in GitLab. Mark it protected so only protected branches read it. Never commit keys to the repository.
After symlink swap, reload PHP-FPM so opcache picks up new files. Deployer's deploy.php should call sudo systemctl reload php8.5-fpm. Stale opcache after deploy is a production bug I hit repeatedly. See PHP opcache configuration for production for the full server-side tuning guide.
For VPS-first setup from scratch, follow deploy a Laravel app with GitLab CI/CD to a VPS. Blue-green patterns for zero downtime are covered in CI/CD blue-green deployment explained.
Rules and environments
Replace deprecated only/except with rules:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- if: $CI_COMMIT_TAG
when: manual
- when: never
The environment block tracks deployment history in GitLab. Pair it with resource_group: production to prevent concurrent deploys from two merged MRs.
Staging deploys on develop branch with a separate Deployer stage name. Production stays manual on tags if your client wants a human approval gate before go-live.
Server provisioning context
CI deploy assumes the server already exists. Ansible playbooks provision PHP-FPM, MySQL, and UFW before the first pipeline deploy. See Ansible playbooks for PHP server provisioning for the host prep layer.
If you need hands-on server work beyond YAML, Linux system administration services cover the same Ubuntu plus PHP-FPM stack this pipeline targets.
What common GitLab CI YAML mistakes break PHP pipelines?
Most failures are YAML or environment mismatches, not PHPUnit assertions. These recur on every client audit.
PHP extension gaps in CI
Local Homestead or Laravel Sail images include every extension. Official php:8.5-cli does not. Install extensions explicitly in before_script or build a custom runner image once.
Common missing extensions: intl, gd, redis, sodium, pcntl. Match the list from php -m on production. The Composer platform config documentation helps lock PHP and extension requirements in composer.json.
Running as root without fixing permissions
Deployer releases owned by root break PHP-FPM writes to storage/. Run deploy SSH as a dedicated deploy user. Set writable_dirs in deploy.php. Never chmod 777 as a shortcut.
Secrets in job logs
set -x in scripts prints variable values. Mask all secrets in GitLab CI/CD settings. Use file type variables for multiline keys. Audit job logs after first deploy.
Ignoring rules:changes for monorepos
Monorepos with PHP API and Node admin panel waste runner minutes testing everything on every commit. Scope jobs:
rules:
- changes:
- app//*
- routes//*
- composer.lock
Comparison with GitHub Actions
Teams evaluating a platform switch should read GitHub Actions vs GitLab CI comparison and the 2026 decision guide at GitHub Actions vs GitLab CI in 2026. GitLab wins when you already host code on GitLab and want integrated registry plus environments without third-party plugins.
For enterprise Laravel builds with long test suites, enterprise application development pipelines often add parallel PHPUnit via parallel:matrix and split tests by directory.
Testing and optimization as a service layer
Pipeline design is half the battle. The other half is what you run inside it. Testing and optimization services focus on the PHPUnit and performance gates your YAML should enforce, not just the YAML itself.
On booking platforms like Adventure Third Pole Trek, CI runs feature tests around Livewire booking flows before any deploy to production. That saved rollback cycles during peak trekking season.
Key Takeaways
- Pin PHP 8.5, Composer 2.10, and Node 26 LTS images explicitly — never rely on implicit latest tags.
- Cache
vendor/keyed oncomposer.lock; passpublic/build/as artifacts to the deploy job. - Separate validate, test, analyse, build, and deploy stages; block deploy on test failure.
- Store SSH keys as masked GitLab variables; reload PHP-FPM after Deployer symlink swap.
- Replace deprecated
only/exceptwithrulesand useresource_groupto serialise production deploys. - Match CI PHP extensions to production — run
php -mon the server and mirror the list inbefore_script.
People Also Ask
Can GitLab CI run PHPUnit without Docker?
Yes, on shell executors installed directly on the host. Install PHP 8.5 and Composer on the runner machine. Shell executors share the host filesystem, so cache paths behave differently. Docker executors remain safer because every job starts clean. Most teams should prefer Docker unless you manage dedicated bare-metal runners.
How do I run multiple PHP versions in one pipeline?
Use parallel:matrix with a PHP_VERSION variable. Define a job template and let GitLab spawn one job per version. Example matrix values: ["8.3", "8.4", "8.5"]. Each matrix job pulls the matching php:${PHP_VERSION}-cli image. Drop 8.3 once you fully commit to Laravel 13.
Should I commit compiled frontend assets or build in CI?
Build in CI and deploy artifacts. Committing public/build/ causes merge conflicts and hides broken Vite configs until deploy day. The exception: servers with no outbound internet and no runner access to npm registry. In that case, build locally once and rsync — but fix the infrastructure instead if possible.
What is the difference between include and extends in GitLab CI?
include pulls external YAML files from other projects or templates — ideal for organisation-wide PHP base configs shared across twenty repos. extends inherits from another job defined in the same file. Use both together: include a shared template, then extend its hidden base job with project-specific scripts.
Ship PHP pipelines that survive production traffic
A proper GitLab CI YAML Deep Dive for PHP Projects ends with a pipeline you trust on Friday afternoon. Start from the skeleton above, add your test suite, wire Deployer, and enforce coverage gates before merge. The YAML file is version-controlled documentation of how your app reaches production — treat it with the same care as application code.
Need help wiring GitLab CI to your Laravel stack or auditing a pipeline that fails unpredictably? Contact us for a review. Explore the full portfolio of shipped projects or read more on the developer blog. For greenfield Laravel work with CI baked in from day one, see custom software development and web development services.
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.

