Vortos
Foundation

Foundation

The application bootstrap layer — Runner, Container auto-discovery, PackageInterface, container caching, and FrankenPHP worker mode.

Foundation

vortos-foundation is the bootstrap layer. It wires Runner (handles HTTP requests and manages the DI container), Container.php (package auto-discovery), and PackageInterface (the contract every module implements).

Runner

Runner is the entry point for every HTTP request. It builds the container, handles the request, and cleans up between requests in worker mode.

public/index.php
use Vortos\Foundation\Runner;

$runner = new Runner(
    environment: $_ENV['APP_ENV'] ?? 'prod',
    debug:       $_ENV['APP_ENV'] === 'dev',
    projectRoot: dirname(__DIR__),
    context:     'http',
);

$response = $runner->run();
$response->send();
$runner->cleanUp();

Constructor Parameters

ParameterTypeDescription
$environmentstringprod, dev, test — controls caching and log level
$debugbooltrue = debug error pages, full exception messages
$projectRootstringAbsolute path to project root (dirname(__DIR__) from public/)
$contextstringhttp (default) or worker — controls route loading and cleanup

run()

run()
├── Request::createFromGlobals()
├── getContainer()
│       ├── prod + http + dump exists → load CachedContainer (zero compile)
│       └── otherwise → include Container.php → compile → dump if prod
├── $kernel->handle($request) → Response
└── return Response

On container compile failure, run() returns a 503 response rather than crashing. The behavior differs by environment — see Boot Error Survival below.

setParameter() / setParameters()

Inject custom container parameters before the container builds:

$runner
    ->setParameter('app.name', 'Squaura')
    ->setParameter('app.version', '2.1.0')
    ->setParameters([
        'feature.beta' => $_ENV['FEATURE_BETA'] === 'true',
        'stripe.key'   => $_ENV['STRIPE_KEY'],
    ]);

Parameters set here are available in services via %parameter.name%.

cleanUp()

Call after $response->send(). In standard PHP-FPM mode, clears the ArrayAdapter and resets the container. In FrankenPHP worker mode, only clears ArrayAdapter — the container stays alive across requests:

// Standard PHP-FPM
$response->send();
$runner->cleanUp(); // container = null, ArrayAdapter cleared

// FrankenPHP worker mode (detected via function_exists('frankenphp_handle_request'))
$response->send();
$runner->cleanUp(); // ArrayAdapter cleared, container kept alive

Always call cleanUp()

cleanUp() must be called after every request. Without it, ArrayAdapter data leaks between requests in worker mode — user A's identity could be visible to user B.

Boot Error Survival

In FrankenPHP worker mode, public/index.php warms the container once before entering the request loop. If that warm-up throws — bad DI config, missing service, syntax error in a config file — the worker must not crash. A crashed worker means FrankenPHP restarts it immediately, it crashes again, and you get an infinite crash loop with a gateway error on every request and no useful output.

Runner handles this entirely. The pre-warm call is wrapped in a try/catch in index.php that survives the failure:

public/index.php (worker mode)
try {
    $runner->getContainer();
} catch (\Throwable $e) {
    // Logged by run() on each request. Nothing else to do here.
}

while (frankenphp_handle_request(function () use ($runner): void {
    $response = $runner->run();
    $response->send();
    $runner->cleanUp();
})) {}

The worker stays alive. When requests arrive, run() takes over:

In prod (APP_DEBUG=false):

  • The boot error is cached in $runner->bootError after the first failure.
  • Every subsequent request returns a plain-text 503 immediately — no recompilation attempted.
  • The full error with stack trace is written to error_log once, visible in docker logs.
  • The worker stays alive and serving 503s until a fix is deployed and the container restarts.

In dev (APP_DEBUG=true):

  • run() retries getContainer() on every request — no error caching.
  • The 503 response includes the full exception message and stack trace rendered in the browser.
  • Fix the code, refresh the browser — if compilation succeeds, $bootError resets to null and the worker resumes normally. No restart needed.
Boot fails (any environment)
    └── Worker survives
    └── run() called per request
            ├── prod: $bootError cached → 503 plain text, no recompile
            └── dev:  retry getContainer() → 503 HTML with error in browser
                      fix code → retry succeeds → $bootError = null → normal operation

docker logs still works

