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 CD for PHP Projects Real World Setup

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.

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.

PHP GitLab CI Pipeline OverviewGit Pushmain / MRLintPint, PHPStanTestPHPUnit + MySQLBuildnpm + ViteDeploy Stagingdevelop branchDeploy Prodmain + manualArtifacts: compiled JS/CSS committed or passed to deploy job via GitLab artifactsCache: vendor/ and node_modules/ keyed by composer.lock + package-lock.json
GitLab CI CD for PHP projects: typical four-stage pipeline from push to staging and production deploy

The baseline stages I use on Laravel apps:

  1. lint — Laravel Pint, PHPStan, and sometimes composer validate --strict
  2. test — PHPUnit with a MySQL or MariaDB service container
  3. buildnpm ci and npm run build when Vite compiles frontend assets
  4. 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.

Runner Choice for PHP PipelinesGitLab Shared Runners+ Zero setup on GitLab.com+ Docker executor built in+ Good for lint and test- Minute quotas on free tier- No private network access- Cold starts on busy queuesSelf-Hosted Runner+ Unlimited minutes+ Same VPC as production DB+ Custom PHP 8.5 image cached- You patch and monitor it- Security is your job- Runner token rotation neededHybrid: shared runners for MR tests, self-hosted tag for deploy jobs
Shared versus self-hosted GitLab runners for PHP CI CD pipelines in production

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.

ConcernLaravel 12/13Symfony 8.1WordPress 7.1
Dependency installcomposer installcomposer installcomposer install if theme uses Composer
Static analysisPint + PHPStan + LarastanPHP-CS-Fixer + PHPStanPHPCS with WordPress ruleset
TestsPHPUnit + artisan migratePHPUnit + bin/console doctrine:migrations:migratePHPUnit for custom plugins; often lint-only
Asset buildVite 8 via npm 12Webpack Encore or Vitenpm build for block themes; often none
DeployDeployer + php artisan migrate --forceDeployer + warmup cachersync + wp cache flush via WP-CLI
Min PHP8.2 (L12) / 8.3 (L13)8.4.17.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.

PHP CI Failure Debug FlowPipeline failed?Composerext missing?Install via docker-php-extTestsDB not ready?Add wait loop + .env.testingLock file stale?Run composer update locallyPass locally only?Match PHP version in CI imageDeploySSH or opcache?Key perms + php-fpm reload in Deployer
Debugging decision tree for GitLab CI CD PHP pipeline failures in production setups

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.

Security and Quality GatesMerge RequestDeveloper pushAuditcomposer auditCoveragemin 70% gatePassBlocked on High CVE1. composer audit --locked fails pipeline2. Developer bumps package or adds exception3. Security maintainer approves MRGitLab Dependency Scanning also available on Premium tiers
Security scanning and code coverage quality gates in GitLab CI CD for PHP projects

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 on composer.lock, wait for MySQL in test jobs, and commit a dedicated .env.testing.
  • Use Deployer 7 with PHP-FPM reload and symlinked current paths so opcache and cron stay correct after deploy.
  • Add composer audit and 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

Four stages — lint, test, build, deploy — using official PHP Docker images, caching vendor/, and Deployer or rsync over SSH only after tests pass on protected branches.

Yes. Use the official php:8.5-cli Docker image in .gitlab-ci.yml. Laravel 13 requires PHP 8.3 minimum; Laravel 12 supports PHP 8.2 and runs fine on 8.4 in production.

Start with a skeleton targeting your PHP version — the article uses Laravel 12 on PHP 8.4 with Composer 2.10. Pin the image tag explicitly, define cache keyed on composer.lock for vendor/, and set four stages. Lint runs Pint and PHPStan after composer install with zip and pdo_mysql extensions. Test uses a mysql:8.4 service, copies .env.testing, runs migrations, then PHPUnit. Build uses node:26-alpine for npm ci and npm run build, passing public/build/ as artifacts. Deploy installs Deployer 7 globally and runs dep deploy production with manual approval on main.

GitLab.com shared runners work well for lint and test on merge requests. Deploy jobs usually need a self-hosted runner with SSH access to your VPS. My default for small teams: shared runners for MR pipelines, one self-hosted runner on the same Ubuntu 24 VPS or sibling EC2 tagged deploy for production releases. Lock that runner to protected branches and disable untagged job execution. Fork MRs from public repos should not reach a runner that holds deploy keys.

