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.

CI CD for Monorepo with Multiple PHP Apps

By Kokil Thapa | Last reviewed: September 2026

Running CI CD for monorepo with multiple PHP apps is a different problem from a single Laravel site. One push can touch a legal portal, a booking API, and a shared package at once. Without path rules and per-app jobs, every pipeline rebuilds every app. That wastes minutes, blocks merges, and trains teams to skip CI. This guide shows a production pattern I use on sister sites that share one Git repo and one GitLab CI deploy pipeline.

What Is CI CD for a Monorepo with Multiple PHP Apps?

A PHP monorepo holds two or more deployable applications in one Git repository. Common layouts include separate Laravel apps, a Symfony API beside WordPress themes, or apps plus shared internal packages. Each app has its own composer.json, env file, and deploy target.

CI/CD must answer three questions on every push: which apps changed, which tests to run, and which servers to update. A naive pipeline runs everything. A tuned pipeline runs only what changed and still validates shared library updates across dependents.

Monorepo CI/CD TopologyGit Monorepoapps/ packages/Path Change DetectionApp A Pipelinetest + deployApp B Pipelinetest + deployApp C Pipelinetest + deployShared Composer Cache + Deployer Releases
CI CD for monorepo with multiple PHP apps: one repo, path-filtered pipelines, shared cache layer

On production systems I maintain, several legal-tech portals share one repo and one CI file. Each site deploys independently. A docs-only change in one app never triggers a full rebuild of the others. That pattern maps cleanly to any multi-app PHP shop running Laravel 12 or 13 on PHP 8.3+.

How Do You Structure a PHP Monorepo for CI/CD?

Start with a predictable folder layout. Keep deployable apps under apps/ and reusable code under packages/. Never mix two apps at the repository root unless you enjoy broken relative paths in CI.

repo-root/
├── apps/
│   ├── notary-portal/          # Laravel 13
│   ├── court-marriage/         # Laravel 12
│   └── translation-api/        # Symfony 8.1
├── packages/
│   └── nepal-legal-core/       # shared Composer package
├── .gitlab-ci.yml
├── deploy.php                  # Deployer 7
└── composer.json               # optional root workspace

Each app owns its own composer.lock, PHPUnit config, and .env.example. Shared packages use a path repository in each app's composer.json during development. CI installs them the same way production does.

Root vs per-app Composer

Two valid models exist. Pick one and document it in your README.

ModelBest forCI implicationTrade-off
Per-app lock filesIndependent deploy cadenceCache keyed per app pathDuplicate dev dependencies
Root workspace (Composer 2.10)Shared packages with tight couplingSingle install, matrix test jobsOne broken package blocks all apps
Hybrid2–4 apps plus one shared libInstall packages first, then appsMore YAML, clearer boundaries

For most client monorepos I prefer per-app lock files. Apps deploy on different schedules. A hotfix on one portal should not force a Composer update on another. See GitLab CI YAML patterns for PHP for job templates you can adapt.

How Do You Configure Path-Based CI Rules for Multiple PHP Apps?

Path rules are the core of efficient monorepo CI. GitLab CI uses rules:changes. GitHub Actions uses paths filters. Both answer the same question: did this commit touch files this job cares about?

GitLab CI example with change detection

This pattern runs tests only when an app or shared package changes. Shared package changes fan out to every dependent app.

stages:
  - prepare
  - test
  - deploy

variables:
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"

.prepare_template: &prepare
  stage: prepare
  image: php:8.5-cli
  cache:
    key:
      files:
        - apps/${APP_NAME}/composer.lock
    paths:
      - .composer-cache/
  script:
    - cd apps/${APP_NAME}
    - composer install --no-interaction --prefer-dist --no-progress

test:notary:
  <<: *prepare
  stage: test
  variables:
    APP_NAME: notary-portal
  rules:
    - changes:
        - apps/notary-portal//*
        - packages/nepal-legal-core//*
  script:
    - cd apps/notary-portal
    - composer install --no-interaction
    - vendor/bin/phpunit --colors=never

deploy:notary:production:
  stage: deploy
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      changes:
        - apps/notary-portal//*
        - packages/nepal-legal-core//*
  script:
    - dep deploy notary-portal --branch=main

Duplicate the test and deploy blocks for each app. Use YAML anchors to keep cache and image settings DRY. A change under packages/nepal-legal-core/ should list every consumer in its changes block. Missing that link is a common production bug. Shared lib updates ship without retesting dependents.