Even in prod where the browser gets a generic 503, the full error is in docker logs on the first failed boot. You never need to guess what broke.

Container Auto-Discovery

Bootstrap/Container.php automatically discovers and registers all Vortos packages by scanning two sources:

Source 1 — vendor/composer/installed.json (Packagist-installed packages):

Any installed package that declares extra.vortos.package in its composer.json is discovered automatically:

packages/Vortos/src/Cache/composer.json
{
  "extra": {
    "vortos": {
      "package": "Vortos\\Cache\\DependencyInjection\\CachePackage",
      "order": 10
    }
  }
}

Source 2 — path repositories (local monorepo packages):

The root composer.json's repositories array is scanned for type: path entries. Their composer.json files are read and packages discovered the same way.

Discovery Order

The order field controls registration sequence. Lower = earlier:

order 1:  HttpPackage
order 5:  CachePackage
order 10: LoggerPackage
order 15: TracingPackage
order 20: PersistencePackage
order 25: DbalPersistencePackage
order 30: MongoPersistencePackage
order 40: MessagingPackage
order 50: CqrsPackage
order 60: AuthPackage
order 70: AuthorizationPackage
order 80: DockerPackage

This order matches the dependency requirements covered in each module's docs.

PackageInterface

Every module implements PackageInterface:

interface PackageInterface
{
    // Return the DI Extension for this package
    public function getContainerExtension(): ?ExtensionInterface;

    // Register compiler passes here — runs before container compiles
    public function build(ContainerBuilder $container): void;
}

Implement this to create your own package:

final class MyFeaturePackage implements PackageInterface
{
    public function getContainerExtension(): ?ExtensionInterface
    {
        return new MyFeatureExtension();
    }

    public function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new MyFeatureCompilerPass());
    }
}

Cross-package wiring rule (load() vs build())

Extension::load() runs during Symfony's MergeExtensionConfigurationPass, while extensions are still loading one by one — so another package's services are not reliably visible there. Deciding whether to register a service based on a foreign $container->has() in load() is order-dependent and silently wrong.

The rule: load() registers only this package's own services and reads only its own config. Any decision that depends on another package's services goes in a CompilerPass (registered in build()), where has() reflects the fully-merged container and is order-independent.

  • Optional cross-package collaborator → inject new Reference(X, ContainerInterface::NULL_ON_INVALID_REFERENCE) and take a nullable constructor arg. No has() needed.
  • Cross-package entry-point command → register it always (so it appears in list) with the collaborator as NULL_ON_INVALID_REFERENCE, and fail loudly with remediation at runtime when it's null.
  • class_exists() / interface_exists() are allowed in load() — they're autoloader-based and order-free.

This is enforced by an architecture test (NoCrossPackageHasInLoadTest) that fails the build on any new cross-package has()/hasDefinition()/hasAlias() inside a load(). See Foundation\DependencyInjection\Compiler\ConditionalWiringPass for the base class and MissingCapabilityException for the fail-loud contract.

Config surfaces, not service overrides

Configure framework packages through their config/<package>.php surface (config/deploy.php, config/alerts.php, config/secrets.php, config/pipeline.php) — each is loaded by a factory. Redefining a vendor service definition to inject configuration is no longer necessary.

Container Caching

In prod + http context, the compiled container is dumped to var/cache/ and loaded on subsequent requests — zero compilation overhead.

Content-Hashed Filenames

The dump filename is container_<hash>.php, where <hash> is an xxh3 hash of the modification times and sizes of all .php, .yaml, and .yml files under config/ and src/. The hash changes whenever any config or source file changes:

First prod request (no cache hit):
    Container.php → compile → dump to var/cache/container_<hash>.php

Subsequent prod requests (hash matches):
    require_once var/cache/container_<hash>.php
    new CachedContainer() → instant, no compile

After any config/source change (new hash):
    Old container_<oldhash>.php is ignored and cleaned up
    Container.php → compile → dump to var/cache/container_<newhash>.php

On startup, Runner scans var/cache/ and deletes any container_*.php files whose hash does not match the current config. Stale dumps from previous deployments are cleaned automatically.

Concurrent Compile Protection

When two requests race to compile the container simultaneously, Runner uses flock(LOCK_EX|LOCK_NB) on a PID-scoped temporary file. The first request wins the lock, compiles, and atomically renames the dump into place. The second request skips the dump (no lock) and serves from the newly written file on the next request.

