Composer path repositories instead of version chaos
Anyone maintaining several related PHP packages knows the pain: every small change in the core package means a new version, a new tag, and a composer update in every dependent package just to test locally whether everything still fits together. A PHP monorepo with Composer path repositories solves exactly this problem, without making the later release to Packagist any harder.
Table of Contents
- 1. What a PHP monorepo really is and when it pays off
- 2. Composer path repositories as the foundation
- 3. Directory structure of a PHP monorepo
- 4. Version dependencies between packages
- 5. Tooling: Symplify MonorepoBuilder in practice
- 6. CI strategy for multiple packages in one repository
- 7. Splitting into standalone Packagist repositories
- 8. Team workflow: atomic changes across package boundaries
- 9. Monorepo vs. multi repo compared directly
- 10. Summary
- 11. FAQ
1. What a PHP monorepo really is and when it pays off
A PHP monorepo is a single Git repository that contains several independent Composer packages, each with its own composer.json, its own namespace and its own version number, but developed and versioned together in one shared history. The counterpart is the multi repo model, also called polyrepo: every package lives in its own Git repository and is pulled in through Packagist or a private registry. Both models solve the same underlying problem of splitting up packages, yet they differ massively in day to day development.
A PHP monorepo pays off as soon as a team maintains several closely related packages that are typically changed together. An internal SDK split into a core package, an HTTP client and testing helpers is a classic example. If an interface in the core package changes, it immediately affects the HTTP client, and both changes can be reviewed in a single commit and a single pull request instead of being synchronized across two separate repositories with staggered releases.
Not every scenario benefits from a PHP monorepo. Packages with completely different release cadences, external contributors who should only work on a single package, or entirely separate audiences are often better served by separate repositories. The choice between monorepo and multi repo is not a matter of taste, it depends directly on how strongly the packages are actually developed together.
2. Composer path repositories as the foundation
Besides the standard vcs type and the implicit Packagist repository, Composer also supports the path type. A path repository points to a local directory relative to the composer.json and lets Composer resolve the package directly from there, without any download or network access. This is exactly the technical core that makes a PHP monorepo practical in the first place: all internal packages already exist locally, Composer merely needs to link them.
The symlink option matters here. When enabled, Composer creates a symbolic link inside the vendor directory instead of copying the files. Changes in the core package are then immediately visible in the dependent package, with no additional composer install required. On operating systems without symlink support, some Windows configurations for instance, Composer automatically falls back to copying, which is functionally identical but only shows changes after another install run.
{
"name": "mironsoft/http-client",
"type": "library",
"require": {
"php": "^8.4",
"mironsoft/core": "^2.0"
},
"repositories": [
{
"type": "path",
"url": "../core",
"options": {
"symlink": true
}
}
],
"autoload": {
"psr-4": {
"Mironsoft\\HttpClient\\": "src/"
}
}
}
A common pitfall: Composer prefers the path repository over a remote source whenever several candidates match, but it does not ignore the declared version constraint while doing so. If the core package has no matching version, for instance because the branch does not carry any tags yet, declaring an explicit dev version through the version option in the repository entry helps Composer accept the local package as valid anyway.
3. Directory structure of a PHP monorepo
The established structure for a PHP monorepo revolves around a packages directory at the repository root, where every package gets its own subfolder with its own composer.json, its own src directory and its own tests. The root itself holds a separate composer.json that only bundles development tools such as PHPStan, PHP CS Fixer and PHPUnit under require dev, so that no individual package needs to duplicate the same dev dependencies.
Namespaces consistently follow PSR 4 per package, usually with the package name as an extra namespace segment, for example Mironsoft\HttpClient for packages/http-client. This separation ensures that every package keeps working without code changes after a later split into its own repository, because the namespace never depended on the monorepo context in the first place.
#!/usr/bin/env bash
# scaffold-package.sh — create a new package skeleton inside the monorepo
set -euo pipefail
PACKAGE_NAME="$1"
PACKAGE_DIR="packages/${PACKAGE_NAME}"
mkdir -p "${PACKAGE_DIR}/src" "${PACKAGE_DIR}/tests"
cat > "${PACKAGE_DIR}/composer.json" <<JSON
{
"name": "mironsoft/${PACKAGE_NAME}",
"type": "library",
"require": { "php": "^8.4" },
"require-dev": { "phpunit/phpunit": "^11.0" },
"autoload": {
"psr-4": { "Mironsoft\\\\$(echo "$PACKAGE_NAME" | sed -r 's/(^|-)([a-z])/\U\2/g')\\\\": "src/" }
}
}
JSON
echo "[OK] Package skeleton created at ${PACKAGE_DIR}"
4. Version dependencies between packages
As soon as one package in the PHP monorepo requires another internal package, the same version constraint applies as with any external dependency, for example require mironsoft/core ^2.0. Because the path repository checks the constraint internally against the local composer.json of the core package, the version number in the core package must be kept consistent, even though development always uses the current working tree anyway.
A typical mistake happens when teams forget to adjust the internal version constraint before packages get released individually. Inside the monorepo everything works smoothly, because the path repository treats the version constraint fairly generously once a branch alias is present. After the split into separate Packagist packages, composer update suddenly fails because the actually released version of the core package no longer matches the constraint declared in the dependent package. Running composer validate and composer outdated directly in the CI pipeline reliably surfaces such inconsistencies before they reach the release.
5. Tooling: Symplify MonorepoBuilder in practice
For orchestrating a PHP monorepo, the symplify/monorepo-builder package has become the de facto standard. It handles three core tasks: validating that all internal version constraints match each other, synchronizing a new version number across all packages, and merging shared composer.json sections such as require dev, so no package has to maintain the same tool versions separately.
Configuration happens through a monorepo-builder.php file at the repository root, where the paths of the individual packages are registered. The validate command typically runs as the first step in every CI pipeline of a PHP monorepo and fails immediately if a package declares an outdated or inconsistent internal dependency, long before a developer would have to debug the problem manually.
# Validate that all internal composer.json dependencies are consistent
vendor/bin/monorepo-builder validate
# Merge shared require-dev and autoload-dev sections into every package
vendor/bin/monorepo-builder merge
# Bump the version constraint across all packages in one atomic step
vendor/bin/monorepo-builder release 3.1.0 --dry-run
vendor/bin/monorepo-builder release 3.1.0
6. CI strategy for multiple packages in one repository
A naive CI setup for a PHP monorepo tests every package fully on every commit, regardless of which package actually changed. With five or more packages this quickly becomes a runtime problem, especially when each package runs its own matrix of PHP versions. The more robust strategy uses git diff to determine which directories changed since the last common commit and starts only the affected job definitions.
For pull requests that touch several packages at once, for example because an interface change in the core package affects every dependent package, the pipeline should still conservatively test all dependent packages, not just the one directly changed. A simple dependency graph, maintained in the same monorepo-builder.php configuration, is usually enough to resolve these reverse dependencies automatically.
# .gitlab-ci.yml — matrix job per package, only for changed directories
stages: [validate, test]
validate:
stage: validate
script:
- composer install --no-progress
- vendor/bin/monorepo-builder validate
test-core:
stage: test
script:
- composer install --working-dir=packages/core
- vendor/bin/phpunit -c packages/core
rules:
- changes: [packages/core/**/*, packages/http-client/**/*]
test-http-client:
stage: test
script:
- composer install --working-dir=packages/http-client
- vendor/bin/phpunit -c packages/http-client
rules:
- changes: [packages/http-client/**/*]
7. Splitting into standalone Packagist repositories
Even with a PHP monorepo as the internal development model, external users still expect individual, focused Composer packages on Packagist, each with its own repository, its own release history and its own issue tracker. This requirement is solved by the split step: an automated process extracts the commit history of a single packages subdirectory into a standalone, usually read only target repository.
Symplify MonorepoBuilder ships a GitHub Action called monorepo-split-github-action for this purpose, which automatically pushes the configured subdirectories to their respective target repositories on every push to the main branch, complete with the full Git history for that package. Developers work exclusively inside the monorepo, the split repository is pure distribution and is never edited directly, to avoid divergence.
# .github/workflows/split.yml — push each package subdirectory to its own repo
name: Monorepo Split
on:
push:
branches: [main]
jobs:
split:
runs-on: ubuntu-latest
strategy:
matrix:
package:
- local_path: 'packages/core'
split_repository: 'mironsoft/core'
- local_path: 'packages/http-client'
split_repository: 'mironsoft/http-client'
steps:
- uses: actions/checkout@v4
- uses: symplify/monorepo-split-github-action@v2.3
with:
package_directory: ${{ matrix.package.local_path }}
repository_organization: mironsoft
repository_name: ${{ matrix.package.split_repository }}
user_name: mironsoft-bot
user_email: bot@mironsoft.de
8. Team workflow: atomic changes across package boundaries
The biggest practical advantage of a PHP monorepo shows up in the everyday life of a feature branch that touches several packages at once. Instead of coordinating two separate pull requests across two repositories and keeping track of the right merge order, the entire change, core package and HTTP client together, lands in a single pull request with a single reviewer context.
This advantage in turn demands strict CI discipline. A broken package must never block the merge of a completely unrelated package in the same monorepo, otherwise the development flow flips into the opposite. The solution is independent pipeline stages per package, combined with a clear rule that branch protection only requires the actually affected job definitions as mandatory checks, not the entire matrix of every package in the repository.
9. Monorepo vs. multi repo compared directly
The choice between a PHP monorepo and separate repositories depends on the actual coupling of the packages, not on a general best practice. The table below compares both models along the criteria that most often tip the scale in practice.
| Criterion | Multi Repo | PHP Monorepo | Practical relevance |
|---|---|---|---|
| Atomic changes | Coordinated across multiple PRs | A single commit, a single PR | High for tightly coupled packages |
| External visibility | Focused single repo | Requires a split for users | Important with external contributors |
| CI runtime | Small and isolated per repo | Requires selective job selection | Relevant from five packages up |
| Onboarding new team members | Clone multiple repos individually | One clone, everything available | Saves setup time day to day |
| Tooling maturity | Native Composer workflow | Extra tool such as MonorepoBuilder needed | Extra learning curve for the team |
In practice the choice usually comes down to a single question: are the packages predominantly changed together, or predominantly independently. When development is predominantly shared, a PHP monorepo almost always pays off despite the added tooling complexity, because the saved coordination effort clearly outweighs the extra cost in CI and split pipeline.
Mironsoft
PHP architecture, package strategy and Composer tooling
Several PHP packages in one cleanly structured monorepo?
We analyze your existing package landscape, plan the migration into a PHP monorepo with path repositories and set up CI pipeline and split automation for Packagist.
Architecture Review
Assessing whether a monorepo or multi repo model fits the coupling of your packages
Migration
Path repositories, MonorepoBuilder configuration and namespace moves without downtime
CI and Split
Selective pipelines and automated split into individual Packagist repositories
10. Summary
A PHP monorepo with multiple packages solves the core problem of tightly coupled Composer packages: instead of tagging a new version and manually catching up dependent packages for every change, Composer links all internal packages directly from the working directory through path repositories. A clear directory structure under packages, consistent PSR 4 namespaces per package and a tool like Symplify MonorepoBuilder for version validation and release synchronization form the technical foundation.
For the CI pipeline the rule is: test selectively what actually changed, but conservatively test all dependent packages whenever a shared interface is affected. Splitting into standalone Packagist repositories through automated GitHub Actions ensures that external users still find focused, individually installable packages, while the team works internally in a single, atomically versioned monorepo.
PHP Monorepo with Multiple Packages — The Essentials at a Glance
Path Repositories
Composer links internal packages directly from the working directory, with the symlink option no reinstall is needed after every change.
Directory Structure
A packages directory with one subfolder per package, each with its own composer.json, its own PSR 4 namespace, its own tests.
Tooling
Symplify MonorepoBuilder validates version constraints, synchronizes releases and merges shared require dev sections.
CI and Split
Selective tests based on changed directories, automated split into standalone Packagist repositories via GitHub Action.