Creating a new library

July 14, 2026 · View on GitHub

There is no monorepo new command — a fresh package is just a handful of files plus the same mirror plumbing every package gets. Scaffold the files, create the (empty) mirror repo, then let bin/monorepo absorb wire up the mirror: with packages/<name> already present it skips the import and only does the mirror-side work (writes mirror.yml, banners the README, creates the lockdown ruleset, strips any hoisted QA tooling).

Throughout, <name> is the package slug (e.g. cache) and <Ns> its studly-cased namespace segment (e.g. Cache).

1. Scaffold the package

mkdir -p packages/<name>/src/<Ns> packages/<name>/tests

Create these files. They mirror an existing small package (packages/span is a good reference) — note what the monorepo hoists and therefore must not appear here: no pint.json, and no Pint/PHPStan/Rector/PHPUnit entries in require-dev (the root toolchain supplies them; see check/test in bin/monorepo).

composer.jsonname must be utopia-php/<name>, declare a license, and never a version (versions come from tags). Depend on siblings via Packagist constraints, never path repositories.

{
    "name": "utopia-php/<name>",
    "description": "<one line>",
    "type": "library",
    "license": "MIT",
    "autoload": {
        "psr-4": { "Utopia\\<Ns>\\": "src/<Ns>/" }
    },
    "autoload-dev": {
        "psr-4": { "Utopia\\<Ns>\\Tests\\": "tests/" }
    },
    "require": {
        "php": ">=8.4"
    },
    "scripts": {
        "test": "phpunit"
    }
}

phpunit.xml, rector.php, .gitignore — copy from packages/span verbatim (the .gitignore ignores /vendor/, composer.lock, and the PHPUnit caches). A phpstan.neon is optional: check analyses every package at the monorepo's level-5 floor (the root phpstan.neon), so add one only to raise the level or add package-specific settings — it overrides the baseline when present.

README.md — start with an # Utopia <Ns> H1 and an Installation / Quick Start section. absorb inserts the read-only-mirror banner under the H1 in step 3, so leave room for it.

CHANGELOG.md# Changelog plus a ## <first version> entry. Never edit it to "release"; releases are tags.

Then write your code under src/<Ns>/ and tests under tests/.

2. Create the empty mirror repo

The split push (step 5) targets github.com/utopia-php/<name>, and absorb's ruleset call needs the repo to exist:

gh repo create utopia-php/<name> --public -d "<one line>"

Leave it empty — CI populates it on the first split.

3. Wire up the mirror

bin/monorepo absorb <name>

Because packages/<name> exists it skips the import and just: strips hoisted QA tooling (a no-op on a clean scaffold), writes packages/<name>/.github/workflows/mirror.yml (closes PRs opened on the mirror with a redirect here), adds the mirror banner to the README, and creates the mirror ruleset (PR-only, no force-push, split app bypassed). Idempotent — re-run freely. If gh can't reach the repo the ruleset step prints a manual fallback.

The mirror needs no CI workflow of its own: the monorepo's own test matrix gates every package on each PR, and mirror.yml is the only workflow a package carries.

4. Verify locally

bin/monorepo check <name> --fix   # pint + phpstan + rector
bin/monorepo test <name>          # composer test (unit tier)
bin/monorepo validate             # composer.json conventions + graph freshness
bin/monorepo graph                # regenerate the README dependency graph

validate fails if the dependency graph is stale, so run graph whenever the package depends on a sibling. Honour the test contract (see the README's Testing section): unit tier in composer test on a bare host; any services tier in composer test:e2e against a committed docker-compose.yml.

5. Commit, push, release

Commit the package and push to main. Unlike an imported package, a new one has no git-subtree-* annotation; bin/monorepo split handles that by synthesizing the package's history from the repository root (its first commit lands parentless), so no manual seeding is needed. The Split workflow pushes that history to the empty mirror, creating its main — confirm the run is green.

bin/monorepo split <name> --dry-run   # optional: preview the split head locally

Then cut the first release:

bin/monorepo release <name> <version>   # --dry-run to preview notes first

This tags the monorepo <name>/<version> and pushes it; CI mirrors the tag, Packagist picks it up, and a GitHub release is published on the mirror. See distribution.md for the pipeline details.