symplify/monorepo-builder
Tools for PHP monorepos: scaffold a repo, merge package composer.json files into the root, validate shared dependency versions, bump inter-package constraints, propagate versions back to packages, and automate releases via a single monorepo-builder.php config.
A set of tools for managing PHP monorepos: merging composer.json files, validating package versions, releasing with automation, and more.
composer require monorepo-php/monorepo --dev
Requires PHP 8.2+. For PHP 8.1, use symplify/monorepo-builder:^11.2 (no longer maintained).
# 1. Scaffold a basic monorepo layout (one time)
vendor/bin/monorepo-builder init
# 2. Fold every package's composer.json into the root composer.json
vendor/bin/monorepo-builder merge
# 3. Cut a release when you're ready
vendor/bin/monorepo-builder release v1.0
All configuration goes in monorepo-builder.php at your project root. See Configuration for the full list of options.
Generates a basic monorepo skeleton (a packages/ directory and a starter monorepo-builder.php) so you can start adding packages immediately:
vendor/bin/monorepo-builder init
Run once at the start of a new monorepo. Existing files are not overwritten.
Merges all sections from package composer.json files into the root composer.json. For the reverse direction, see propagate.
vendor/bin/monorepo-builder merge
Behavior:
require, autoload, etc.) and custom ones (scripts-aliases, abandoned, etc.)require and require-dev, the require entry takes prioritycomposer.json is preserved; new sections are appended at the endTo customize what gets merged (append / remove data, reorder sections, skip autoload merging, etc.) see Customizing merge output.
Checks that all packages use the same version for shared dependencies:
vendor/bin/monorepo-builder validate
Updates mutual dependencies between packages to a given version:
vendor/bin/monorepo-builder bump-interdependency "^4.0"
Propagates versions from root composer.json back to each package's composer.json (the reverse of merge):
vendor/bin/monorepo-builder propagate
Updates the branch-alias in every package composer.json to match the current version:
vendor/bin/monorepo-builder package-alias
To customize the alias format string, see Custom alias format under Configuration.
Sets mutual package paths to local packages for pre-split testing:
vendor/bin/monorepo-builder localize-composer-paths
Automates the release process: bumping dependencies, tagging, pushing, and updating changelogs.
vendor/bin/monorepo-builder release v7.0
Preview what will happen without making changes:
vendor/bin/monorepo-builder release v7.0 --dry-run
Release by semver level (patch, minor, or major):
# current v0.7.1 → v0.7.2
vendor/bin/monorepo-builder release patch
The default pipeline runs TagVersionReleaseWorker followed by PushTagReleaseWorker. To customize the pipeline (add workers, reorder, disable defaults, enable LTS-aware tag resolution), see Customizing the release pipeline.
All configuration lives in monorepo-builder.php at your project root. Every option below is set on the MBConfig instance passed into the configurator closure:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
// your configuration here
};
By default, packages are discovered from ./packages. To customize:
return static function (MBConfig $mbConfig): void {
$mbConfig->packageDirectories([
__DIR__ . '/packages',
__DIR__ . '/projects',
]);
// exclude specific packages
$mbConfig->packageDirectoriesExcludes([__DIR__ . '/packages/secret-package']);
};
These options shape what vendor/bin/monorepo-builder merge writes into the root composer.json.
use Symplify\MonorepoBuilder\ComposerJsonManipulator\ValueObject\ComposerJsonSection;
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\ValueObject\Option;
return static function (MBConfig $mbConfig): void {
// add data after merge (supports any composer.json key)
$mbConfig->dataToAppend([
ComposerJsonSection::AUTOLOAD_DEV => [
'psr-4' => [
'Symplify\Tests\\' => 'tests',
],
],
ComposerJsonSection::REQUIRE_DEV => [
'phpstan/phpstan' => '^2.1',
],
]);
// remove data after merge
$mbConfig->dataToRemove([
ComposerJsonSection::REQUIRE => [
// removed by key, version is irrelevant
'phpunit/phpunit' => '*',
],
ComposerJsonSection::REPOSITORIES => [
Option::REMOVE_COMPLETELY,
],
]);
};
By default, the original key order of root composer.json is preserved. To enforce a specific order:
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Merge\JsonSchema;
return static function (MBConfig $mbConfig): void {
$mbConfig->composerSectionOrder(JsonSchema::getProperties());
};
By default, every internal package's autoload and autoload-dev PSR-4 entries are folded into the root composer.json so that vendor/bin/phpunit and other root-level tooling can resolve every namespace. The three scenarios below cover when you'd want to skip part or all of that merging — pick the one that matches your monorepo:
Scenario 1 — Default monorepo of libraries. No skip needed. The root composer.json autoload aggregates every internal library's PSR-4 mapping, so any namespace resolves from the root vendor.
Scenario 2 — Mixed monorepo with libraries symlinked + apps not required from root. When disablePackageReplace() is on (libraries are real path-repo deps, Composer symlinks them into vendor/), the libraries' autoload is registered automatically by Composer via vendor/composer/autoload_psr4.php. Folding them into the root composer.json would duplicate that registration. Skip autoload merging for libraries only — apps' autoload still merges so root-level scripts can find them:
use Symplify\MonorepoBuilder\Config\AutoloadSection;
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Config\PackageType;
return static function (MBConfig $mbConfig): void {
$mbConfig->disablePackageReplace();
$mbConfig->disableAutoloadMerge(
sections: [AutoloadSection::Autoload],
forTypes: [PackageType::Library],
);
};
Result: root autoload contains apps' PSR-4 entries but NOT libraries'. Root autoload-dev still aggregates everything (see "Why autoload-dev is independent" below).
Scenario 3 — Custom merge. To turn off both sections entirely (you handle merging yourself, e.g. via a custom decorator):
use Symplify\MonorepoBuilder\Config\AutoloadSection;
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
$mbConfig->disableAutoloadMerge(
sections: [AutoloadSection::Autoload, AutoloadSection::AutoloadDev],
forTypes: [],
);
};
Result: root autoload and autoload-dev are untouched by monorepo-builder merge.
API reference:
disableAutoloadMerge(array $sections, array $forTypes) — both arguments are required.
$sections: a non-empty list of AutoloadSection cases (Autoload, AutoloadDev).$forTypes: a list of composer.json type filter values. Each element may be either a PackageType enum case (preferred for the four Composer schema types: Library, Project, Metapackage, ComposerPlugin) or a non-empty string (escape hatch for ecosystem types defined by composer/installers such as 'wordpress-plugin', 'drupal-module', 'symfony-bundle', and for user-defined custom types). Mixing enum cases and strings in the same call is allowed; multiple types are OR-matched. The two filter channels are intentionally distinct: pass an empty array (forTypes: []) to skip every package regardless of type, OR pass a non-empty list of types to skip ONLY packages whose composer.json declares the matching type literally. Composer's "missing type defaults to library" rule does NOT extend the filter — a package without an explicit type field is NOT swept up by forTypes: [PackageType::Library]. If you want the filter to catch an untyped package, declare type: library (or whichever) in that package's composer.json.Migrating from the previous binary API: The earlier zero-argument form $mbConfig->disableAutoloadMerge(); continues to work but is deprecated and emits an E_USER_DEPRECATED notice. It maps to the full-kill behavior — equivalent to disableAutoloadMerge(sections: [AutoloadSection::Autoload, AutoloadSection::AutoloadDev], forTypes: []). Update existing config files at your convenience. The legacy MBConfig::isAutoloadMergeDisabled() getter is also kept as a deprecated convenience that returns true only when both sections are configured to skip merging for all packages — prefer MBConfig::shouldSkipAutoload($packageType) and MBConfig::shouldSkipAutoloadDev($packageType) for new code.
Composer treats autoload-dev as a root-only section: dev autoload entries from path-repo dependencies are NEVER registered in the consumer's vendor/composer/autoload_psr4.php. (See Composer schema docs — autoload-dev.)
Practical consequence: if your CI runs vendor/bin/phpunit from the monorepo root and expects to discover library test classes, those test classes are reachable ONLY because monorepo-builder merge has folded each library's autoload-dev PSR-4 into the root composer.json. Skipping AutoloadSection::AutoloadDev from root merge therefore breaks cross-package PHPUnit discovery — skip it only when you're handling test discovery another way.
By default, monorepo-builder merge writes a replace section into the root composer.json listing every internal package at self.version. This is correct for monorepos that publish a single combined dependency surface — Composer then refuses to install any external copy of those packages.
Some monorepos do NOT want this:
path repositories and rely on Composer's symlink installation (the replace entry would short-circuit the symlink)type: library packages and type: project apps where the apps need real installs of the libsTo skip writing the replace section entirely:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
$mbConfig->disablePackageReplace();
};
This pairs naturally with Scenario 2 of the autoload skip section above. With both opt-outs on, your path-repository-based libraries get symlink-installed by Composer and only your apps' autoload entries land in the root composer.json.
These options shape what vendor/bin/monorepo-builder release does on each invocation.
TagVersionReleaseWorker and PushTagReleaseWorker are enabled by default. Add more workers or customize the order:
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\AddTagToChangelogReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushNextDevReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushTagReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetCurrentMutualDependenciesReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetNextMutualDependenciesReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\TagVersionReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateBranchAliasReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateReplaceReleaseWorker;
return static function (MBConfig $mbConfig): void {
$mbConfig->workers([
UpdateReplaceReleaseWorker::class,
SetCurrentMutualDependenciesReleaseWorker::class,
AddTagToChangelogReleaseWorker::class,
TagVersionReleaseWorker::class,
PushTagReleaseWorker::class,
SetNextMutualDependenciesReleaseWorker::class,
UpdateBranchAliasReleaseWorker::class,
PushNextDevReleaseWorker::class,
]);
};
To disable the default workers (and define your pipeline from scratch):
return static function (MBConfig $mbConfig): void {
$mbConfig->disableDefaultWorkers();
};
You can also add custom workers by implementing ReleaseWorkerInterface.
If you maintain multiple version lines, the release command may reject older versions because it compares against the most recent tag globally. Enable branch-aware validation to compare only within the same major version:
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Contract\Git\TagResolverInterface;
use Symplify\MonorepoBuilder\Git\BranchAwareTagResolver;
return static function (MBConfig $mbConfig): void {
$services = $mbConfig->services();
$services->set(BranchAwareTagResolver::class);
$services->alias(TagResolverInterface::class, BranchAwareTagResolver::class);
};
vendor/bin/monorepo-builder package-alias writes a branch-alias entry into every package composer.json. To override the format string used:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
// default: "<major>.<minor>-dev"
$mbConfig->packageAliasFormat('<major>.<minor>.x-dev');
};
To split packages into separate repositories, use symplify/github-action-monorepo-split with GitHub Actions.
How can I help you explore Laravel packages today?