Path-Based Trigger FlowGit Pushcommit diffDiff Changed FilesMatch Path RulesApp A Changed?run test + deployApp B Unchangedskip jobsShared Package?run all dependentsPipeline completes in minutesnot full monorepo rebuild
Path rules decide which PHP app pipelines run after each Git push in a monorepo

GitHub Actions users mirror the same logic with paths-filter or native on.push.paths. The GitHub Actions vs GitLab CI comparison covers when each platform fits a small Nepal agency team versus a self-hosted runner on Ubuntu 24.

Handling shared package changes

When packages/nepal-legal-core changes, list all apps that import it in every relevant changes block. Alternatively, add a dedicated job that runs first:

  1. Detect changed paths with a small shell script or CI component.
  2. Write affected app names to a dotenv artifact.
  3. Trigger downstream jobs with needs and dynamic child pipelines.
  4. Block merge if any affected app fails PHPUnit or PHPStan.

For parallel test speed on large Laravel apps, wire in ParaTest parallel runs in CI. Run them only on changed apps to keep wall-clock time low.

How Do You Cache Composer and Speed Up Monorepo PHP Pipelines?

Composer install dominates PHP CI time. A monorepo without cache can burn 3–5 minutes per app per pipeline. With a keyed cache, warm runs often finish in under 60 seconds.

Cache key strategy

Key the cache on the lock file hash for each app. Never use a global key across apps with different lock files. That causes stale vendor trees and confusing test failures.

cache:
  key:
    files:
      - apps/notary-portal/composer.lock
  paths:
    - .composer-cache/
    - apps/notary-portal/vendor/

Pin CI images to PHP 8.5 or the minimum your apps require. Laravel 13 needs PHP 8.3+. Symfony 8.1 needs PHP 8.4.1+. Match the runner PHP version to production PHP-FPM on your VPS. Version skew between CI and server is a recurring source of "works in pipeline, fails after deploy" incidents.

Read CI/CD caching for Composer and npm for fallback keys and cache invalidation rules. If front-end assets use Vite 8.x, cache node_modules/ per app with a separate key tied to package-lock.json.

Composer Cache LayersGlobal .composer-cache/ (packagist downloads)App A vendor/lock hash key AApp B vendor/lock hash key BApp C vendor/lock hash key CWarm cache: ~45 sec installCold cache: 3–5 min per appWrong key = stale vendor bugs
Per-app Composer cache keys prevent cross-contamination between PHP apps in one monorepo

How Do You Deploy Multiple PHP Apps from One Monorepo?

Deployer 7 handles multi-host, multi-app releases well. One deploy.php at the repo root defines hosts and per-app paths. CI passes the app name as an argument.

Deployer multi-app configuration

namespace Deployer;

require 'recipe/laravel.php';

host('notary.example.com')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/var/www/notary-portal')
    ->set('application', 'notary-portal');

host('court.example.com')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/var/www/court-marriage')
    ->set('application', 'court-marriage');

task('deploy', function () {
    $app = get('application');
    set('release_path', "{{deploy_path}}/releases/{{release_name}}");
    set('current_path', "{{deploy_path}}/current");
    within("apps/{$app}", function () {
        invoke('deploy:prepare');
        invoke('deploy:vendors');
        invoke('artisan:storage:link');
        invoke('artisan:migrate');
        invoke('deploy:publish');
    });
});

After symlink swap, reload PHP-FPM so opcache picks up new files. I document this in every handover because stale opcache after deploy still catches teams off guard. See PHP opcache configuration for production for validate_timestamps and reload settings.

Sister legal-tech sites on shared EC2 — including work visible in our Notary Kathmandu portfolio case and Translation Nepal project — use this exact Deployer 7 plus GitLab CI pattern. Each app gets zero-downtime symlinked releases and a shared runner that builds only changed apps.

Environment and secrets per app

Store secrets in GitLab CI variables or GitHub Environments scoped per app. Prefix variables: NOTARY_DB_PASSWORD, COURT_APP_KEY. Never share one generic APP_KEY across apps. Follow CI/CD secrets management best practices and rotate keys when staff leave.

Production .env files live on the server under shared/, not in Git. CI injects build-time values only. Runtime secrets stay on the VPS. That aligns with the twelve-factor config model and limits blast radius if a CI log leaks.

Multi-App Deploy FlowCI Passesdep deploy appNew Release Dircomposer install --no-dev on serverartisan migrate + cache clearSymlink Swapcurrent -> releasePHP-FPM Reloadopcache refreshRollback: dep rollback app
Deployer zero-downtime releases for each PHP app in a monorepo with independent rollback

What Quality Gates Belong in a PHP Monorepo Pipeline?

Path filtering saves time. It does not replace quality checks. Every changed app should pass a consistent gate set before deploy.

  • PHPUnit or Pest — unit and feature tests for the touched app.
  • PHPStan or Psalm — static analysis at level appropriate to the codebase age.
  • Laravel Pint or PHP-CS-Fixer — style check, not auto-fix in CI unless team agrees.
  • Composer audit — catch known CVEs in dependencies before merge.
  • Migration dry-run — optional job against a disposable MySQL 9.7 or MariaDB 12.3 container.

Add code coverage gates in CI once test suites are stable. Start with a low threshold and raise it per app. Shared packages should require higher coverage because one bug affects multiple deployables.

For API apps, validate OpenAPI specs in CI. Paste responses into the JSON formatter tool during local debugging. That catches schema drift before integration tests fail in the runner.

Official references worth bookmarking: the GitLab CI rules:changes documentation, the Composer install CLI reference, and the Deployer 7 getting started guide.

Key Takeaways

  • Place each PHP app under apps/ with its own lock file, env template, and deploy target.
  • Use path-based rules:changes so only affected apps run test and deploy jobs.
  • When a shared package changes, trigger tests for every app that depends on it.
  • Key Composer cache per app lock hash; never share one vendor cache across apps.
  • Deploy with Deployer 7 hosts mapped per app; reload PHP-FPM after symlink swap.
  • Scope CI secrets per app and keep production env files on the server, not in Git.

People Also Ask

Should each PHP app in a monorepo have its own composer.lock?

Yes, in most cases. Independent lock files let you deploy one Laravel 12 app while another stays on Laravel 13 without forced upgrades. Shared packages still work via path repositories or a private Composer registry. A single root lock file suits tightly coupled apps that always release together.

How do you run CI only for changed apps in GitLab?

Add rules:changes with glob paths like apps/my-app/**/* to each job. Include shared package paths in every dependent app's rule set. GitLab compares the commit diff against those globs and skips jobs with no matching files.

Can Deployer deploy multiple apps from one repository?

Deployer supports multiple hosts and task variants in one deploy.php. Pass the app name from CI as a CLI argument or host setting. Each host gets its own deploy_path, shared directory, and release history. Roll back one app without touching others.

What PHP version should monorepo CI use?

Match production. If apps run PHP 8.4 on Ubuntu 24 with PHP-FPM, CI should use PHP 8.4 or 8.5 with the same extensions enabled. Laravel 13 requires PHP 8.3+. Symfony 8.1 requires PHP 8.4.1+. Test the lowest version any app still supports if runners are shared.

Build a Monorepo Pipeline That Scales With Your PHP Apps

CI CD for monorepo with multiple PHP apps pays off the first time a one-line fix deploys in four minutes instead of forty. Start with path rules, per-app caches, and Deployer hosts. Add parallel tests and coverage gates once the basics are stable. If you want help wiring this on Ubuntu 24 with GitLab CI, see our Linux system administration service or custom software development offering. For ongoing pipeline maintenance after launch, support and maintenance keeps deploys boring in the best way. Review the Adventure Third Pole Trek Laravel build for a single-app reference, then scale the pattern across your repo. Questions about your setup? Contact us with your repo layout and runner environment.

Frequently Asked Questions

One Git repo holding two or more deployable PHP applications, each with its own composer.json, env file, and deploy target, where every push triggers decisions about which apps changed, which tests to run, and which servers to update.

Place deployable apps under apps/ and reusable code under packages/. Each app owns its own composer.lock, PHPUnit config, and .env.example. Shared packages link via path repositories in each app's composer.json during development, and CI installs them the same way production does. Keep .gitlab-ci.yml and deploy.php at the repo root. Avoid mixing two apps at the repository root unless you want broken relative paths in CI. On production systems I maintain, several legal-tech portals share one repo this way and each site deploys independently.

Yes, in most cases. Independent lock files let you deploy one Laravel 12 app while another stays on Laravel 13 without forced upgrades.

