Skip to content

Kernel Boot Lifecycle — Discovery, Phases & Failure Policy

Reference for middag-io/middag-php-framework's kernel boot pipeline: the container's phase model, its convention-based service discovery, the pluggable boot-failure policy, and (for provenance) the moodle-local_middag legacy mechanics it replaced. Backs FW-012.

The boot pipeline (shipped, current)

Kernel/ContainerFactory::build() runs six steps, then a separate call boots modules:

  1. Register synthetic-service placeholders (registerSyntheticDefinitions()).
  2. $bootstrap->configure($container) — the adapter registers host services.
  3. ServiceProvider::register() — suffix-based auto-discovery (only when a project root is given).
  4. $container->compile() — freezes the dependency graph.
  5. Inject each registered synthetic instance (set()).
  6. AbstractFacade::setFacadeContainer().

bootModules($modules) is a distinct, later call: it runs each module's boot() under the configured BootFailurePolicyInterface.

Phase enforcement matrix

Operationregister()boot()compile()
Declare container bindings/servicesYESnono
Resolve from the container, register hooks/controllers/subscribers, access another module's already-registered service, publish signalsnoYESno
Modify an existing bindingnonono
Freeze the container / cache routesnonoYES — kernel only

What's actually code-enforced today: only the compile() freeze boundary. ContainerFactory::addSynthetic() throws MiddagLifecycleViolationException if called after build() — that's the one phase rule with a runtime guard and a test (ContainerFactoryLifecycleTest::addSyntheticAfterBuildThrowsLifecycleViolation). The register()-must-not-resolve / boot()-must-not-bind split is a design discipline for ModuleInterface implementers, not (yet) a lint rule or runtime check.

Circular dependencies are rejected at compile time by Symfony's own ContainerBuilder::compile() (CheckCircularReferencesPass) — no lazy-proxy escape hatch is offered; a cycle means refactoring the design (setter injection, an intermediate factory, a responsibility split), not wrapping the dependency in a proxy.

Discovery by suffix (shipped, current)

Kernel/ServiceProvider scans configured directories under {projectRoot}/src and registers any class whose name ends in one of these suffixes, with autowiring enabled:

Register suffixesIgnore suffixes (never registered)Ignore directories (skipped entirely)
Service, Repository, Controller, Adapter, Handler, Hooks, Router, Builder, Factory, Registrar, Mapper, Manager, Provider, Resolver, Validator, DispatcherEntity, Interface, Trait, Enum, DTO, Dto, Exception, Signal, EventContract, Entity, Enum, Exception, Trait, ValueObject, DTO, Dto

A single-interface implementation is auto-aliased so it can be resolved by its interface. Scanned code must live under {projectRoot}/src, and the provider's root namespace must map to that tree's PSR-4 root — otherwise the derived FQCN fails class_exists() and discovery silently registers nothing for that file.

Discovery by suffix (legacy, moodle-local_middag)

The suffix vocabulary above replaces a richer, host-integrated one. Not shipped in this OSS framework — reference only for whoever ports a legacy extension or implements the host-adapter discovery layer:

