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.

GitLab CI YAML Deep Dive for PHP Projects

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.

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.

GitLab CI Pipeline for PHPGit PushtriggerValidatelint YAMLTestPHPUnitBuildVite assetsCachevendor/ by lockArtifactspublic/buildDeployDeployer SSHRunner executes jobs inside Docker imagesPHP 8.5 + Composer 2.10 + Node 26 LTSFailed test stage blocks deploy stage
GitLab CI YAML deep dive for PHP projects — typical stage flow from push through deploy

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:

  1. Validate — Composer install, php artisan config:clear, route list sanity check
  2. Test — PHPUnit, optional Pest, parallel test splits for large suites
  3. Analyse — PHPStan or Larastan at level 5+
  4. Buildnpm ci and npm run build via Vite 8.x
  5. 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.

Laravel .gitlab-ci.yml Job Mapcomposer:installvalidate stagephpunittest stagephpstananalyse stagenpm:buildbuild stagedeploy:proddeploy stageShared YAML anchors.php-base before_scriptcache key: composer.lockrules: main + tagsneeds: composer:install passes vendor/ artifact downstream
Laravel GitLab CI YAML job structure with shared anchors and artifact dependencies

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 jobs
  • public/build/ — pass from Vite build to deploy job
  • coverage/ — Cobertura XML for GitLab coverage reports
  • storage/logs/ — only on failure, via artifacts: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.

FeatureCacheArtifacts
ScopeCross-pipeline, keyed by branch or file hashSingle pipeline run only
Typical PHP pathsvendor/, .composer-cache/, node_modules/public/build/, coverage XML, compiled config
Survives job failureYes — next run reuses cacheConfigurable via when: key
Best forSpeeding Composer and npm installsPassing built assets to deploy job
Common mistakeStatic cache key ignores lockfile changesDeploy job rebuilds assets instead of reusing them
Cache vs Artifacts in PHP CICachekey: composer.lock hashvendor/ + .composer-cache/reused across pipelinesArtifactsexpire_in: 1 hourpublic/build/ to deployone pipeline onlycomposer:install → phpunit → npm:build → deploy:prodPull cache at job start, push after installNever cache .env or storage/ — secrets leak risk
GitLab CI cache versus artifacts strategy for PHP Composer vendor and Vite build output

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.

Deploy Job SequenceGitLab RunnerSSH + depRelease dirshared .envSymlink swapArtifacts: vendor/ + public/build/ rsynced into releasemigrate --forceartisan taskqueue:restarthorizon signalreload FPMopcache flushZero-downtime via Deployer current symlinkRollback: dep rollback production
GitLab CI YAML deploy sequence for PHP Laravel applications using Deployer SSH release workflow

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 on composer.lock; pass public/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/except with rules and use resource_group to serialise production deploys.
  • Match CI PHP extensions to production — run php -m on the server and mirror the list in before_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

It means defining validate, test, build, and deploy stages in one version-controlled .gitlab-ci.yml — pinning PHP 8.5, caching vendor/ by composer.lock hash, running PHPUnit, building assets with Node 26 LTS, and deploying via SSH with Deployer 7.

GitLab.com bills runner minutes; faster pipelines with proper vendor/ caching reduce that cost. On shared hosting around Rs 3,000/month (~USD 22), every saved minute matters for budget-sensitive Nepal SMB clients.

Run deploy only on protected branches like main, or manually on tags — never on every feature branch push.

Separate at least five concerns into stages or jobs: validate (Composer install, config clear, route sanity check), test (PHPUnit with optional parallel splits), analyse (PHPStan or Larastan at level 5+), build (npm ci and Vite 8.x), and deploy (SSH plus Deployer 7). Keep analyse in its own job with allow_failure false once your codebase is clean — I have seen teams leave analysis optional for months and debt compounds fast on real client projects.

Never use a static cache key. Key vendor/ and .composer-cache/ on composer.lock using cache key files, with policy pull-push on the install job and policy pull on downstream jobs that never install dependencies. Set COMPOSER_CACHE_DIR to a path inside CI_PROJECT_DIR so GitLab can cache Composer's download cache separately from vendor/. When composer.lock changes, a stale vendor cache causes phantom passes or cryptic autoload errors I have hit repeatedly in production audits.

