Splitting the monorepo, and the three bugs it was hiding
TetherPHP started as one repository. The framework lived in src/ inside the skeleton application, and a GitHub
Action ran splitsh-lite --prefix=src/ on every push to main, force-pushing the result into tetherphp-core.
It worked, in the sense that a package appeared on Packagist. It was also a dead end, and today it got reversed.
The problem with a generated repository
A force-push means the destination repository can never own anything. No tests, because they would be wiped on the
next merge. No CI, no history, no README — the split had silently deleted the LICENSE.md and README.md from the
core repo, because those files lived at the skeleton's root rather than inside src/.
Worse, my local checkout of the core repository was seven commits of orphaned history. The force-push had rewritten the remote out from under it months ago. Any refactoring done there would have evaporated on the next merge, and nothing in the tooling would have told me.
The framework is now the source of truth in its own repository, and the skeleton consumes it as an ordinary Composer dependency. Less clever, much harder to get wrong.
The bugs that only appear once you are a real package
Reversing the direction meant the framework had to work as an installed dependency for the first time. Three things broke immediately, and all three had been latent for months.
Path resolution. The framework resolves its own shipped assets — code generation stubs, fallback error views —
through a helper called core_dir(). It was implemented as the package root plus /src/framework:
function package_root(): string
{
return dirname(__DIR__, 3);
}
function core_dir(): string
{
return package_root() . '/src/framework';
}
Perfectly correct while the package root was the monorepo. Installed under vendor/, it resolved to
vendor/dillonsmart/src/framework, a directory that has never existed. The make:* commands could not find their
own templates.
The fix is to resolve relative to the file rather than to where you think you are:
function core_dir(): string
{
// this file lives at <package>/src/framework/Helpers/
return dirname(__DIR__);
}
A namespace with nowhere to live. make:command generated classes into the Commands\ namespace. The
skeleton's composer.json had no PSR-4 mapping for Commands\. The console registers commands by globbing the
directory and calling class_exists() on each result — which returned false, so every generated command was
silently dropped from the registry.
No error. No warning. The file was right there on disk, and tether help simply never mentioned it. The most
expensive bugs are the polite ones.
A lockfile locking nothing. The skeleton committed a composer.lock that resolved to zero packages. Since
composer create-project installs from the committed lock, a fresh install would have produced an application with
no framework in it at all, and exited successfully. Skeletons should not ship lockfiles anyway — you are pinning
strangers to whatever Tuesday you generated it on.
What actually changed
The core repository now has a src/ directory, a test suite, and room to grow a CI pipeline. The skeleton declares
a dependency like any other project. Local development against both at once uses a Composer path repository, which
symlinks the package so edits in one show up instantly in the other.
Three bugs, all of them invisible until the code had to work somewhere other than where it was written. That is the argument for consuming your own package the way your users do, as early as you can stand to.