pilotphp/container

Compiled dependency injection container for the PilotPHP Agent-First framework.

Maintainers

Package info

gitlab.com/pilotphp/container

Issues

pkg:composer/pilotphp/container

Transparency log

Statistics

Installs: 17

Dependents: 2

Suggesters: 0

Stars: 0

0.1.4 2026-08-02 19:19 UTC

This package is auto-updated.

Last update: 2026-08-02 16:21:53 UTC


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 locationpilot/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 packagepilotphp/core / composition — never this package
Selecting BuildStageInterface declarationspilotphp/build
Writing container/compiled.phpContainerBuildStage::compile() via ArtifactWriterInterface
Staging, cache, publicationpilotphp/build
Calling CompiledContainerLoadera worker bootstrap, once per boot
Deciding what happens to a poisoned workerthe 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 timeRuntime
InputServiceDeclaration, AliasDeclaration, FactoryDeclarationa service id
Worknormalize, autowire, validate, generatecall a method
Uses Reflectionyes, in PilotPHP\Container\Compiler onlynever
Outputa PHP class + a fingerprintan 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

DocumentContents
docs/architecture.mdLayers, the build/runtime split, the type map, exceptions
docs/build-integration.mdContainerBuildStage and the universal Build SPI
docs/package-registration.mdHow packages contribute declarations
docs/service-definitions.mdDeclarations, definitions, arguments, factories, aliases
docs/autowiring.mdWhat is autowired, what is refused, and why
docs/scopes.mdProcess, request and transient lifetimes; reset and poisoning
docs/external-services.mdObjects the container does not build, and who resets them
docs/compilation.mdThe pipeline, determinism, fingerprints, artifact writing
docs/migration.mdStandalone compiler → Build SPI
docs/performance.mdWhat the generated container costs, with measurements
docs/public-api.mdWhat is public, what is internal, and the generated ABI
docs/compatibility.mdStability, SemVer in 0.x, what breaks what, composer.lock policy
CHANGELOG.mdWhat changed, in observable terms
AGENTS.mdThe 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.