This prevents a partial container dump from being loaded mid-write under concurrent traffic.

Clearing the Cache

Because filenames are content-hashed, you do not need to delete a specific file. On the next request after a deployment, the hash changes and the old dump is ignored. The stale file is cleaned on startup.

If you need to force a recompile immediately (e.g. during debugging), delete all container dumps:

rm -f var/cache/container_*.php

In dev, the container is recompiled on every request — config changes take effect immediately with no cache to clear.

#[DefaultImpl]

#[DefaultImpl] is a compile-time attribute that tells the container to create an interface alias pointing to the annotated class — without any manual $services->alias() calls.

use Vortos\Foundation\DependencyInjection\Attribute\DefaultImpl;

#[DefaultImpl]
final class RedisTokenStore implements TokenStoreInterface
{
    // ...
}

DefaultImplCompilerPass runs at compile time, reflects the class, finds TokenStoreInterface (the single app-namespace interface it implements), and registers:

// Equivalent to what the compiler pass generates automatically:
$container->setAlias(TokenStoreInterface::class, RedisTokenStore::class);

The alias is only created if no alias or definition for TokenStoreInterface already exists — explicit registrations in services.php always take precedence.

When the Interface Cannot Be Inferred

If the class implements multiple app-namespace interfaces, the pass cannot infer which one to alias. Specify it explicitly:

#[DefaultImpl(interface: TokenStoreInterface::class)]
final class RedisTokenStore implements TokenStoreInterface, StoreStatsInterface
{
    // ...
}

Compile-Time Errors

The pass throws \LogicException at container build time in the following cases:

SituationError
Multiple app-namespace interfaces and no interface arg"Ambiguous: specify interface explicitly"
interface arg points to a non-existent interface"Interface does not exist"
interface arg names a class, not an interface"Target is not an interface"

Errors surface at boot, not at runtime — misconfiguration is caught before any request is served.

"App Namespace" Detection

The pass reads composer.json's psr-4 autoload map to determine which namespaces belong to your application. Framework interfaces (e.g. Vortos\*, Symfony\*) are excluded from inference — only your own interfaces are candidates.

Inspecting bindings at runtime

Use debug:bindings to list all #[DefaultImpl] interface → class bindings that the compiler pass resolved:

php vortos debug:bindings
 Default Implementation Bindings
 ================================

 -------------------------------------------------------------------------------
  Interface                                      Implementation
 -------------------------------------------------------------------------------
  App\User\Domain\Repository\UserRepositoryInterface  App\User\Infrastructure\Repository\UserWriteRepository
  App\Auth\Contract\TokenStoreInterface               App\Auth\Infrastructure\RedisTokenStore
 -------------------------------------------------------------------------------

 2 binding(s) registered.

Add -v to also show the file path of each implementation:

php vortos debug:bindings -v
 -------------------------------------------------------------------------------
  Interface                  Implementation        File
 -------------------------------------------------------------------------------
  ...UserRepositoryInterface  ...UserWriteRepository  /var/www/html/src/User/...
 -------------------------------------------------------------------------------

Only bindings created by DefaultImplCompilerPass are listed. Aliases registered manually in services.php are not included — they take precedence over #[DefaultImpl] and are visible via php vortos debug:container.

#[OverrideImpl]

#[OverrideImpl] is the counterpart to #[DefaultImpl]. It unconditionally replaces any existing alias or definition — including bindings that framework extensions registered during load().

Use it when app code needs to swap out a framework-provided binding without adding an explicit alias to services.php.

use Vortos\Foundation\DependencyInjection\Attribute\OverrideImpl;

#[OverrideImpl]
final class CustomFlagContextResolver implements FlagContextResolverInterface
{
    // This replaces whatever the FeatureFlags extension registered
    // for FlagContextResolverInterface — no services.php edit needed.
}

Why not just use #[DefaultImpl]?

DefaultImplCompilerPass intentionally skips an interface that already has a registered alias or definition. Framework extensions register their bindings during load(), which runs before DefaultImplCompilerPass. This means #[DefaultImpl] always loses to a framework default.

OverrideImplCompilerPass runs after DefaultImplCompilerPass (priority 0 vs 5, same TYPE_BEFORE_OPTIMIZATION phase) and never skips — it always wins.