Suffix (legacy)LoaderArtifact
_service, _repository, _manager, _handler, _factory, _provider, _loader, _builder, _connector, _adapter, _settingsservice_loaderRegistered in the container
_extensionextension_loaderFramework extensions
_controller (with #[Route])route_loaderHTTP controllers
_facadefacade_loaderFacades
_signal (with #[moodle_event])moodle_signal_loaderHost event bridge
#[item_type] or TYPEtype_loaderItem types
#[on]signal_loaderSignal handlers via attribute

Domains the legacy service_loader ignored (containing exclusively non-injectable types): cli/, contract/, db/, deprecated/, dto/, entity/, enum/, exception/, fixture/, interface/, legacy/, templates/, tests/, trait/, value_object/. Notably not ignored despite looking like plain data folders: base/, model/, domain/, view/, widget/, facade/ — the class-name suffix decides registration, never the folder.

External plugins needed only a lib.php-style hook to register their extension directories for non-native distributions; the rest (extend_..._service_loader, extend_..._register_item_types, extend_..._moodle_signal_loader) were optional. This whole mechanism reads a specific host's plugin registry by construction, so it is host-adapter territory and does not appear in this framework's own discovery engine.

Boot-failure policy (shipped, current)

A module boot() failure is caught by ContainerFactory::bootModules() and handed to the bound BootFailurePolicyInterface:

ImplementationBehaviour
BootRethrowFailurePolicyRe-throws immediately — fail loud
BootIsolateFailurePolicyLogs at critical, does not re-throw — the module is skipped, the rest of the boot loop continues

A consumer binds whichever policy fits its own governance model (or writes its own) — the framework holds no fixed distribution taxonomy. FailedModuleRegistryInterface (register()/has()/all()) is the current, framework-agnostic descendant of the legacy kernel::failed_extensions() registry: it carries a plain distribution string per failure with no opinion on its meaning, for a consumer to inspect programmatically after boot.

A register()-phase failure is not routed through any policyContainerFactory::build() does not wrap $bootstrap->configure() or ServiceProvider::register() in a try/catch, so it always propagates as an ordinary PHP exception. Only boot() failures go through BootFailurePolicyInterface.

Boot-failure policy — legacy distribution mapping (legacy)

Legacy distributionFailure in boot()Current OSS equivalent
NATIVEPropagates immediately — fatal, the team fixes it before deployBootRethrowFailurePolicy
PROPropagates immediately — same responsibility as NATIVEBootRethrowFailurePolicy
THIRD_PARTYIsolated and logged — extension disabled, product keeps runningBootIsolateFailurePolicy
CUSTOMIsolated and logged — same treatment as THIRD_PARTYBootIsolateFailurePolicy

A register() failure was always fatal regardless of distribution, matching the current framework's behaviour above. An extension under the host's own namespace defaulted to NATIVE; one loaded from an external plugin defaulted to THIRD_PARTY.

Why the policy is mandatory dispatch, not a nice-to-have: the legacy loaders originally caught Throwable silently through a debug-trace helper — invisible whenever debugging was off, and a direct contradiction of the "lateral never means automatically suppressed" rule elsewhere in the reactive model (see the signal & hook reference). BootFailurePolicyInterface exists specifically to close that silent-swallowing bug.

Runtime-only, honestly incomplete legacy mechanism, worth knowing before assuming parity: a caught failure marked the extension disabled_by_error for that runtime only — no flag was ever persisted to storage or config. The ADR-609 body itself claimed an admin would see an inline notification "on the next visit to the MIDDAG panel," but its own reference companion stated plainly that no such notification was ever built — the real mechanism was always the logger plus the failed-extensions registry, consulted programmatically or by hand. Don't assume a UI alert exists just because a decision record once described one.

Legacy entry points (legacy)

Entry pointRoleContext
index.phpPage routing and REST APIUI navigation and endpoints
webhook.phpInbound external requestsCallbacks from external services (payment gateways)
ajax.phpInternal frontend requestsAJAX calls from the product's own JS

All three delegated to kernel::handle(). A known, never-fixed limitation: an external plugin registering controllers only got routes nested under the host plugin's own entry-point path, losing its own URL identity — a "Developer Kit" with delegated entry points was the planned fix, never shipped. Where the adapter triggers boot in the host (a Moodle callback/setup step, a WordPress plugins_loaded/init hook) remains the adapter's own responsibility today, not something this framework prescribes.

Anti-patterns

Anti-patternWhy it's wrong
Resolving from the container inside register()register() is the structural, bindings-only phase; resolving there assumes services that may not exist yet.
Declaring a new binding inside boot()Bindings must be closed before boot() runs — compile() freezes the graph on the assumption nothing declares after register().
new {Service}() inside boot() instead of resolving through the containerBypasses the container entirely, defeating autowiring, testability, and the synthetic/facade wiring that depends on going through it.
A circular dependency declarationRejected at compile time by design — refactor (setter injection, an intermediate factory, a responsibility split), don't reach for a lazy-proxy workaround.
Treating an isolate-tier failure as a rethrow-tier one (or omitting a policy)Takes the whole host down for a failure that was supposed to be contained to one module.
Catching a module's boot() exception yourself instead of letting BootFailurePolicyInterface handle itHides a genuinely structural failure and bypasses the failed-module registry a consumer may depend on for visibility.
Continuing to use a module in the same request after its boot() has already failedThe module's state is unknown past that point — isolation means "skip it," not "keep partially using it."
Relying on ad hoc debug tracing instead of the logger/FailedModuleRegistryInterface for boot-failure visibilityInvisible whenever debugging is off — the exact failure mode BootFailurePolicyInterface exists to close.

MIDDAG © 2015-2026