Loรฏc Faugeron Technical Blog

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:

  1. ๐Ÿ‹ got it to run in a local container
  2. ๐Ÿ’จ written Smoke Tests
  3. ๐ŸŽฏ written End to End Tests
  4. ๐Ÿงน created and applied Coding Standards
  5. โ›ƒ migrated to PDO
  6. ๐Ÿ˜ upgraded PHP 5 to PHP 8
  7. ๐Ÿก applied automated refactorings using Rector
  8. ๐Ÿ˜ migrated from MySQL to PostgreSQL
  9. ๐Ÿ”’ fixed an XSS Vulnerability
  10. ๐ŸŽญ 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:

  1. Edit package A, commit, push, tag
  2. Edit package B, bump the constraint to pick up the tag, commit, push
  3. 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 43 prefix is inspired by the game itself ("love" has 4 letters, "you" has 3), the next two digits identify the application (00 for monolith, 01 for QA), and the last digit identifies the service (0 for the web server, 1 for 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:

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 *@dev constraints are no longer sufficient. Tools such as symplify/monorepo-builder or 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?