The pipeline shape stays the same; commands change. Laravel 12/13 uses Pint, PHPStan, Larastan, PHPUnit with artisan migrate, Vite 8 via npm 12, and Deployer with php artisan migrate --force — minimum PHP 8.2 for Laravel 12 or 8.3 for Laravel 13. Symfony 8.1 needs PHP 8.4.1 minimum, PHP-CS-Fixer, PHPStan, doctrine:migrations:migrate, and cache warmup on deploy. WordPress 7.1 often runs PHPCS, skips PHPUnit for theme-only shops, and deploys via rsync with wp cache flush. WooCommerce 11.1 custom payment plugins need more than lint-only CI.

Missing PHP extensions — ext-intl or ext-gd — cause composer install to fail; install them in before_script or use a custom image matching production. Stale vendor/ cache produces autoload errors; bust cache by changing the cache key suffix. Tests pass locally but fail in CI when .env.testing is missing — commit it with QUEUE_CONNECTION=sync and CACHE_STORE=array. MySQL connection refused on first run needs a retry loop before migrate. Green deploy with old homepage content means PHP-FPM opcache was not reloaded. Cron jobs pointing at timestamped release paths break silently after Deployer symlink swaps.

Add a security:audit job in the lint stage running composer audit --locked with allow_failure: false on merge requests — Composer 2.10 checks locked dependencies against the Packagist advisory database. For coverage, run PHPUnit with --coverage-clover=coverage.xml, parse statement coverage from the XML, and fail below 70 percent. Publish the Clover report to GitLab's coverage badge and track the trend over time. Validate your YAML through GitLab's CI lint endpoint at /ci/lint before pushing broken pipeline syntax.

Build in CI when possible. It keeps Node off the production VPS and guarantees assets compile before deploy. On Rs 3,000–5,000/month (~USD 22–37) shared VPS plans in Nepal with no Node installed, build in CI and either pass public/build/ artifacts to the deploy job or commit built assets after CI validates the Vite config.

At minimum: SSH_PRIVATE_KEY as a masked file-type variable, DEPLOY_HOST, and DEPLOY_USER. Store them as protected CI/CD variables scoped to protected branches so fork merge requests cannot exfiltrate secrets. Laravel production APP_KEY belongs on the server itself, not in CI logs. Mark database URLs and API keys as both protected and masked. Never commit deploy keys to the repository.

Add a mysql:8.4 service with alias mysql and set DB_HOST, DB_USERNAME, DB_PASSWORD, and APP_ENV=testing variables. In before_script, install pdo_mysql, run composer install, copy .env.testing to .env, generate the app key, then migrate. Add a retry loop waiting up to 60 seconds for MySQL before migrating — SQLSTATE 2002 Connection refused is the usual first-run failure. Run vendor/bin/phpunit on merge requests and main. For large test suites, shard with ParaTest across parallel CI jobs.

PHP opcache serves stale bytecode after Deployer swaps the current symlink. Your Deployer recipe must reload PHP-FPM — for example sudo systemctl reload php8.4-fpm in a deploy:reload_php task. I've seen this repeatedly on EC2-hosted legal-tech portals: the pipeline is green but Blade templates from yesterday still render. Opcache invalidation after every deploy is mandatory, not optional.

Key the cache on composer.lock and cache both vendor/ and .composer-cache/ using COMPOSER_CACHE_DIR pointing at the project directory. GitLab cache can be pull-only on some configurations, so a corrupted vendor/ cache causes bizarre autoload failures. When debugging, change the cache key suffix — for example composer-v2-$CI_COMMIT_REF_SLUG — to force a fresh pull. Pin your PHP Docker image tag so local and CI Composer resolution stay aligned.

Merge requests should run lint and test only — no deploy — to save runner minutes and prevent accidental releases. Production deploy on protected main should use when: manual so a maintainer approves after reviewing the diff. On WooCommerce projects with fragile payment gateway callbacks, keep production manual even if staging auto-deploys on develop. That split is the baseline the article recommends for real client pipelines.

Both run containerised jobs from YAML. GitLab bundles registry, CI, and merge requests in one product, and self-hosted runners are first-class for SSH deploy jobs. GitHub Actions has a larger marketplace of prebuilt actions. For monorepos already on GitLab with Deployer 7 deploys, staying on GitLab CI avoids duplicating runner infrastructure and keeps MR pipelines and deploy secrets in one place.

On Laravel 12/13 projects, run vendor/bin/pint --test for code style and vendor/bin/phpstan analyse --memory-limit=512M for static analysis — optionally with Larastan for Laravel-specific rules. Add composer validate --strict to catch composer.json schema issues early. Symfony 8.1 pipelines swap Pint for PHP-CS-Fixer. WordPress 7.1 theme projects use PHPCS with the WordPress ruleset. Run lint on merge_request_event and main branch pushes before any test job executes.

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: