A monorepo for four packages and one application

The shared code lived in four Composer packages, each in its own repository, each with its own version. A change touching three of them meant three pull requests, three releases in dependency order, and three version constraint bumps in the application — an afternoon of coordination for what was one logical change.

The symptom

one change spanning three packages:

  1  PR to contracts, review, merge, tag v2.4.0
  2  PR to http, bump contracts, review, merge, tag v3.1.0
  3  PR to queue, bump both, review, merge, tag
  4  PR to the application, bump all three
  5  discover step 1 was wrong; go to 1

four reviews, three tags, and no single place where the
whole change can be read.

The reviewability is the real cost rather than the time. A change split across four pull requests cannot be reviewed as one thing, and the reviewer of step two has to take step one on trust.

Why it happens

A package per repository is the default because that is how Composer distributes, and the coupling between the packages is not visible until a change spans them. By the time it is, there are four repositories with four histories.

The fix

The layout

shop/
  composer.json      the application
  app/
  packages/
    contracts/       composer.json + src/ → turkerdev/contracts
    http/            → turkerdev/http
    queue/
    testing/
  .github/workflows/

one repository, one history, one pull request per change.
{
  "repositories": [{ "type": "path", "url": "packages/*" }],
  "require": {
    "turkerdev/contracts": "*",
    "turkerdev/http": "*",
    "turkerdev/queue": "*"
  }
}

A path repository with a wildcard symlinks every package into vendor, so a change to a package is visible to the application immediately with no version, no tag and no install. That is the development story and it is most of the benefit.

The symlink breaks in a Docker build because the target is outside the build context, which is why the production build resolves from the split repositories instead — "symlink": false forces a copy and is the other option, at the cost of needing a reinstall after every package change.

Splitting to read-only repositories

name: Split packages
on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  split:
    strategy:
      matrix: { package: [contracts, http, queue, testing] }
    steps:
      - uses: actions/checkout@v3
        with: { fetch-depth: 0 }
      - uses: symplify/monorepo-split-github-action@v2
        with:
          package-directory: packages/${{ matrix.package }}
          repository-name: turkerdev/${{ matrix.package }}
          branch: main
          tag: ${{ github.ref_name }}

The split repositories are read-only and exist so that external consumers can require the packages normally. A push to them is discarded on the next split, which needs saying in the README of each one — a contributor opening a pull request against a split repository is a wasted afternoon for both parties.

Every package is tagged with the same version, which is the trade: turkerdev/testing gets a major bump because turkerdev/http had a breaking change. That is inaccurate and it is much simpler than per-package versioning, which requires deciding what changed in each on every release.

The CI matrix, and what runs when

# which packages changed, plus their dependents
changed=$(git diff --name-only "$BASE"..."$HEAD" 
  | awk -F/ '/^packages//{print $2}' | sort -u)

affected=$(./bin/dependents $changed | sort -u)
[ -n "$affected" ] && affected="$affected app"

echo "matrix=$(jq -cn --args '.positional' $affected)" 
  >> "$GITHUB_OUTPUT"

The transitive closure is what makes this correct rather than merely faster — a change to contracts must test everything that depends on it, and a naive path filter tests only the package that changed. Getting that wrong produces a green build that skipped the thing that broke.

before  4 repositories, 4 pipelines, triggered manually in
        order. 22 minutes, spread over an afternoon.

after   one push, one pipeline:
          packages/testing    1m 40s
          packages/contracts  8m 20s (everything)
          app/ only           4m 10s
        median across a month: 3m 50s.

Enforcing the dependency direction

# a package must not depend on the application, and
# contracts must depend on nothing
layers:
  - { name: Contracts, collectors: [{ type: directory,
      value: packages/contracts/.* }] }
  - { name: Http, collectors: [{ type: directory,
      value: packages/http/.* }] }
  - { name: App, collectors: [{ type: directory,
      value: app/.* }] }

ruleset:
  Contracts: ~
  Http:      [Contracts]
  App:       [Contracts, Http, Queue]

A monorepo removes the physical barrier that separate repositories provided, so a package can now use AppSomething and nothing stops it at install time. The dependency check is what replaces the barrier and it is stricter, because it fails at the moment the coupling is introduced rather than at the next release.

What was lost

  independent versioning  gone. one tag, four packages.
  independent release     gone. a package cannot ship
                          without the others.
  per-package issues      consolidated, which is mostly an
                          improvement.
  a small clone           packages/ is in every checkout,
                          including CI. 40 MB.

and the surprise: external consumers see no difference.

External consumers being unaffected is what makes this a repository-layout decision rather than a distribution one, and it is worth checking before starting — a package with many outside users and a need for independent versioning is the case where this does not apply.

Verifying it worked

$ composer require turkerdev/http:^3.1 --dry-run
# resolves from Packagist, from the split repository,
# exactly as before

$ vendor/bin/deptrac analyse
 Violations: 0
 Uncovered:  0

$ git log --oneline -1 --stat
a41f2b8 fix: preserve the correlation id across retries
 packages/contracts/src/Retryable.php  |  4 +-
 packages/http/src/Client.php          | 11 ++--
 packages/queue/src/Middleware.php     |  8 ++-
 app/Providers/AppServiceProvider.php  |  2 +-

# one commit. four packages. one review.

The four-file commit is the outcome and it is the thing that could not previously exist. The split repositories resolving normally is the assertion that consumers are unaffected, and it is worth testing with an actual composer require rather than assuming.

What this costs

A build that must know about package boundaries, expressed in a shell script computing an affected set. That script is now infrastructure: it can be wrong about what to skip, and being wrong produces a green build that did not test the change. A weekly full run on the default branch is the backstop and is what catches a mistake in the closure logic.

The shared version number is the other honest cost. Tagging turkerdev/testing as 4.0.0 because an unrelated package had a breaking change is a lie told to every consumer of the changelog, and the alternative — per-package versioning in a monorepo — requires tooling that decides what changed in each package on every release. Four packages with one internal consumer made the simple answer correct; a dozen with external users would not.