AttributeSkip if alias exists?When to use
#[DefaultImpl]Yes — yields to explicit registrationsYour own bindings when nothing registers first
#[OverrideImpl]No — always replacesReplacing a framework-registered binding

Explicit interface argument

Same inference rules as #[DefaultImpl] — if the class implements multiple app-namespace interfaces, specify which one:

#[OverrideImpl(FlagContextResolverInterface::class)]
final class CustomFlagContextResolver implements FlagContextResolverInterface, ResettableInterface
{
    // ...
}

Error collection

Unlike DefaultImplCompilerPass (which throws on the first error), OverrideImplCompilerPass collects all errors and throws a single exception listing every problem:

2 container configuration error(s) found:

  [1] #[OverrideImpl] on "App\Foo\Bar" (src/Foo/Bar.php:14)
      → Class does not implement "SomeInterface". Add "implements SomeInterface" or fix the attribute argument.

  [2] #[OverrideImpl] on "App\Baz\Qux" (src/Baz/Qux.php:22)
      → #[OverrideImpl] conflict: both "App\Other" and "App\Baz\Qux" override "SomeInterface". Only one class may override an interface.

Compile-Time Errors

SituationError
Multiple app-namespace interfaces and no interface arg"Ambiguous: specify interface explicitly"
interface arg names an interface the class does not implement"Class does not implement X"
Two classes both carry #[OverrideImpl] for the same interface"Conflict: only one class may override an interface"

All errors are collected before the exception is thrown — you see every misconfiguration in one boot failure, not one at a time.

Inspecting bindings

#[OverrideImpl] bindings are stored in the vortos.override_impl.bindings container parameter. To inspect them, use debug:container:

php vortos debug:container --parameter=vortos.override_impl.bindings

#[AsDecorator]

#[AsDecorator] wraps an existing service with a new class — without touching the original class or any of its callers. The outer alias is updated at compile time; the original implementation is kept and injected as $inner into the decorator.

use Vortos\Foundation\DependencyInjection\Attribute\AsDecorator;

#[AsDecorator(decorates: FlagContextResolverInterface::class)]
final class AuditingFlagContextResolver implements FlagContextResolverInterface
{
    public function __construct(private readonly FlagContextResolverInterface $inner) {}

    public function resolve(): FlagContext
    {
        $context = $this->inner->resolve();
        $this->audit($context);
        return $context;
    }
}

DecoratorCompilerPass runs at compile time. It finds AuditingFlagContextResolver (tagged vortos.decorator via autoconfiguration), resolves the existing alias for FlagContextResolverInterface, and rewires the container:

Before:
  FlagContextResolverInterface → DefaultFlagContextResolver

After:
  FlagContextResolverInterface              → AuditingFlagContextResolver
  FlagContextResolverInterface.vortos_inner_0  → DefaultFlagContextResolver

$inner on AuditingFlagContextResolver is bound to the inner alias automatically. All callers that depend on FlagContextResolverInterface now receive the decorator transparently.

What $decorates accepts

$decorates is a plain string — the target does not have to be an interface:

// Interface FQCN (most common)
#[AsDecorator(decorates: FlagContextResolverInterface::class)]

// Concrete class FQCN
#[AsDecorator(decorates: DefaultFlagContextResolver::class)]

// Any string service ID registered in the container
#[AsDecorator(decorates: 'app.some_service')]

When the target is an interface, the decorator must implement it. When the target is a concrete class or a string ID, no interface check is performed — the only requirement is that a constructor parameter typed to the target exists.

Inner parameter discovery

The pass scans the decorator's constructor for a parameter whose declared type matches $decorates exactly. The parameter can be named anything:

#[AsDecorator(decorates: FlagContextResolverInterface::class)]
final class TracingFlagContextResolver implements FlagContextResolverInterface
{
    public function __construct(
        private readonly FlagContextResolverInterface $decorated, // name does not matter
        private readonly Tracer $tracer,
    ) {}
}

Chaining multiple decorators

Multiple decorators on the same target form a chain ordered by priority. Higher priority = outer wrapper (called first, wraps everything beneath it).

#[AsDecorator(decorates: FlagContextResolverInterface::class, priority: 10)]
final class TracingFlagContextResolver implements FlagContextResolverInterface
{
    public function __construct(private readonly FlagContextResolverInterface $inner) {}
}

