Multiple applications
Everything the package ships lives under StaticPHP\ and is resolved by PSR-4. Application
code is not. A module name is its own namespace root - Pasta\Controllers\Quality - and it
means whatever the application that served the request says it means.
That is the framework’s least obvious decision. This page is the reason for it, and what it costs.
One repository, several applications
Section titled “One repository, several applications”This page is about the resolution mechanism - why the tree can look like this at all. If
you are working from the skeleton, the tree itself is built for you by
staticphp app add, and the
asset pipeline discovers each application
without registration.
The layout this exists for:
project/├── composer.json├── vendor/│ └── 4apps/staticphp-core/└── src/ ├── Application/ the staff intranet │ ├── Config/ │ │ ├── Config.php │ │ └── Routing.php │ ├── Modules/ │ │ └── Pasta/ │ │ ├── Controllers/Quality.php Pasta\Controllers\Quality │ │ └── Models/Batch.php Pasta\Models\Batch │ └── Public/index.php ├── Portal/ the customer portal │ ├── Config/ │ ├── Modules/ │ │ └── Pasta/ │ │ ├── Controllers/Quality.php Pasta\Controllers\Quality again │ │ └── Models/Batch.php Pasta\Models\Batch again │ └── Public/index.php └── Shared/ └── Modules/ └── Billing/Models/Invoice.php Billing\Models\InvoiceTwo applications, one composer install, one vendor/. Both have a Pasta module, because
both are about pasta: the intranet books quality checks against a batch, the portal shows a
customer what their batch tested at. Different code, different data, same obvious name for
it.
The two front controllers are the same three lines and differ only in where they sit:
<?php
require dirname(__DIR__, 3) . '/vendor/autoload.php';
define('PUBLIC_PATH', __DIR__);
require StaticPHP\Core\Bootstrap::FILE;Every path constant derives from PUBLIC_PATH, so that one define() is the whole of
choosing an application:
| Constant | Served from src/Application/Public |
Served from src/Portal/Public |
|---|---|---|
PUBLIC_PATH |
project/src/Application/Public |
project/src/Portal/Public |
APP_PATH |
project/src/Application |
project/src/Portal |
APP_MODULES_PATH |
project/src/Application/Modules |
project/src/Portal/Modules |
BASE_PATH |
project/src |
project/src |
SP_PATH and VENDOR_PATH come out the same for both - they describe the install, not the
application. The full derivation is in
the front controller.
What composer cannot say
Section titled “What composer cannot say”PSR-4 is a map from a namespace prefix to a directory, written into composer.json and
dumped once at install time. It is static, and it is global to the process.
The layout above needs Pasta\ to mean src/Application/Modules/Pasta in one request and
src/Portal/Modules/Pasta in the next, in the same install, with no third party knowing
which. There is nowhere to write that down: a prefix gets one directory list, and the
loader has no idea which front controller is running.
So the framework registers a second autoloader of its own, in
src/Core/Helpers/Autoload.php, and it is a callback rather than a map - it answers the
question at the moment the class is asked for, when APP_MODULES_PATH is already known.
The same class name, two files
Section titled “The same class name, two files”Both applications above define Pasta\Models\Batch. Each front controller was asked for
the same url, and each got its own:
$ php src/Application/Public/index.php /pasta/quality/indexAPP_MODULES_PATH src/Application/ModulesPasta\Models\Batch src/Application/Modules/Pasta/Models/Batch.phpBatch::label() staff intranet
$ php src/Portal/Public/index.php /pasta/quality/indexAPP_MODULES_PATH src/Portal/ModulesPasta\Models\Batch src/Portal/Modules/Pasta/Models/Batch.phpBatch::label() customer portalNothing distinguishes the two runs except which Public/index.php was executed. The class
name is identical, and it resolves to a different file because APP_MODULES_PATH is
different.
How the callback resolves a name
Section titled “How the callback resolves a name”Autoload.php registers one spl_autoload_register() callback, and it does four things:
- Rejects anything that is not an identifier. The class name is split on
\, and every component must match/^[a-zA-Z_][a-zA-Z0-9_]*$/. Class names reach this point from url segments by way of the router, so a name containing..would otherwise become an include of an arbitrary file. A component that fails the test makes the callback return without touching the filesystem. - Tries two roots, in order:
APP_MODULES_PATH, thenAPP_PATH. The candidate is<root>/<class path>.php, soPasta\Models\BatchisAPP_MODULES_PATH/Pasta/Models/Batch.phpand a class at the application root, likeModels\AppConfig, isAPP_PATH/Models/AppConfig.php. - Confirms the resolved file is really under the root.
realpath()on both, thenstr_starts_with(), so a symlink inside the tree that points somewhere else does not get included. - Includes it and returns. No exception, no error - a name it cannot resolve is left for whoever comes next, which is what an autoloader is supposed to do.
It is registered without $prepend, so it runs after composer’s. Framework and vendor
classes are resolved by composer long before the callback is reached, and StaticPHP\
never touches it.
The two roots used to be five. The chain ended with the framework’s own directories and
BASE_PATH so that an application could shadow a framework class by filename; nothing used
it, and every extra root cost a failed is_file() on every miss. The code puts a failed
stat at roughly 50 times the cost of a successful one, because PHP caches resolved paths
and never caches failures.
Sharing a module between applications
Section titled “Sharing a module between applications”The autoloader only ever looks inside the current application. Code that two applications
share is reached deliberately, through
Load, by naming a registered
module path:
<?php
$config['module_paths'] = [ 'shared' => BASE_PATH . '/Shared/Modules',];The value is the directory that holds modules, not its parent, which is why the entry
above ends in /Modules. BASE_PATH is the directory holding the applications - the same
value whichever one is serving - so it is the natural place to hang a shared tree.
staticphp is reserved, resolves to the framework’s own modules, and cannot be
overridden or registered. Everything else is a key of $config['module_paths']:
<?php
Load::model(['Invoice'], 'Billing', 'shared');Load::config(['Db'], 'Utils', 'staticphp');The three ways that can go, in one request:
$ php src/Portal/Public/index.php /pasta/quality/sharedautoloaded Error: Class "Billing\Models\Invoice" not foundLoad::model() src/Shared/Modules/Billing/Models/Invoice.phpunregistered name InvalidArgumentException: Unknown module path "archive". Add it to $config['module_paths'].Which is the summary of the whole design: the shared tree is invisible to the autoloader,
Load reaches it because the configuration named it, and a name that was never registered
is an InvalidArgumentException rather than a path that quietly does not exist.
Keep module names distinct across the trees you share. The loaders use require rather
than require_once, so two modules called Billing in two registered paths are two
different files declaring the same class, and loading both in one request is a fatal
Cannot redeclare class Billing\Models\Invoice naming the file that got there first.
The cli picks an application the same way
Section titled “The cli picks an application the same way”staticphp migrate and staticphp i18n take a --project=NAME option, defaulting to
Application. Both are entries in
StaticPHP\Core\Cli::commands().
Both resolve the name as <repository root>/src/<NAME>/Public, check that the
directory exists, and define PUBLIC_PATH as that before requiring Bootstrap::AUTOLOAD -
the same first move a front controller makes, so the rest of the derivation follows:
staticphp migrate status --project=PortalA name with no Public directory exits 2 with error: project "Portal" not found at ....
That is also why the applications sit in src/<Name>/ with a Public/ directory: the
front controller can live anywhere, but the cli looks there. See
where the settings come from.
Each application has its own Config/ directory, so each has its own database connections,
its own routing and its own migrations directory.
Why the application tree stays out of composer’s map
Section titled “Why the application tree stays out of composer’s map”Because the map is global, and a global map has one answer per class name.
Hand composer the application tree - a classmap rule over src/ in the root
composer.json - and both copies of Pasta\Models\Batch land in the same map. Composer
says so, and then the request says nothing at all:
$ composer dump-autoloadGenerating autoload filesWarning: Ambiguous class resolution, "Pasta\Models\Batch" was found in both "/srv/pasta/src/Portal/Modules/Pasta/Models/Batch.php" and "/srv/pasta/src/Application/Modules/Pasta/Models/Batch.php", the first will be used.Warning: Ambiguous class resolution, "Pasta\Controllers\Quality" was found in both "/srv/pasta/src/Portal/Modules/Pasta/Controllers/Quality.php" and "/srv/pasta/src/Application/Modules/Pasta/Controllers/Quality.php", the first will be used.To resolve ambiguity in classes not under your control you can ignore them by path using exclude-from-classmapGenerated autoload files
$ php src/Application/Public/index.php /pasta/quality/indexAPP_MODULES_PATH src/Application/ModulesPasta\Models\Batch src/Portal/Modules/Pasta/Models/Batch.phpBatch::label() customer portalComposer warns, twice, which is more than nothing. What it cannot do is warn at the moment
it matters: composer’s loader is registered before the framework’s, so the staff intranet -
serving its own url, out of its own Modules directory - was handed the customer portal’s
model, and the request itself proceeded in silence.
The warning is also less reliable than it looks. Composer filters ambiguity reports by path,
dropping any file matching /(test|fixture|example|stub)s?/i, so a tree whose directory
names happen to match gets the collision without the warning.
Note what did not cause this. There is no -o in that command. The trigger is the
classmap rule itself, and composer rebuilds the map from it on every composer install,
composer update and composer dump-autoload, optimised or not. Optimising what composer
already owns - vendor/ and the package’s own StaticPHP\ prefix - is fine and changes
nothing here. It is the application tree that has to stay out of composer’s map when one
repository serves several applications.
What it costs
Section titled “What it costs”- No classmap optimisation for application classes. Every application class costs a
stat, and a class under
APP_PATHrather thanAPP_MODULES_PATHcosts two. - No editor or static analyser knows the mapping from
composer.jsonalone. Tools that read PSR-4 will not findPasta\Controllers\Quality. - Class names are only unique within an application.
Pasta\Models\Batchis one class in this request and a different one in the next. Anything that has to hold both at once - a global classmap, an index over the whole repository - has to pick one.
If you only ever serve one application from a repository, none of this is visible: the
single application is src/Application, its modules are under
src/Application/Modules, and the callback finds them there. The design costs that
installation nothing and buys the other one the ability to exist at all.