pilotphp / container
Compiled dependency injection container for the PilotPHP Agent-First framework.
Requires
- php: ^8.5
- pilotphp/contracts: ^0.1.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
README
Compiled dependency injection container for the PilotPHP Agent-First framework.
Packages describe their services as typed declarations. A build-time
compiler turns those declarations into an ordinary PHP class: one private
property per process-scoped service, one method per service that builds it
with a direct new, and a match for the public surface. That class is
what serves requests on a long-lived worker.
At runtime there is no Reflection, no autowiring, no service definitions,
no array of closures, no call_user_func, and no eval. Resolving a
service is a method call and a property read.
Requires PHP 8.5+. Depends on pilotphp/contracts only — including the
universal Build SPI. Not on pilotphp/build, pilotphp/config, or any
runtime package.
Installation
composer require pilotphp/container
Status: experimental. 0.1.4 is the first release. The public API is
documented as if it were stable and is covered by semantic versioning, but
it may still change in a minor release before 1.0.0 — see
docs/compatibility.md for what that means in
practice, including which of this package's four separate contracts a given
change moves.
Package discovery
An application does not wire this package up by hand. A build tool finds it
at a fixed location — pilot/package.json, relative to the Composer
install path, the same for every package:
<install path>/pilot/package.json → entrypoint
→ new PilotPHP\Container\ContainerPackage()
→ descriptor() what this package is and what role it fills
→ register() ContainerBuildStage
{
"$schema": "https://pilotphp.dev/schema/package-v1.json",
"schemaVersion": 1,
"name": "pilotphp/container",
"version": "0.1.4-dev",
"entrypoint": "PilotPHP\\Container\\ContainerPackage",
"requiresPackages": {},
"providesCapabilities": {
"pilotphp.service-container": "1.0"
},
"requiresCapabilities": {}
}
Eight fields, and no ninth. There is no extra.pilotphp.manifest in
composer.json: a package that names the file a trusted build tool opens is
a package choosing what that tool reads, and the location is not a package's
to choose. Nor is there a responsibilities list, a forbidden_dependencies
list, or a generated_paths list — prose a machine never acts on reads as
authoritative while nothing checks it. What the loader actually refuses is in
docs/public-api.md, named by exception type.
Build stages, commands and service definitions are not listed here.
They appear only through register(), and only for a package an application
actually activated.
Name and version are stated in two places — composer.json and the
manifest — and the manifest agrees with the executable descriptor(). An
architecture test walks the chain and asserts they agree.
requiresPackages is empty on purpose. pilotphp/contracts is a Composer
require — a dependency on PHP types — and not a Pilot dependency, which
would demand that another extension package be active in the same
composition. Contracts has no manifest and no entry point, so it can never be
active. See
docs/architecture.md.
Who does what:
| Owner | |
|---|---|
Calling register() on each package | pilotphp/core / composition — never this package |
Selecting BuildStageInterface declarations | pilotphp/build |
Writing container/compiled.php | ContainerBuildStage::compile() via ArtifactWriterInterface |
| Staging, cache, publication | pilotphp/build |
Calling CompiledContainerLoader | a worker bootstrap, once per boot |
| Deciding what happens to a poisoned worker | the runtime |
This package reads nobody's Composer metadata and discovers nothing.
ContainerPackage::register() contributes exactly one ContainerBuildStage.
Other packages contribute services; the stage consumes those declarations
during prepare.
It is a domain extension, not a package registry. It owns one thing — the
service dependency graph — and has no API through which a service definition
could activate a package. The package graph belongs to pilotphp/core and is
resolved before any stage runs; the service graph is built out of what that
resolution replayed. Nothing flows the other way.
Provenance comes from core, and is never recomputed
Each replayed declaration arrives as a DeclarationRecord: the declaration
plus a DeclarationProvenance naming the package and the occurrence's index
in that package's whole registration. The stage stores both as given.
foreach ($context->declarations()->records() as $record) {
$collector->addRecord($record);
}
No counter, deliberately. If a domain stage numbered the declarations it recognized, a service registered after two console commands would be reported as its package's first declaration, and two stages reading one replay would describe the same occurrence by two different names. Selecting container declarations is this package's job; naming them is not.
The two halves
| Build time | Runtime | |
|---|---|---|
| Input | ServiceDeclaration, AliasDeclaration, FactoryDeclaration | a service id |
| Work | normalize, autowire, validate, generate | call a method |
| Uses Reflection | yes, in PilotPHP\Container\Compiler only | never |
| Output | a PHP class + a fingerprint | an object |
Declaring services
A package contributes declarations through the registration target it is
handed. ContainerDeclarationCollector picks out the ones this package
owns and ignores the rest, so a package can register console commands and
services through the same call.
use PilotPHP\Container\Declaration\AliasDeclaration;
use PilotPHP\Container\Declaration\FactoryDeclaration;
use PilotPHP\Container\Declaration\ServiceDeclaration;
use PilotPHP\Container\Definition\ParameterValue;
use PilotPHP\Contracts\Container\ServiceScope;
public function register(PackageRegistrationInterface $registration): void
{
$registration->add(new ServiceDeclaration(
id: PgsqlUserRepository::class,
class: PgsqlUserRepository::class,
));
// Interfaces are resolved through an alias, never guessed.
$registration->add(new AliasDeclaration(
alias: UserRepositoryInterface::class,
target: PgsqlUserRepository::class,
));
$registration->add(new ServiceDeclaration(
id: ConnectionFactory::class,
class: ConnectionFactory::class,
arguments: ['dsn' => new ParameterValue('pgsql://localhost/app')],
));
// A factory is an ordinary service; the container calls a method on it.
$registration->add(new FactoryDeclaration(
id: Connection::class,
factoryService: ConnectionFactory::class,
factoryMethod: 'create',
));
$registration->add(new ServiceDeclaration(
id: CreateUserHandler::class,
class: CreateUserHandler::class,
public: true,
));
$registration->add(new ServiceDeclaration(
id: RequestState::class,
class: RequestState::class,
scope: ServiceScope::Request,
));
}
Services are private by default. Only public services and public
aliases can be reached through get(); everything else is reachable only
as somebody's dependency.
Objects the container cannot build — a resolved ConfigInterface, a
logger, the runtime's per-request context — are declared with
ExternalServiceDeclaration and supplied from outside. See
docs/external-services.md.
Compiling
use PilotPHP\Container\Compiler\ContainerCompiler;
use PilotPHP\Container\Registry\ContainerDeclarationCollector;
$collector = new ContainerDeclarationCollector();
$collector->collect(new MyPackage());
$result = new ContainerCompiler()->compile(
$collector,
namespace: 'App\\Generated',
className: 'CompiledContainer',
artifactPath: __DIR__ . '/var/cache/container/CompiledContainer.php',
);
$result->definitionFingerprint(); // changed service graph?
$result->artifactFingerprint(); // changed file? key your cache on this
$result->artifactSchema(); // pilotphp.container.compiled.v3
The artifact path is a parameter, never a convention: this package does not
decide where an application keeps its cache. Under the Build SPI it does not
see a path at all — the stage hands ArtifactWriterInterface the relative
container/compiled.php and the writer owns everything else. The generated code is checked
with php -l, out of process, on every compilation — including
in-memory ones — and the write is atomic, so a broken artifact can never
reach the path workers load from by this package's doing.
ContainerCompilationResult also hands back the code itself, so a build can
diff it without reading the file.
Identifiers are validated and kept byte for byte. '\App\Foo',
' app.logger ', 'app.' and ' create ' are refused rather than quietly
repaired into App\Foo, app.logger, an accepted id and create — a
machine name that had to be edited before it could be accepted is a mistake
at its source. Canonical fully qualified names have no leading backslash,
which is what ::class produces.
Identical declarations produce a byte-identical artifact — registration order, hostnames, timestamps and paths are not inputs.
Wiring is type-checked while compiling: a ServiceReference whose target
cannot satisfy the parameter, a literal of the wrong type, or an alias
named after an interface its target does not implement are all build
failures, not TypeErrors on some later request.
Serving requests
$container = new CompiledContainerLoader()->load(
artifactPath: $artifactPath,
expectedClass: 'App\\Generated\\CompiledContainer',
bindings: new ExternalServiceBindings([ConfigInterface::class => $config]),
);
$scope = $container->requestScope();
$scope->enter();
// Request-scoped external services, if any. Checked against the declared
// type before anything is stored, so a wrong object fails here and not as
// a TypeError inside whichever service first asks for it.
$container->bindRequestExternal(RequestContextInterface::class, $context);
try {
return $container->get(CreateUserHandler::class)->handle($input);
} finally {
// Every cleanup stage runs, even if an earlier one threw. Nesting the
// `finally` blocks is what guarantees that: a `reset()` that throws
// must not skip the `leave()` that ends the scope.
try {
$container->reset();
} finally {
try {
$scope->reset();
} finally {
$scope->leave();
}
}
}
In a full framework this is not what application code writes: the kernel
resets the container as part of its own reset, and pilotphp/runtime
brackets the invocation, aggregates whatever the cleanup stages threw, and
decides what happens to the worker. The shape above is what a direct user
of this package — or that runtime — has to guarantee.
CompiledContainerLoader runs once per worker boot, called by a bootstrap —
not by application code, and never per request. A plain
require $artifactPath; followed by new App\Generated\CompiledContainer()
works and checks nothing; the loader is the same thing with the questions
asked — is the configured class name a class name, does the file exist, is
it readable, did including it raise, did it declare that class, is that
class a compiled container, is its ARTIFACT_SCHEMA the one this code
understands, are both of its fingerprint constants well-formed SHA-256
digests, and (optionally) is the artifact fingerprint the expected one. Only
then is anything constructed, and a constructor that does not have the shape
its schema promises becomes a CompiledContainerInstantiationException
rather than a raw TypeError in the middle of a boot. Each failure has its
own exception, because each has a different fix. It scans no directories and
invents no cache location: the path, the class name and the optional
fingerprint are all parameters.
require errors are turned into a typed loader failure, and that failure
is terminal. The artifact is a PHP file, and require on a PHP file runs
it — so a truncated deploy raises a ParseError and an edited artifact can
throw anything at file scope. Both become
CompiledContainerArtifactLoadException, with the original kept as
previous and a message naming the artifact path and the type that was
thrown. Never its text: a file this package did not write can put a
connection string in there, and a boot log is not the place to find out.
Including a file is not all-or-nothing: an artifact that declares its class
and then throws leaves that class behind, fully formed and never fully
run. So the failure poisons the loader — a later load() raises
CompiledContainerLoaderPoisonedException instead of quietly reusing the
half-declared class — and, because a PHP class cannot be unloaded and a new
loader instance cannot know what an earlier one hit, it is a mandatory
signal to terminate the worker process. Do not retry, and do not build a
second loader.
The schema is not one of them. It is CompiledContainerAbi::SCHEMA, the
same constant the generator emits from, so a caller cannot hand the loader a
stale value and switch the version check off, and the compiler and the
loader cannot drift apart. The artifact fingerprint stays optional — a
worker that cannot compute the expected value has nothing to compare
against — but that only turns off the comparison: the constants
themselves are validated on every load.
After a successful first load, the artifact can be used to create several containers in the same process; the file is required once. When the class is already loaded the path is still checked, so a misconfigured path is never silently accepted. After a failed load, retrying in that process is forbidden — the worker boot must fail.
What the loader proves is the ABI and the compiled build identity, not
the file's contents. ARTIFACT_FINGERPRINT answers "was this class compiled
by this generator behaviour from this service graph?"; it is computed from
the inputs to generation and emitted into the very file it describes, so it
is not and cannot be a checksum of those bytes. An artifact edited without
touching its constants is accepted. This package does not verify artifact
contents — see
docs/compilation.md.
bindRequestExternal() comes from RequestExternalServiceBinderInterface,
which every generated container implements alongside DevelopmentContainer.
It is the only supported way to seed a per-request object: it refuses an
undeclared id, refuses an object of the wrong type, stores nothing when it
refuses, and hands the request scope the reset ownership compiled in from
the declaration — so an object declared managedReset: false is never reset
by the container, however resettable it happens to be.
Any cleanup failure poisons: the container, the scope, or both refuse all further work, permanently. The next invocation must not be started, and the worker must be terminated. A service whose cleanup threw is in an unknown state, and serving another request from it risks leaking one request's data into the next.
Terminal means terminal in both directions. A poisoned container refuses to
produce objects — get() and reset() raise
ContainerPoisonedException — and refuses to accept them:
bindRequestExternal() raises it too, before it looks the id up and before
it checks the type, storing nothing. Note that a failed process reset
poisons the container while leaving the request scope healthy, so enter()
still succeeds; without the binding guard that sequence looked like a
working request until the first get(). has() keeps working throughout —
it answers a question about the container's schema, which a failed reset
does not change, and a runtime shutting a worker down should be able to ask
it.
Testing a graph without generating code
DevelopmentContainer runs the same compiled plan directly, with the same
validation. It is a reference implementation for tests and local
development — not the production container, and not a performance
reference.
$container = new ContainerCompiler()->compileDevelopmentContainer($collector);
Documentation
| Document | Contents |
|---|---|
| docs/architecture.md | Layers, the build/runtime split, the type map, exceptions |
| docs/build-integration.md | ContainerBuildStage and the universal Build SPI |
| docs/package-registration.md | How packages contribute declarations |
| docs/service-definitions.md | Declarations, definitions, arguments, factories, aliases |
| docs/autowiring.md | What is autowired, what is refused, and why |
| docs/scopes.md | Process, request and transient lifetimes; reset and poisoning |
| docs/external-services.md | Objects the container does not build, and who resets them |
| docs/compilation.md | The pipeline, determinism, fingerprints, artifact writing |
| docs/migration.md | Standalone compiler → Build SPI |
| docs/performance.md | What the generated container costs, with measurements |
| docs/public-api.md | What is public, what is internal, and the generated ABI |
| docs/compatibility.md | Stability, SemVer in 0.x, what breaks what, composer.lock policy |
| CHANGELOG.md | What changed, in observable terms |
| AGENTS.md | The rules for changing this package |
Development
make install # composer install
make check # validate + cs-check + analyse + test
make benchmark # measurement scripts, no assertions
Individual steps: make test, make analyse, make cs-check, make cs-fix,
make validate.
License
MIT. See LICENSE.