eXtreme Legacy 11: A PHP Monorepo of Apps and Packages 04/03/2026
BisouLand is an eXtreme Legacy (2005 LAMP) app, an idle browser game where players take each other's Love Points by blowing kisses across clouds.
In this series, we're dealing with it as it is: a big ball of mud, a spaghetti code base written by a student learning web development from online tutorials.
So far, we have:
- ๐ got it to run in a local container
- ๐จ written Smoke Tests
- ๐ฏ written End to End Tests
- ๐งน created and applied Coding Standards
- โ migrated to PDO
- ๐ upgraded PHP 5 to PHP 8
- ๐ก applied automated refactorings using Rector
- ๐ migrated from MySQL to PostgreSQL
- ๐ fixed an XSS Vulnerability
- ๐ญ built Qalin: a Test Control Interface, with modern Symfony
The XSS fix introduced clean domain objects (Account, AuthToken) and PDO PostgreSQL
adapters. Qalin, the modern Symfony application built alongside the monolith,
depends on those same objects to create test scenarios.
Now we have two applications and ten packages. That is where a monorepo comes in.
The Problem
Adding Qalin turned a single-application repository into something bigger: two apps and a growing set of shared packages.
In a multi-repository setup, each package lives in its own repository: its own checkout, its own CI pipeline, its own version tag, its own Packagist entry.
Changing an interface that touches two packages means:
- Edit package A, commit, push, tag
- Edit package B, bump the constraint to pick up the tag, commit, push
- Edit the application, bump the constraint again, commit, push
A change that touches one line of an interface requires three commits across three repositories before you can test whether it compiles.
Cross-cutting tools face the same problem. A single PHPStan rule change
means updating ten phpstan.neon.dist files, one per package.
A monorepo solves this by putting everything in one place.
The Structure
The root of the repository has two directories:
bisouland/
โโโ apps/
โ โโโ monolith/ โ the 2005 LAMP app
โ โโโ qa/ โ Qalin (Symfony 8)
โโโ packages/
โโโ bl-auth/
โโโ bl-auth-bundle/
โโโ bl-auth-pdopg/
โโโ bl-auth-tests/
โโโ bl-exception/
โโโ bl-exception-bundle/
โโโ bl-game/
โโโ bl-game-bundle/
โโโ bl-game-pdopg/
โโโ bl-game-tests/
There is no root composer.json. Each application and each package manages its own
dependencies independently. What connects them is Composer's built-in support for
local path repositories.
Composer Path Repositories
When Composer resolves a package, it normally fetches it from Packagist.
But packages do not have to be published to be used.
Composer supports a path repository type that points to a local directory:
{
"repositories": [
{"type": "path", "url": "../../packages/*"}
]
}
The * glob means every subdirectory under packages/ is a potential package.
When you run composer install, Composer reads each subdirectory's composer.json,
finds the declared name, and makes it available as if it had been fetched from Packagist.
Here is the monolith's (app) composer.json:
{
"name": "bl/monolith",
"type": "project",
"repositories": [
{"type": "path", "url": "../../packages/*"}
],
"require": {
"php": ">=8.5",
"bl/auth": "*@dev",
"bl/auth-pdopg": "*@dev",
"bl/exception": "*@dev",
"ext-curl": "*",
"ext-pdo_pgsql": "*",
"symfony/uid": "^8.0"
}
}
The version constraint *@dev means "any version, at development stability".
Composer symlinks the local directory into vendor/ rather than copying it,
so edits to the package source are immediately reflected without a composer update.
Packages can also depend on each other.
bl/auth depends on bl/exception, and declares its own path repository pointing upstream:
{
"name": "bl/auth",
"type": "library",
"repositories": [
{"type": "path", "url": "../*"}
],
"require": {
"php": ">=8.5",
"bl/exception": "*@dev",
"symfony/uid": "^8.0"
},
"autoload": {
"psr-4": {"Bl\\Auth\\": "src/"}
}
}
The Package Graph
The ten packages form a layered dependency graph. Each layer has a clear responsibility:
| Suffix | Responsibility |
|---|---|
| (none) | Domain model: interfaces, value objects, entities |
-pdopg |
Infrastructure: PDO PostgreSQL implementations |
-bundle |
Infrastructure: Symfony integration (service wiring) |
-tests |
Package unit tests and shared fixtures (builders, factories) |
The domain packages (bl-auth, bl-game) are framework-agnostic.
They declare interfaces and value objects but hold no framework dependency
beyond symfony/uid for UUID generation.
The infrastructure packages implement the domain interfaces using PDO. The application never depends on the implementation directly; it depends on the interface and relies on the Symfony bundle to provide the binding:
// packages/bl-auth-bundle/src/BlAuthBundle.php
final class BlAuthBundle extends AbstractBundle
{
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
$container->services()
->set(PdoPgSaveAuthToken::class)->autowire()->autoconfigure()
->alias(SaveAuthToken::class, PdoPgSaveAuthToken::class);
}
}
SaveAuthToken is the domain interface. PdoPgSaveAuthToken is the PDO implementation.
The bundle connects them.
๐ค Retrospective: back in 2016, when I explored MonoRepos with the Mars Rover series, one thing I was wondering about was whether Symfony's autowiring would play nicely with bundles living in local path-repository packages.
It does ๐. Composer symlinks the package into
vendor/, Symfony finds the bundle through the normal discovery mechanism, and autowiring resolves across package boundaries without any extra configuration.
The test packages serve a dual purpose. They contain the package's own unit
tests, which PHPUnit discovers via the packages-spec suite in apps/qa.
They also expose builders and factories that other packages and applications
can import as test dependencies.
bl/auth-tests for example provides AccountFixture, a static factory that
assembles a valid Account with randomised defaults and optional overrides:
// packages/bl-auth-tests/src/Fixtures/AccountFixture.php
final readonly class AccountFixture
{
public static function make(
?AccountId $accountId = null,
?Username $username = null,
?PasswordHash $passwordHash = null,
): Account {
return new Account(
accountId: $accountId ?? AccountIdFixture::make(),
username: $username ?? UsernameFixture::make(),
passwordHash: $passwordHash ?? PasswordHashFixture::make(),
);
}
}
bl/game-tests follows the same pattern, providing PlayerFixture and its value-object
companions. A unit test in the QA application can pull fixtures from both packages
without any setup duplication:
// apps/qa/tests/Qalin/Spec/Application/Action/SignUpNewPlayerHandlerTest.php
use Bl\Auth\Tests\Fixtures\Account\PasswordPlainFixture;
use Bl\Auth\Tests\Fixtures\Account\UsernameFixture;
use Bl\Game\Tests\Fixtures\PlayerFixture;
public function test_it_signs_up_a_new_player_for_a_given_username_and_password(): void
{
$username = UsernameFixture::makeString();
$passwordPlain = PasswordPlainFixture::makeString();
$expectedPlayer = PlayerFixture::make();
// ...
}
One Place for Quality
Code quality is enforced from a single place: the QA application.
apps/qa/composer.json lists all packages in require and all tools in require-dev:
{
"name": "bl/qa",
"type": "project",
"repositories": [
{"type": "path", "url": "../../packages/*"}
],
"require": {
"php": ">=8.5",
"ext-curl": "*",
"ext-pdo_pgsql": "*",
"bl/auth": "*@dev",
"bl/auth-bundle": "*@dev",
"bl/auth-pdopg": "*@dev",
"bl/exception": "*@dev",
"bl/exception-bundle": "*@dev",
"bl/game": "*@dev",
"bl/game-bundle": "*@dev",
"bl/game-pdopg": "*@dev",
"phpspec/prophecy-phpunit": "^2.5",
"symfony/console": "^8.0.4",
"symfony/framework-bundle": "^8.0.5",
"symfony/monolog-bundle": "^4.0.1",
"symfony/property-access": "^8.0.4",
"symfony/property-info": "^8.0.5",
"symfony/routing": "^8.0.4",
"symfony/runtime": "^8.0.1",
"symfony/serializer": "^8.0.5",
"symfony/twig-bundle": "^8.0.4",
"symfony/uid": "^8.0.4",
"symfony/yaml": "^8.0.1"
},
"require-dev": {
"bl/auth-tests": "*@dev",
"bl/game-tests": "*@dev",
"friendsofphp/php-cs-fixer": "^3.94.1",
"jangregor/phpstan-prophecy": "^2.3",
"phpstan/phpstan": "^2.1.39",
"phpunit/phpunit": "^13",
"rector/rector": "^2.3.7",
"rector/swiss-knife": "^2.3.5",
"symfony/dotenv": "^8.0",
"symfony/http-client": "^8.0.5",
"symfony/maker-bundle": "^1.66"
},
"config": {
"allow-plugins": {
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
}
}
Each tool is configured to scan everything: all apps (QA, monolith) and all packages.
PHPStan analyses them all at once:
# apps/qa/phpstan.neon.dist
includes:
- phpstan-baseline.neon
- vendor/jangregor/phpstan-prophecy/extension.neon
parameters:
level: 10
paths:
- src/
- tests/
- ../monolith/src/
- ../../packages/
excludePaths:
- templates/maker/
bootstrapFiles:
- vendor/autoload.php
- ../monolith/vendor/autoload.php
# legacy code is in phpincludes/, not src/ - baseline is empty
PHP CS Fixer scans the same three locations, but unlike PHPStan it reaches
into phpincludes/. Two legacy files that break the indentation rule are
excluded, and declare_strict_types is disabled: CS Fixer would apply it
blindly to every file, including the legacy ones where it causes fatal errors:
// apps/qa/.php-cs-fixer.dist.php
$finder = (new PhpCsFixer\Finder())
->in([
__DIR__,
__DIR__.'/../monolith',
__DIR__.'/../../packages',
])
->exclude(['var'])
->notPath([
'phpincludes/bisous.php',
'phpincludes/cerveau.php',
])
;
return (new PhpCsFixer\Config())
->setRules([
// ...
'declare_strict_types' => false, // would break legacy phpincludes/
// ...
])
// ...
;
Rector covers the same paths and applies the TYPE_DECLARATION set, which
includes adding declare(strict_types=1). Unlike CS Fixer, Rector analyses
each file before modifying it and only applies the transformation where it is
safe to do so; legacy files in phpincludes/ that would break are left alone:
// apps/qa/rector.php
return RectorConfig::configure()
->withPaths([
__DIR__,
__DIR__.'/../monolith',
__DIR__.'/../../packages',
])
->withSkip([
__DIR__.'/../monolith/vendor',
__DIR__.'/../../packages/*/vendor',
// ...
])
->withSets([
SetList::PHP_85,
SetList::TYPE_DECLARATION, // adds declare(strict_types=1) where safe
// ...
]);
PHPUnit organises tests into named suites covering all packages and both apps:
<!-- apps/qa/phpunit.xml.dist -->
<testsuites>
<testsuite name="packages-spec">
<directory>../../packages</directory>
</testsuite>
<testsuite name="qalin-spec">
<directory>tests/spec</directory>
</testsuite>
<testsuite name="qalin-integration">
<directory>tests/integration</directory>
</testsuite>
<testsuite name="monolith-smoke">
<directory>tests/monolith/smoke</directory>
</testsuite>
<testsuite name="monolith-end-to-end">
<directory>tests/monolith/end-to-end</directory>
</testsuite>
</testsuites>
Running make phpunit from the QA application executes every package spec,
every Qalin test, and every monolith integration test in one command.
We can select a specific subset of tests, using PHPUnit's options:
make phpunit arg='--testsuite qalin-spec,qalin-integration --filter Handler'
Docker Integration
Each application has its own compose.yaml. The QA application mounts the
packages directory and the monolith source as Docker volumes:
# apps/qa/compose.yaml (excerpt)
services:
app:
volumes:
- ../../packages:/packages
- ../monolith:/apps/monolith
- bisouland-monolith_vendor:/apps/monolith/vendor
networks:
- bisouland-monolith_default
ports:
- "43010:8080"
Mounting the monolith's vendor from the monolith's own named volume
avoids duplicating the dependency installation.
The monolith must be started before the QA application so the named volume exists.
The root apps-init target enforces this order: it runs the monolith's app-init
before QA's, so the volume is always present by the time QA's containers start.
The QA application joins the monolith's Docker network (bisouland-monolith_default),
which allows the integration and end-to-end test suites to send real HTTP requests
to the monolith container using its internal hostname.
| Port | Service |
|---|---|
| 43000 | Monolith (web) |
| 43001 | Monolith (DB) |
| 43010 | Qalin (web) |
Port assignments follow a convention: the
43prefix is inspired by the game itself ("love" has 4 letters, "you" has 3), the next two digits identify the application (00for monolith,01for QA), and the last digit identifies the service (0for the web server,1for the database).
Root Makefile
A root Makefile ties everything together with two targets:
# Makefile
apps-init:
@$(MAKE) -C apps/monolith app-init
@$(MAKE) -C apps/qa app-init
apps-qa:
@$(MAKE) -C apps/qa app-qa
make apps-init bootstraps both applications in the correct order.
make apps-qa runs the full quality pipeline from the QA application,
which in turn runs code style checks, static analysis, refactoring validation,
and all test suites:
# apps/qa/Makefile (excerpt)
app-qa: composer-dump cs-check phpstan rector-check phpunit
One command covers the entire codebase. No CI configuration file is required: whatever runs locally is what would run in any future CI environment.
Tradeoffs
A monorepo is not without cost.
Coupling risk: packages sharing a repository make it easy to skip proper
interfaces. A shortcut that imports an internal implementation directly rather
than going through the declared interface is one file away instead of one
repository away. The package graph above enforces discipline by design,
but nothing in the tooling prevents a careless use statement.
No granular access control: a contributor with read access to the repository can see every application and every package. For BisouLand this is not a concern, but it would matter in a team where different members should see different parts of the system.
Synchronised upgrades: upgrading a shared dependency or the PHP version requires every package and application to be updated at once. There is no gradual rollout across repositories; the whole monorepo moves together. For a small project this is an advantage as much as a cost, but in a large codebase with many packages it can turn a routine upgrade into a significant coordinated effort.
Repository growth: every application, package, vendor directory (as
bind-mount or volume), and test fixture lives in the same history. git clone
and git log grow with the full project. For a project of this size the
impact is negligible; at the scale of a large organisation it is not.
For BisouLand, a single-team project where all packages are internal, none of these tradeoffs are blocking. They are worth naming so the choice remains a deliberate one.
Conclusion
BisouLand started as a single PHP file importing other PHP files. After ten iterations it has two applications and ten packages.
A monorepo keeps them together without any tooling beyond what Composer already provides:
- Path repositories resolve local packages by directory rather than Packagist, with symlinks so edits are picked up immediately
*@devconstraints opt out of version pinning for in-repository packages- Cross-cutting tools run from one place, configured once, covering everything
- Docker volumes share package sources and compiled assets across containers
- Root Makefile gives a single entry point for bootstrapping and quality checks
No monorepo-specific tooling was needed. No version synchronisation scripts.
No split tooling to publish packages separately. Everything lives in one checkout,
one git log, one make apps-qa.
๐ค Retrospective: this approach works well as long as all packages remain internal to the project. The moment one needs to be published to Packagist for external consumption, path repositories and
*@devconstraints are no longer sufficient. Tools such assymplify/monorepo-builderor GitHub Actions with a split workflow automate publishing each package to its own repository. For BisouLand, that day has not come.
Want to learn more?
- browse BisouLand source code on Github
- Managing monolithic repositories with Composer's path repository by Samuel Roze,
the article that first described this pattern: one repository, multiple
composer.jsonfiles, path repositories linking them - composer-monorepo-plugin by Benjamin Eberlei,
a Composer plugin that auto-discovers all
composer.jsonfiles in subdirectories and wires their dependencies, removing the need to declare path repositories by hand - pmu by Antoine Bluchet,
a CLI tool for managing dependencies across all packages in a monorepo at once:
pmu require symfony/uidadds the constraint to everycomposer.jsonthat needs it in a single command