Cache speeds up repeated runs across pipelines and survives job failure — use it for vendor/, .composer-cache/, and node_modules/ keyed on lockfiles. Artifacts pass output between jobs in a single pipeline run only — pass vendor/ from composer:install to test jobs, public/build/ from the Vite build job to deploy, and coverage XML for GitLab reports. Set expire_in around one hour on large artifacts; old artifacts fill runner disk and I have seen self-hosted runners stop accepting jobs when /var fills silently.

Add a deploy stage job using php:8.5-cli, install rsync and openssh-client, composer global require deployer/deployer:^7.0, load SSH_PRIVATE_KEY from a masked protected GitLab variable, and run dep deploy production --branch=$CI_COMMIT_SHA. The job needs npm:build artifacts so deploy reuses compiled assets instead of rebuilding. Pair the environment block with resource_group production to prevent concurrent deploys from two merged MRs. After Deployer's symlink swap, reload PHP-FPM so opcache picks up new files — stale opcache after deploy is a production bug I hit repeatedly on sister sites sharing one pipeline.

Most failures are YAML or environment mismatches, not PHPUnit assertions. The official php:8.5-cli image lacks extensions your Homestead or Sail setup includes — install intl, gd, redis, sodium, pcntl, and others explicitly in before_script and match php -m from production. Testing against SQLite locally while CI or production uses MySQL 8.4 has burned me more than once. A single YAML indentation error fails the entire pipeline parse step, so validate merged YAML before pushing.

Add a services block with mysql:8.4 and alias mysql, set MYSQL_ROOT_PASSWORD and MYSQL_DATABASE, and pass DB_CONNECTION, DB_HOST, DB_DATABASE, DB_USERNAME, and DB_PASSWORD as job variables. GitLab DNS resolves the alias to the service container. The phpunit job should need composer:install artifacts, copy .env.testing to .env, run php artisan key:generate and migrate --force, then php artisan test --parallel. PostgreSQL 18 projects swap the service image and env vars; Redis 8.10 tests need a redis:8 service with alias redis.

Docker executors remain safer because every job starts clean with a pinned php:8.5-cli image. Shell executors run directly on the host — install PHP 8.5 and Composer on the runner machine, but cache paths behave differently because jobs share the host filesystem. Most teams should prefer Docker unless you manage dedicated bare-metal runners. Pin PHP 8.5 in CI even if production still runs 8.4 to catch deprecation warnings before they hit the server.

Add a build stage job using Node 26 LTS, run npm ci keyed on package-lock.json cache — never npm install in CI — then npm run build via Vite 8.x. Pass public/build/ as artifacts with expire_in around one hour to the deploy job via needs. A common mistake is letting the deploy job rebuild assets instead of reusing the build job output, wasting runner minutes on every production deploy.

The official php:8.5-cli image ships minimal extensions. Install git, unzip, libzip-dev, then docker-php-ext-install zip pdo_mysql bcmath at minimum for Laravel. Common production gaps that break CI: intl, gd, redis, sodium, and pcntl. Run php -m on your production Ubuntu VPS and mirror that list in before_script, or build a custom runner image once. The Composer platform config in composer.json helps lock PHP and extension requirements so CI and production stay aligned.

Use YAML anchors for shared before_script blocks. Define a hidden job like .php-base with image php:8.5-cli and common apt-get and Composer setup, anchor it with &php-base, then merge into child jobs using

Replace deprecated only/except with rules. Run production deploy on_success when CI_COMMIT_BRANCH equals main, set tag deploys to manual, and end with when never as a catch-all. Mark SSH_PRIVATE_KEY as protected so only protected branches read it. Staging deploys on develop with a separate Deployer stage name; production on tags can stay manual if the client wants a human approval gate before go-live. Never commit SSH keys to the repository — store them as masked GitLab CI/CD variables.

The YAML structure stays identical across stacks — only scripts change. WordPress 7.1 pipelines skip artisan commands and run PHPCS against theme and plugin paths instead. WooCommerce 11.1 adds a job booting the 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. Laravel 13 targets PHP 8.3 or higher; pin 8.5 in CI. For monorepos, scope jobs with rules changes on app/, routes/, or composer.lock to avoid wasting runner minutes testing unrelated commits.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: