
September 09, 2026
11 min read
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.
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.
Recommended directory layout
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.
| Model | Best for | CI implication | Trade-off |
|---|---|---|---|
| Per-app lock files | Independent deploy cadence | Cache keyed per app path | Duplicate dev dependencies |
| Root workspace (Composer 2.10) | Shared packages with tight coupling | Single install, matrix test jobs | One broken package blocks all apps |
| Hybrid | 2–4 apps plus one shared lib | Install packages first, then apps | More 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.
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:
- Detect changed paths with a small shell script or CI component.
- Write affected app names to a dotenv artifact.
- Trigger downstream jobs with
needsand dynamic child pipelines. - 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.
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.
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:changesso 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
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.