Add rules:changes with glob paths like apps/my-app/*/ to each job. Include shared package paths in every dependent app's rule set.

Duplicate test and deploy blocks per app, using YAML anchors to keep image and cache settings DRY. Set variables like APP_NAME, point each job at apps/${APP_NAME}, and list changes globs for that app folder plus any packages it imports. Deploy jobs add a branch condition such as main. When packages/nepal-legal-core changes, every consumer must list that path in its changes block. Missing that link is a common production bug: shared library updates ship without retesting dependents. GitHub Actions users mirror the same logic with paths filters on push events.

Trigger tests for every app that depends on the package. List the shared package path in each dependent app's rules:changes block, or run a detect-changed-paths job first that writes affected app names to a dotenv artifact and fans out downstream jobs. Block merge if any affected app fails PHPUnit or PHPStan. Shared packages should carry higher test coverage because one bug affects multiple deployables. For parallel speed on large Laravel apps, wire ParaTest but only on changed apps to keep wall-clock time low.

Set COMPOSER_CACHE_DIR to a project-level folder and key the cache on each app's composer.lock hash using cache:key:files. Cache .composer-cache/ and optionally apps/my-app/vendor/ as paths. Never use one global cache key across apps with different lock files; that causes stale vendor trees and confusing test failures. Without cache, Composer install can burn three to five minutes per app per pipeline. Warm keyed runs often finish in under 60 seconds. Pin CI images to PHP 8.5 or the minimum your apps require, matching production PHP-FPM extensions.

Yes. One deploy.php at the repo root defines multiple hosts, each with its own deploy_path, remote_user, and application name. CI passes the app name as an argument, for example dep deploy notary-portal --branch=main. The deploy task runs within apps/{application}, invoking Laravel recipe steps like deploy:vendors, artisan:storage:link, artisan:migrate, and deploy:publish. Each host keeps independent release history, so you roll back one app without touching others. Sister legal-tech sites on shared EC2 use this exact Deployer 7 plus GitLab CI pattern with zero-downtime symlinked releases.

Match production PHP-FPM on your VPS. If apps run PHP 8.4 on Ubuntu 24, CI should use PHP 8.4 or 8.5 with the same extensions enabled. Laravel 13 requires PHP 8.3 or higher. Symfony 8.1 requires PHP 8.4.1 or higher. If one shared runner builds all apps, test against the lowest version any app still supports. Version skew between CI and server is a recurring source of pipelines that pass locally and in CI but fail after deploy.

Per-app lock files suit independent deploy cadences: a hotfix on one portal should not force a Composer update on another. A root workspace with Composer 2.10 fits tightly coupled apps that always release together, giving one install and matrix test jobs but blocking all apps if one package breaks. A hybrid model installs shared packages first, then apps, adding YAML but clearer boundaries. For most client monorepos I prefer per-app lock files because apps deploy on different schedules and shared packages still work via path repositories or a private Composer registry.

Every changed app should pass a consistent set regardless of path filtering. Run PHPUnit or Pest for unit and feature tests, PHPStan or Psalm at a level suited to the codebase age, Laravel Pint or PHP-CS-Fixer for style checks, and Composer audit to catch known CVEs before merge. Optionally dry-run migrations against a disposable MySQL 9.7 or MariaDB 12.3 container. Add code coverage gates once test suites are stable, starting with a low threshold per app. API apps should validate OpenAPI specs in CI to catch schema drift before integration tests fail in the runner.

Store secrets in GitLab CI variables or GitHub Environments scoped per app. Prefix variable names such as NOTARY_DB_PASSWORD and COURT_APP_KEY. Never share one generic APP_KEY across apps. Production .env files live on the server under shared/, not in Git. CI injects build-time values only; runtime secrets stay on the VPS, aligning with the twelve-factor config model and limiting blast radius if a CI log leaks. Rotate keys when staff leave and follow CI/CD secrets management best practices for your platform.

PHP opcache can keep serving bytecode from the previous release after Deployer publishes a new symlinked release. Without reloading PHP-FPM, teams see old code running despite a successful deploy. I document this in every handover because stale opcache after deploy still catches people off guard. After deploy:publish within apps/{application}, reload PHP-FPM on the target host so opcache picks up new files. Review validate_timestamps and reload settings in your production opcache configuration to match your release workflow.

A tuned pipeline with path rules, per-app Composer caches, and independent Deployer hosts can deploy a one-line fix in about four minutes instead of forty. A naive pipeline runs everything on every push, wasting minutes, blocking merges, and training teams to skip CI. A docs-only change in one app should never trigger a full rebuild of the others. Path filtering saves wall-clock time immediately; add parallel PHPUnit via ParaTest and coverage gates once the basics are stable.

Cache node_modules/ per app with a separate cache key tied to that app's package-lock.json, independent of the Composer cache keyed on composer.lock. Pin front-end tooling to Vite 8.x and Node.js versions matching your build environment. Keep Composer cache under COMPOSER_CACHE_DIR with per-app lock file keys and Vite cache separate to prevent cross-contamination between apps. If your server has no Node, commit built assets as artefacts from CI the same way many production PHP deployments handle frontend builds without a runtime Node installation on the VPS.

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: