symplify/monorepo-builder
Toolkit for managing PHP monorepos: merge package composer.json files into the root, validate dependency versions, bump interdependencies, propagate versions back, manage package aliases, and automate releases. Configured via monorepo-builder.php; requires PHP 8.2+.
monorepo-builder is a dev-only Composer CLI that keeps a PHP monorepo's composer.json files consistent and automates releases. It is not a runtime library.
Install (PHP 8.2+; on PHP 8.1 pin symplify/monorepo-builder:^11.2, which is unmaintained):
composer require monorepo-php/monorepo --dev
First three commands to learn:
vendor/bin/monorepo-builder init # one-time: scaffolds packages/ + monorepo-builder.php
vendor/bin/monorepo-builder merge # folds every package composer.json into root
vendor/bin/monorepo-builder release v1.0 --dry-run
Where to look first: the root monorepo-builder.php. Everything is configured through a closure receiving MBConfig:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
$mbConfig->packageDirectories([__DIR__ . '/packages']);
};
First real use case: you already have packages/*/composer.json files with their own require, autoload, and require-dev. Run init (or hand-write the config), then merge, commit the regenerated root composer.json, and run composer update once at the root. From then on, per-package dependency edits are followed by a merge instead of manual root edits.
Merge is the daily driver. Run vendor/bin/monorepo-builder merge whenever a package's composer.json changes. It merges all sections — standard (require, autoload) and custom (scripts-aliases, abandoned, …). Key behaviors to rely on:
require and require-dev resolves to the require version.autoload aggregates every internal package's PSR-4 so root-level vendor/bin/phpunit resolves all namespaces.Enforce dependency consistency in CI. validate fails when packages disagree on a shared dependency version. Pair it with merge in a "no drift" job: run merge and git diff --exit-code composer.json to catch a package edit that was never folded in.
Version bumps across packages. After releasing, bump-interdependency "^4.0" rewrites mutual package dependencies to a version constraint. propagate goes the other direction, pushing root composer.json versions back into each package — useful when the root is the source of truth.
Pre-split smoke test. localize-composer-paths points mutual package requirements at local paths, letting you test the monorepo as if the packages were installed from Packagist. package-alias syncs each package's branch-alias to the current version (format configurable).
Release pipeline as a first-class workflow. Default pipeline is TagVersionReleaseWorker → PushTagReleaseWorker. Typical richer pipeline configured in monorepo-builder.php:
$mbConfig->workers([
UpdateReplaceReleaseWorker::class,
SetCurrentMutualDependenciesReleaseWorker::class,
AddTagToChangelogReleaseWorker::class,
TagVersionReleaseWorker::class,
PushTagReleaseWorker::class,
SetNextMutualDependenciesReleaseWorker::class,
UpdateBranchAliasReleaseWorker::class,
PushNextDevReleaseWorker::class,
]);
Use disableDefaultWorkers() to start from scratch, or implement ReleaseWorkerInterface for custom steps. Always dry-run first: release v7.0 --dry-run. Semver shortcuts work too — release patch turns v0.7.1 into v0.7.2.
Tame merge output rather than post-editing the root file. Use dataToAppend() / dataToRemove() to inject shared dev tooling or strip sections, and composerSectionOrder(JsonSchema::getProperties()) to impose a stable layout:
$mbConfig->dataToAppend([
ComposerJsonSection::REQUIRE_DEV => ['phpstan/phpstan' => '^2.1'],
]);
$mbConfig->dataToRemove([
ComposerJsonSection::REPOSITORIES => [Option::REMOVE_COMPLETELY],
]);
Mixed monorepo of libraries + apps. If apps consume libraries via path repositories and rely on Composer symlinks, disable replace and scope the autoload skip:
$mbConfig->disablePackageReplace();
$mbConfig->disableAutoloadMerge(
sections: [AutoloadSection::Autoload],
forTypes: [PackageType::Library],
);
Libraries get symlinked and registered by Composer; only apps' PSR-4 lands in the root autoload.
Custom package roots. packageDirectories([...]) accepts several roots; packageDirectoriesExcludes([...]) carves out internal packages that shouldn't participate.
merge owns the root composer.json. Hand edits to sections that merge produces will be overwritten on the next run. Put persistent additions in dataToAppend() and removals in dataToRemove().autoload-dev is root-only in Composer. Path-repo dependencies' dev autoload is never registered in the consumer's vendor/composer/autoload_psr4.php. If root CI runs vendor/bin/phpunit and expects library test classes, you must keep folding AutoloadSection::AutoloadDev — skipping it breaks cross-package test discovery.forTypes does not use Composer's "missing type means library" default. A package with no explicit type is not matched by forTypes: [PackageType::Library]. Declare type: library explicitly in the package's composer.json if you want the filter to see it.disableAutoloadMerge() is now argument-based. The old zero-arg call still works but emits E_USER_DEPRECATED and maps to full-kill behavior. Repeated calls compose across different sections but are last-call-wins per section. Prefer shouldSkipAutoload($type) / shouldSkipAutoloadDev($type) over the deprecated isAutoloadMergeDisabled() getter.disablePackageReplace() and autoload skipping are a package deal. Turning off replace without skipping library autoload merging duplicates registration when Composer already symlinks those libraries into vendor/.forTypes accepts strings as well as enum cases — 'wordpress-plugin', 'drupal-module', 'symfony-bundle', or custom types. Enum cases and strings can be mixed; multiple types are OR-matched.release mutates and pushes. It bumps interdependencies, tags, and pushes. Run --dry-run on a clean working tree and read the plan; there's no undo beyond git reset plus deleting the remote tag.init never overwrites. Safe to re-run, but it won't repair an existing monorepo-builder.php — edit it directly.composer.json after merge (git diff composer.json) and check the package-level composer.json that introduced the change. Root key order being preserved means appended sections appear at the bottom — that's expected, not a bug.monorepo-builder.php that returns an array or executes configuration at include time silently does nothing.How can I help you explore Laravel packages today?