XPSR packages
XPSR is developed in one repository and installed as separate Composer packages. Applications get only the router they use, and the framework never depends on the application skeleton.
Packages and dependencies
crystoline/xpsr (skeleton, repository root)
├── crystoline/xpsr-framework packages/framework
├── crystoline/xpsr-router-fastroute packages/router-fastroute ──► framework, nikic/fast-route
├── nyholm/psr7 PSR-7/17 implementation used by config/factory.php
├── twig/twig views (the web example); API-only apps can remove it
├── (dev) filp/whoops detailed debug error pages
├── (suggested) crystoline/xpsr-router-sunrise packages/router-sunrise ──► framework, sunrise/http-router
├── (suggested) crystoline/xpsr-cache packages/cache ──► framework, symfony/cache
├── (suggested) crystoline/xpsr-http-client packages/http-client ──► framework, symfony/http-client
├── (suggested) crystoline/xpsr-database packages/database ──► framework, doctrine/dbal
├── (suggested) crystoline/xpsr-orm-doctrine packages/orm-doctrine ──► framework, database, doctrine/orm
├── (suggested) crystoline/xpsr-orm-eloquent packages/orm-eloquent ──► framework, database, illuminate/database
├── (suggested) crystoline/xpsr-queue packages/queue ──► framework, database, symfony/messenger
├── (suggested) crystoline/xpsr-mail packages/mail ──► framework, symfony/mailer
├── (suggested) crystoline/xpsr-scheduler packages/scheduler ──► framework, dragonmantank/cron-expression, symfony/lock
├── (suggested) crystoline/xpsr-storage packages/storage ──► framework, league/flysystem
├── (suggested) crystoline/xpsr-view-plates packages/view-plates ──► framework, league/plates
├── (suggested) crystoline/xpsr-view-latte packages/view-latte ──► framework, latte/latte
├── (suggested) crystoline/xpsr-view-blade packages/view-blade ──► framework, illuminate/view
├── (suggested) crystoline/xpsr-log-monolog packages/log-monolog ──► framework, monolog/monolog
├── (suggested) crystoline/xpsr-validation packages/validation ──► framework, symfony/validator
└── (suggested) crystoline/xpsr-auth-jwt packages/auth-jwt ──► framework, lcobucci/jwt| Package | Namespace | Depends on |
|---|---|---|
crystoline/xpsr-framework |
Crystoline\Xpsr\ |
PSR interfaces, Symfony Console/Process/Finder, Illuminate Config, phpdotenv, Nyholm PSR-7 server, and any PSR-17 implementation (psr/http-factory-implementation). Accepts psr/http-message 1.1 or 2.0. Suggests Twig (views) and Whoops (debug pages), but does not require them. |
crystoline/xpsr-router-fastroute |
Crystoline\Xpsr\Router\FastRoute\ |
framework, nikic/fast-route ^1.3 |
crystoline/xpsr-cache |
Crystoline\Xpsr\Cache\ |
framework, symfony/cache ^7.4, symfony/rate-limiter ^7.4, psr/cache 2 or 3, psr/simple-cache 1 to 3 (1.x keeps it installable next to the Sunrise adapter) |
crystoline/xpsr-database |
Crystoline\Xpsr\Database\ |
framework, doctrine/dbal ^4.5. |
crystoline/xpsr-orm-doctrine |
Crystoline\Xpsr\Orm\Doctrine\ |
framework, database, doctrine/orm ^3.7, doctrine/persistence ^4.1, symfony/cache ^7.4. |
crystoline/xpsr-orm-eloquent |
Crystoline\Xpsr\Orm\Eloquent\ |
framework, database, illuminate/database ^12.0, illuminate/events ^12.0. |
crystoline/xpsr-queue |
Crystoline\Xpsr\Queue\ |
framework, database, symfony/messenger, symfony/doctrine-messenger, and symfony/event-dispatcher 7.4 or 8; Redis, AMQP, and SQS through their Messenger bridges. |
crystoline/xpsr-mail |
Crystoline\Xpsr\Mail\ |
framework, symfony/mailer and symfony/mime 7.4 or 8; crystoline/xpsr-queue for queued email; providers through Symfony's mailer bridges. |
crystoline/xpsr-scheduler |
Crystoline\Xpsr\Scheduler\ |
framework, dragonmantank/cron-expression ^3.4, symfony/lock 7.4 or 8; crystoline/xpsr-queue for scheduled jobs. |
crystoline/xpsr-storage |
Crystoline\Xpsr\Storage\ |
framework, league/flysystem, league/flysystem-local, and league/flysystem-memory ^3.29; league/flysystem-async-aws-s3 for the s3 driver. |
crystoline/xpsr-view-plates |
Crystoline\Xpsr\View\Plates\ |
framework, league/plates ^3.6. |
crystoline/xpsr-view-latte |
Crystoline\Xpsr\View\Latte\ |
framework, latte/latte ^3.1.2. |
crystoline/xpsr-view-blade |
Crystoline\Xpsr\View\Blade\ |
framework, illuminate/view, illuminate/container, illuminate/events, illuminate/filesystem, and illuminate/support ^12.0. |
crystoline/xpsr-log-monolog |
Crystoline\Xpsr\Log\Monolog\ |
framework, monolog/monolog ^3.9. |
crystoline/xpsr-validation |
Crystoline\Xpsr\Validation\Symfony\ |
framework, symfony/validator ^7.4, symfony/property-access ^7.4. The contract, attributes, and mapping it uses are in the framework (Crystoline\Xpsr\Validation\). |
crystoline/xpsr-auth-jwt |
Crystoline\Xpsr\Auth\Jwt\ |
framework, lcobucci/jwt ^5.6 (ext-openssl, ext-sodium). The contracts, session sign-in, and policies are in the framework (Crystoline\Xpsr\Auth\); the token driver and database users are in crystoline/xpsr-database. |
crystoline/xpsr-http-client |
Crystoline\Xpsr\HttpClient\ |
framework, symfony/http-client ^7.4, psr/http-client ^1.0.3. |
crystoline/xpsr-router-sunrise |
Crystoline\Xpsr\Router\Sunrise\ |
framework, sunrise/http-router ^2.16, psr/http-message ^1.1 |
crystoline/xpsr |
App\ |
framework, FastRoute adapter, nyholm/psr7, twig/twig; development: filp/whoops, phpunit/phpunit |
Rules that keep the split clean:
- The framework must not reference
App\, skeleton paths, or adapter classes. It knows the official adapter names (fastroute,sunrise) and their class names only as strings inRouteServiceRegistry::ADAPTERS. If the selected adapter is missing, it reports which package to install. - Adapters depend on the framework, never on each other.
- Shared test fixtures live in the framework's tests (
Crystoline\Xpsr\Tests\Fixtures\). Fixtures that need the skeleton live in the roottests/.
Known coupling: the setup command's presets name the skeleton's route files (routes/api.php, routes/web.php), and its routing template matches the skeleton's config/routing.php. This belongs in an installer package once xpsr new exists.
Versions
master is the development line of the next minor release, currently 0.4.x-dev. When a release starts a new line, such as 0.5, change every occurrence below in one commit.
- In this repository, the root path repository assigns that version through its
versionsoption, so it does not depend on Git branch detection. - In the split repositories, each
composer.jsondeclares the branch aliasdev-master→0.4.x-dev. - Between packages, requirements use
^0.4@dev. Composer ignores stability flags in dependencies, so they also accept the stable0.4.0once it is tagged. - The released skeleton requires
^0.4:export-skeleton.php --releasewrites that range. setupderives the adapter constraint from the installed framework:^0.4for a 0.4.x release,^0.4@devfor a development install. Adapters therefore always match the framework's release line.
master moved to 0.2.x-dev with the namespace change, so that a project following 0.1.x-dev did not receive it, to 0.3.x-dev with the 0.3.0 release, and to 0.4.x-dev with the 0.4.0 release.
Working in the monorepo
composer install # links packages/* into vendor/crystoline/
composer test # framework, adapter, and skeleton suites
composer analyse && composer style
composer test:packaging # slower; needs network access
Edits under packages/ take effect immediately, because vendor/crystoline/* are symbolic links. After changing a package's composer.json, run composer update 'crystoline/*'.
Where tests go:
| Test | Location |
|---|---|
| Framework unit tests | packages/framework/tests (Crystoline\Xpsr\Tests\) |
| Adapter tests (the shared router contract) | packages/router-*/tests |
| Tests that need the skeleton's files: application boot, console, generators, setup, front controller | tests/ (Tests\) |
Installing packages separately and composer create-project |
tests/Packaging (group packaging, excluded from composer test) |
Each package has its own phpunit.xml.dist and runs its tests standalone after composer install in its directory. The packaging test does exactly that.
Exporting the skeleton
php scripts/export-skeleton.php /path/to/empty/dir
The script copies the files a new application needs, including the application test scaffold (tests/TestCase.php, tests/Application/, and a phpunit.xml from scripts/skeleton/). It writes a composer.json without the monorepo-only parts:
- the
packages/*path repository; - autoloading for package tests;
- the analysis and style tooling and their scripts.
It does not copy composer.lock; see Publishing for why the skeleton ships without one. --release=X.Y.Z writes release constraints (^X.Y) on the XPSR packages instead of ^0.2@dev.
The packaging test builds a project from this export with composer create-project, using the packages through path repositories without symbolic links. It then checks that:
- only the FastRoute adapter is installed;
setupworks;- a real
composer requireof the Sunrise adapter succeeds, including movingpsr/http-messagefrom 2.x to 1.x; - requests are served and generators work;
- the project's own tests pass;
- a
composer install --no-dev --optimize-autoloaderproduction install still serves requests.
XPSR_DEPENDENCIES=lowest composer test:packaging installs each package at the lowest versions its constraints allow.
Lowest dependency versions
The CI job lowest-dependencies runs the whole suite after composer update --prefer-lowest --prefer-stable, with deprecation notices treated as failures. This guards the minimum versions declared in each composer.json. It found that vlucas/phpdotenv 5.4, nyholm/psr7 1.8.0, filp/whoops 2.14, and symfony/var-dumper 6.4 print PHP 8.4 deprecation notices that corrupt responses and console output. The minimums are therefore ^5.6.1, ^1.8.2, and ^2.16, plus a conflict with symfony/var-dumper below 7.4. On PHP 8.5, Whoops before 2.18.4 also uses a deprecated (integer) cast, so the Whoops minimum is now ^2.18.4. The HTTP client package conflicts with symfony/polyfill-php83 below 1.30, which Symfony HttpClient would otherwise allow and which declares implicitly nullable parameters. For the same reason the validation package conflicts with symfony/translation-contracts below 3.5 and with symfony/property-info below 6.4.8 and in 7.0.
Publishing
scripts/split.sh publishes the monorepo into ten read-only repositories, and the split job in .github/workflows/ci.yml runs it after every other CI job passes:
| Repository | Content | Packagist package |
|---|---|---|
crystoline/xpsr-framework |
packages/framework, with its history (git subtree split) |
crystoline/xpsr-framework |
crystoline/xpsr-router-fastroute |
packages/router-fastroute, with its history |
crystoline/xpsr-router-fastroute |
crystoline/xpsr-router-sunrise |
packages/router-sunrise, with its history |
crystoline/xpsr-router-sunrise |
crystoline/xpsr-cache |
packages/cache, with its history |
crystoline/xpsr-cache |
crystoline/xpsr-http-client |
packages/http-client, with its history |
crystoline/xpsr-http-client |
crystoline/xpsr-database |
packages/database, with its history |
crystoline/xpsr-database |
crystoline/xpsr-orm-doctrine |
packages/orm-doctrine, with its history |
crystoline/xpsr-orm-doctrine |
crystoline/xpsr-log-monolog |
packages/log-monolog, with its history |
crystoline/xpsr-log-monolog |
crystoline/xpsr-validation |
packages/validation, with its history |
crystoline/xpsr-validation |
crystoline/xpsr-skeleton |
the output of scripts/export-skeleton.php, one sync commit per change |
crystoline/xpsr |
Each repository is named after its Composer package without the vendor prefix. The skeleton is the exception: its package is crystoline/xpsr, so that composer create-project crystoline/xpsr works, but its repository is xpsr-skeleton, because crystoline/xpsr is this monorepo. Development, issues, and pull requests happen in crystoline/xpsr. The other ten repositories are read-only mirrors.
- A push to
masterupdates each repository'smasterbranch. Packages declare the branch aliasdev-master→0.4.x-dev, so^0.4@devresolves to it. - A
v*tag pushes that tag to every repository and leaves branches alone. The skeleton's tagged commit is exported with--release, so it requires the release range (^0.4), while itsmasterkeeps^0.4@dev. - Re-running with the same commit changes nothing.
- Inputs: everything comes from the committed tree, never the working directory.
The skeleton ships without composer.lock: each new application resolves its dependencies at create-project time and commits its own lock. This replaces the original plan's "commit the skeleton's lockfile". A shipped lock would pin every new application to the versions current at release time.
Rehearsal
The split and release flow was rehearsed against local bare repositories:
- Splitting twice produced identical commits.
- A release tagged every repository.
composer create-projectfrom them (as VCS repositories) installed framework and FastRoute0.1.0, and the application's tests passed.setup --router=sunrise --install-dependenciesinstalled the Sunrise adapter0.1.0and movedpsr/http-messageto 1.1.- A
0.1.x-devproject resolved themasterbranches through the branch aliases.
To repeat it locally:
for r in xpsr-framework xpsr-router-fastroute xpsr-router-sunrise xpsr-cache xpsr-skeleton; do git init --bare /tmp/remotes/$r.git; done
scripts/split.sh /tmp/remotes master # branch sync
scripts/split.sh /tmp/remotes master v0.1.0 # release
First publication
Owner-only steps, in order:
- Repositories. Create five empty public repositories under
crystoline:xpsr-framework,xpsr-router-fastroute,xpsr-router-sunrise,xpsr-cache, andxpsr-skeleton. Describe each as a read-only mirror ofcrystoline/xpsr, and consider turning off issues and pull requests there. - Token. Create a fine-grained personal access token with access to those five repositories only, with Contents: read and write. Store it as the
SPLIT_TOKENActions secret ofcrystoline/xpsr: Settings → Secrets and variables → Actions. - First sync. Merge
framework-rebuildintomasterand push. When CI passes, thesplitjob publishes the fivemasterbranches. - Old prototype. In Packagist, delete the
v1.0.0version ofcrystoline/xpsr(the old prototype), and delete thev1.0.0tag incrystoline/xpsr. Change the package's repository URL tohttps://github.com/crystoline/xpsr-skeleton. - Packagist. Submit
crystoline/xpsr-framework,crystoline/xpsr-router-fastroute,crystoline/xpsr-router-sunrise, andcrystoline/xpsr-cachewith their repositories. Enable Packagist's GitHub integration so all five update automatically. - Release. Move the
CHANGELOG.mdentries under0.1.0, commit, then tagv0.1.0incrystoline/xpsrand push the tag. CI tags all five mirrors. - Check. In an empty directory, run
composer create-project crystoline/xpsr my-appand confirm it installs0.1.0, includingxpsr-framework0.1.0.
Adding a package
A new package can be developed and tested in packages/ before it has a mirror. Wire it into the monorepo the way packages/cache is:
composer.jsonin the package, with thedev-masterbranch alias of the current line (see Versions), its ownphpunit.xml.dist,.gitignore,LICENSE, andREADME.md;- the root
composer.json:require-dev, the path repository'sversions, andautoload-devfor its tests; phpunit.xml(a test suite and asourceexclusion),phpstan.neon(itssrcandtests), and the packages list intests/Packaging/PackagesTest.php;MONOREPO_DEV_PACKAGESinscripts/export-skeleton.php, so the skeleton does not require it.
scripts/split.sh publishes only the packages in its PUBLISHED list, so an unpublished package does not break the split job. To publish one:
- Repository. Create an empty public repository
crystoline/xpsr-<name>and describe it as a read-only mirror. - Token. Give the
SPLIT_TOKENtoken Contents: read and write access to the new repository too. - Split. Add
<name>toPUBLISHEDinscripts/split.sh, add the package to the publishing table above and to the skeleton'ssuggestif applications should discover it, commit, and push. CI publishes itsmaster. - Packagist. Submit
https://github.com/crystoline/xpsr-<name>. The next release tag then tags it with the other packages.