Skip to main content

RFC-011: Absorb the OLCS PHP Libraries into the Mono-repository

Summary

Bring the six OLCS PHP libraries whose only consumer is vol-app — olcs-xmltools, olcs-logging, olcs-utils, olcs-transfer, olcs-common, olcs-auth — into this repository. Five live as in-tree Composer packages under lib/<name>, consumed by the apps through relative path repositories; olcs-xmltools (API-only) is fully inlined into app/api/module/XmlTools. Full git history is preserved via subtree merges. The standalone GitHub repositories are archived and the Packagist packages marked abandoned once absorbed.

This RFC is published retrospectively: the absorption is complete on main. It records the decisions taken and why.

Problem

RFC-001 merged the three applications into this mono-repository but left the shared libraries as external Composer packages on Packagist. Those libraries have no consumer other than vol-app, yet being external imposed a continuous tax:

  • Cross-cutting changes were multi-repo PR trains. A change touching a library and the apps meant: library PR → review → tag/release → per-app constraint-bump PRs → re-review. Slow feedback, easy to mis-sequence, and rollback required coordinated version pinning.
  • Local development was error-prone. Testing a library change locally meant manually copying sibling repo checkouts into each app's vendor/olcs/* directory (or maintaining compose-custom.yaml mount overrides) — an easy-to-forget, easy-to-get-wrong workflow that regularly cost debugging time.
  • Duplicated overhead per repository. Six sets of GitHub Actions workflows, Dependabot configs, branch protections and access control, for code that ships only inside vol-app.
  • Version drift. Library main could sit ahead of what the apps' lockfiles pinned for long periods, so "what is actually deployed" required cross-referencing several repositories.
  • The libraries are nominally open source on Packagist, but there are no external consumers to serve.

Proposal

Absorb all six libraries into vol-app, leaf-first (dependency order): olcs-xmltools (pilot) → olcs-loggingolcs-utilsolcs-transferolcs-commonolcs-auth. One PR per library.

Out of scope: php-govuk-account (genuinely shared with other DVSA services) and olcs-etl (on its own replacement path via Doctrine migrations).

Placement: in-tree Composer packages under lib/, not full inlining

Five libraries keep their own composer.json (own dependencies, own require-dev, own pinned QA toolchain) at lib/<name>, and each consuming app references them via a relative path repository with symlink: true:

{ "type": "path", "url": "../../lib/olcs-logging", "options": { "symlink": true } }

Why keep them as packages rather than merging their source into the apps:

  • Independently testable. Each library runs its own composer all (tests + phpcs + psalm + phpstan) in CI, with its own tool versions. This decouples upgrades: a library can stay on an older PHPUnit while the apps move ahead, and vice versa — each migrates on its own schedule.
  • Dependency truth is preserved. Each library still declares its own runtime dependencies, so the apps' dependency trees remain honest rather than absorbing a merged blob of requirements.
  • The absorption itself stays incremental. Import a library, wire it, verify, merge — without simultaneously rewriting its test suite to the apps' conventions.

olcs-xmltools is the deliberate exception: it is consumed by the API only, so it was fully inlined into app/api/module/XmlTools (src + tests under the API's own PHPUnit and static-analysis config, its four transitive Laminas dependencies promoted to direct API requirements). The pilot confirmed why full inlining doesn't scale to the bigger shared libraries: it forces an immediate test-suite migration to the host app's (newer, stricter) toolchain — acceptable for 25 tests, not for thousands.

Two mechanical consequences of path repositories, applied per library:

  • Relative paths, not absolute. Absolute path repositories broke under Docker (VOL-6099) because host paths don't exist in containers; relative paths keep the symlink valid anywhere the repo is checked out.
  • Explicit version fields. A path repository is canonical and reports dev-<branch> by default, which fails other packages' semver constraints (e.g. olcs-common requires olcs-logging: ^9.0). Each absorbed library carries its last released version (e.g. "version": "9.0.1") so inter-library constraints resolve; apps require the libs as "*".

History preservation: subtree merges, no squash

Each library was imported with git subtree add (equivalently: an -s ours merge + read-tree --prefix) without --squash, preserving full commit history — from 275 commits (xmltools) to ~13,500 (common). git log/git blame continue to work across the import boundary via the merge's second parent.

Corollary: absorption PRs must be merged with a merge commit, not squashed. A squash flattens the import into a single commit and orphans the imported history (this happened to the olcs-logging PR and was repaired by a later graph-only merge). Repository settings were changed to allow merge commits for these PRs.

Operational consequences

The running cost of in-tree path packages, all applied as part of the absorption:

  • CI. A reusable php-lib.yaml workflow runs each library's composer all; the ci.yaml orchestrator detects lib/** changes and both runs the library jobs and re-tests the dependent apps.
  • Production packaging. The packaged app artefact ships only the app directory, where vendor/olcs/* are relative symlinks pointing outside it. The package step materialises each symlink into a real copy so artefacts are self-contained.
  • Local Docker. Compose bind-mounts map each lib/<name> into the consuming containers' vendor/olcs/<name>, since the app-dir mount alone would leave the symlinks dangling.
  • Static analysis. The apps' psalm configs add ../../lib to both projectFiles (so library symbols are scanned and resolvable) and ignoreFiles (so library code is not re-analysed under each app's config).

The old workflows this replaces — vendor-copying sibling repos, compose-custom.yaml library mounts, dev-branch as version aliases — were removed as each library landed.

Endgame

With all six absorbed: the standalone GitHub repositories get a pointer README ("moved to dvsa/vol-app") and are archived (read-only — history remains available in both places), and the Packagist packages are marked abandoned.

Alternatives Considered

Status quo (keep the libraries external)

The costs in the Problem section are the argument. With a single consumer there is no benefit purchasing them.

Fully inline everything into the apps

Merging library source directly into app module trees (as done for xmltools) was rejected for the five shared libraries: olcs-common/olcs-auth are consumed by two apps (inlining duplicates them), and it forces each library's test suite onto the host app's QA toolchain in the same change — coupling absorption to major test-migration work. Collapsing lib/ into the apps remains available as a deliberate future decision; it loses per-library isolated testing and should be weighed on its own merits, not done as part of the move.

Git submodules

Still separate repositories with separate releases — none of the coordination cost goes away — plus well-known developer-experience friction (detached heads, forgotten submodule update). Rejected.

Squash imports

Importing each library as a single commit would have been simpler mechanically but destroys history and blame for ~17,000 commits of still-actively-maintained code. Rejected; the subtree/no-squash requirement is cheap by comparison.

Keep publishing to Packagist via subtree splits

Maintaining read-only split mirrors would preserve the external packages, but there are no external consumers to serve — pure ongoing overhead. Archival + abandonment is honest about the packages' status.

Follow-ups

  • Library lockfile policy. The lib/ packages have no committed composer.lock, so library CI resolves the latest versions within constraints while the apps test against their locked versions — a new upstream release can fail a library's CI for a version production isn't running (this has happened once, via a laminas-session minor). Options: commit per-library locks, pin to the apps' resolved versions, or keep "test against latest" with per-case test hardening (the current approach). To be decided as a cross-library policy.
  • compose-custom.yaml.dist no longer carries library overrides and is a candidate for slimming once local-refresh tooling no longer depends on it.

References

  • RFC-001: Mono-repository (the applications)
  • VOL-6099 — the absolute-path repository/Docker symlink failure that motivated relative paths
  • Absorption tickets: VOL-7253 (xmltools), VOL-7254 (logging), VOL-7255 (utils), VOL-7256 (transfer), VOL-7257 (common), VOL-7258 (auth), VOL-7259 (docs)