#[AsDecorator(decorates: FlagContextResolverInterface::class, priority: 5)]
final class CachingFlagContextResolver implements FlagContextResolverInterface
{
    public function __construct(private readonly FlagContextResolverInterface $inner) {}
}

Resolved call chain at runtime:

FlagContextResolverInterface
  → TracingFlagContextResolver  (priority 10, outermost)
    → CachingFlagContextResolver  (priority 5)
      → DefaultFlagContextResolver  (original)

Container aliases after compilation:

FlagContextResolverInterface               → TracingFlagContextResolver
FlagContextResolverInterface.vortos_inner_0  → DefaultFlagContextResolver   (innermost)
FlagContextResolverInterface.vortos_inner_1  → CachingFlagContextResolver

Compile-time errors

All errors are collected before the exception is thrown — you see every misconfiguration in one boot failure:

3 container configuration error(s) found:

  [1] #[AsDecorator] on "App\Foo\AuditDecorator" (src/Foo/AuditDecorator.php:12)
      → Target "App\Foo\UnknownInterface" has no alias or definition in the container.
        Cannot decorate a service that does not exist.

  [2] #[AsDecorator] on "App\Foo\BadDecorator" (src/Foo/BadDecorator.php:20)
      → No constructor parameter typed to "App\Foo\SomeInterface" found on "App\Foo\BadDecorator".
        Add a constructor argument with type "App\Foo\SomeInterface" to receive the inner service.

  [3] #[AsDecorator] on "App\Foo\ConflictDecorator" (src/Foo/ConflictDecorator.php:31)
      → Priority conflict on "App\Foo\SomeInterface": both "App\Foo\OtherDecorator" and
        "App\Foo\ConflictDecorator" have priority 0. Assign distinct priorities to control chain order.
SituationError
$decorates target has no alias or definition in the container"Target has no alias or definition"
Decorator class name equals $decorates"A class cannot decorate itself"
Target is an interface and decorator does not implement it"Does not implement the interface"
No constructor parameter typed to $decorates"No constructor parameter typed to X found"
Two decorators on the same target share the same priority"Priority conflict"

Pass ordering

DecoratorCompilerPass runs at TYPE_BEFORE_OPTIMIZATION priority -5 — after DefaultImplCompilerPass (priority 5) and OverrideImplCompilerPass (priority 0). All interface aliases are fully settled before decoration begins, so #[AsDecorator] composes correctly on top of both #[DefaultImpl] and #[OverrideImpl] bindings.

PassPriorityEffect
DefaultImplCompilerPass5Creates alias if none exists
OverrideImplCompilerPass0Unconditionally replaces alias
DecoratorCompilerPass-5Wraps whatever the alias currently points to

Choosing between the three attributes

NeedAttribute
Register a new binding when nothing registered it yet#[DefaultImpl]
Replace a framework-registered binding entirely#[OverrideImpl]
Add behaviour around an existing binding without replacing it#[AsDecorator]

ConsoleCommandPass

ConsoleCommandPass is added by Runner automatically. It discovers all services tagged console.command and wires them into Symfony's Application through a lazy ContainerCommandLoader — it never eagerly instantiates every command:

// This happens automatically — you never call this directly
$container->addCompilerPass(new ConsoleCommandPass());

Any command registered as a service with ->addTag('console.command') is available in bin/console — Vortos packages use this automatically via #[AsCommand]. Because the loader is lazy, only the invoked command is instantiated; a sibling command whose dependency graph reaches Redis/Postgres/Kafka is never constructed. This is what lets an operator or deploy command (vortos:release:record-manifest, deploy:doctor, deploy) boot inside the image on a host with no infrastructure reachable. The pass still fails closed at compile time if the Application service is missing or private, since the loader hangs off it.

CLI Context

For worker processes and console commands, boot with context: 'worker' to skip HTTP route compilation:

bin/console
$runner = new Runner(
    environment: $_ENV['APP_ENV'] ?? 'prod',
    debug:       false,
    projectRoot: dirname(__DIR__),
    context:     'worker',   // no routes, no RouterListener
);

$container = $runner->getContainer();
$app = $container->get(\Symfony\Component\Console\Application::class);
$app->run();

With context: 'worker', kernel.enable_routes is set to falseRouteCompilerPass and HttpListenerCompilerPass both skip, making the container cheaper to build for CLI use.

On this page