liquidrazor / registry-loader
Compile-time bridge from class discovery to a compiled DIRegistry for LiquidRazor.
Requires
- php: >=8.3
- liquidrazor/class-locator: ^0.1
- liquidrazor/config-loader: ^0.1
- liquidrazor/diregistry: ^0.1
- liquidrazor/file-locator: ^0.1
Requires (Dev)
- phpunit/phpunit: ^12.5
README
Overview
The RegistryLoader is the bridge between class discovery and a compiled dependency registry.
It exists to transform the output of:
liquidrazor/file-locatorliquidrazor/class-locator
into normalized descriptor definitions consumable by:
liquidrazor/diregistry
In practical terms, the RegistryLoader is responsible for turning a discovered class collection into a strict, deterministic, compile-time validated service definition graph.
It is not a container on its own. It is not a runtime resolver. It is not an object instantiator. It is not responsible for service lifetime handling at runtime.
Those concerns belong to DIRegistry.
The RegistryLoader is the compile-time transformation layer that prepares data for DIRegistry.
Position in the LiquidRazor pipeline
The intended pipeline is:
filesystem
-> FileLocator
-> ClassLocator
-> RegistryLoader
-> DIRegistry RegistryCompiler
-> DescriptorResolver
-> DescriptorInstantiator
-> RuntimeInstanceStore
Responsibility split
FileLocator
Responsible only for deterministic filesystem scanning.
It discovers relevant PHP files according to configured roots and project conventions. It does not parse classes semantically and does not perform DI logic.
ClassLocator
Responsible for mapping files to expected classes and validating class-level structure.
It uses FileLocator output, applies PSR-4 mapping rules, validates FQCN expectations, and produces a reliable class collection. It does not register services and does not perform dependency-injection decisions.
RegistryLoader
Responsible for transforming discovered classes into normalized descriptor definitions.
It applies:
- conventions
- attributes
- explicit configuration
- strict validation rules
and outputs a descriptor-definition graph ready to be compiled into DIRegistry.
DIRegistry
Responsible for the actual dependency-injection runtime model.
It owns:
- descriptor compilation
- descriptor storage/indexing
- descriptor resolution
- service instantiation
- runtime instance retention
The RegistryLoader must never duplicate these responsibilities.
Core purpose
The RegistryLoader answers one question:
Given a validated class collection, which classes should become services, under which contracts, with which lifecycle, using which scalar values, and under which compile-time rules?
Its output must be:
- deterministic
- explicit where ambiguity exists
- compile-time validated
- free of runtime guessing
The entire philosophy is simple:
- discovery may be broad
- registration must be strict
- ambiguity must fail early
- runtime must not make architectural decisions
Design principles
1. Compile-time first
All service definition decisions must be made before runtime.
The RegistryLoader is part of the compile phase of the framework. It exists so that runtime behavior is reduced to resolution and instantiation, not discovery and interpretation.
2. Deterministic behavior
The same codebase and configuration must always produce the same descriptor graph. No load-order tricks. No priority roulette. No best-effort inference.
3. Explicit over clever
Conventions are allowed only where they are trivial and safe. When a decision becomes non-trivial, the system must require attributes or config.
4. Fail hard on ambiguity
If the system cannot know the correct answer with certainty, compilation must fail. Warnings are not enough for architecture-level ambiguity.
5. Runtime must stay boring
By the time DIRegistry receives the normalized definitions, the meaningful architectural decisions are already made.
Runtime should not negotiate with the environment or attempt to infer service topology.
Input sources
The RegistryLoader operates on three input categories.
1. Class collection
Produced by ClassLocator.
This is the authoritative discovered set of classes eligible for further evaluation.
The RegistryLoader does not scan the filesystem directly. It consumes the output of the locator layer.
2. Attributes
Attributes provide explicit metadata directly on classes or constructor parameters. They refine or override convention-based inference.
Attributes are not mandatory for the common case. They are used where explicit control is needed.
3. Explicit configuration
Explicit configuration has the highest precedence. It exists to:
- override convention and attribute defaults
- bind scalars
- define explicit defaults among competing implementations
- enable or disable services where necessary
- define metadata not suitable for inference
RegistryLoader delegates config file resolution, format handling, parsing, layered merge behavior, and environment interpolation to liquidrazor/config-loader.
Its own responsibility starts after config has already been loaded into normalized arrays.
Precedence model
The precedence order is fixed:
- explicit configuration
- attributes
- convention
This rule applies across all supported metadata categories.
Convention
Convention provides the baseline candidate definition.
Attributes
Attributes refine or replace convention-derived values.
Configuration
Configuration is authoritative and overrides both attributes and convention.
There is no “last loaded wins” behavior. There is no scanner-order dependency. There is no merge order determined by accident.
Configuration files are loaded through liquidrazor/config-loader in YAML mode.
RegistryLoader does not parse YAML or JSON itself.
Service candidate evaluation
Every discovered class from ClassLocator is evaluated as a possible service candidate.
A class is considered eligible for convention-based evaluation only if all of the following are true:
- it is a concrete class
- it is instantiable
- it is not an interface
- it is not a trait
- it is not an enum
- it is not abstract
- it is not explicitly excluded
Root-aware convention behavior
The project layout strongly influences service candidacy.
Convention-based loading should apply by default to:
src/lib/
Convention-based loading should not apply blindly to:
include/
This is intentional.
include/ is expected to contain contracts, types, enums, exceptions, descriptors, value objects, and other definition-level structures that are not automatically runtime services.
Classes inside include/ may still become services through explicit attributes or configuration, but they should not be loaded by naive convention.
Hybrid loading model
The RegistryLoader uses a hybrid model:
- convention creates the baseline
- attributes refine the baseline
- configuration overrides both
This gives the framework both low boilerplate and strict control.
Why hybrid is the correct model
Pure convention is too implicit for non-trivial systems. Pure attributes are too verbose for ordinary services. The hybrid model allows the common case to remain clean while still forcing explicitness where necessary.
Baseline convention definition
For each valid service candidate, convention produces an initial descriptor-definition candidate.
Typical baseline values include:
- service identifier
- concrete class path
- construction strategy
- lifecycle
- constructor dependency list
- inferred contracts
- source metadata
- environment metadata
- default flags
Default identifier
The default identifier is the fully qualified class name.
This ensures uniqueness and removes the need for string aliases or arbitrary service names.
Default construction strategy
The default construction strategy is constructor-based instantiation.
Factory-based construction is never inferred by convention in version 1. Factories must be explicit.
Source metadata
The loader should preserve enough source information to generate useful compile-time errors. That usually includes:
- FQCN
- source file path
- origin type (convention, attribute, config)
Lifecycle inference rules
Lifecycle inference is intentionally strict and architecture-aware.
Core lifecycle philosophy
Shared services are safe only when architecture makes them safe. Long-lived workers and future fork-based models make careless singleton defaults dangerous.
Therefore, the framework does not treat singleton as the universal default.
Default lifecycle rules
Constructor-based class services
- if the class is declared as a readonly class, default lifecycle =
singleton - otherwise, default lifecycle =
transient
Factory-based services
- default lifecycle =
transient
Pooled services
- never inferred by convention
- allowed only through explicit attribute declaration
Scoped services
- explicit only unless a concrete scope model exists in the wider framework
Why readonly matters
Readonly classes provide a language-level architectural signal that the service is intended to be immutable after construction. That makes them viable candidates for safe sharing.
Non-readonly classes are assumed mutable and therefore isolated by default.
Important limitations
Readonly is not absolute purity. A readonly class may still hold references to mutable objects. Therefore:
- readonly is a strong default signal
- it is not proof of perfect share-safety
The framework still allows explicit lifecycle overrides where necessary.
Contract inference rules
Contract inference is one of the most sensitive parts of the system. The framework intentionally avoids magic here.
Core principles
- only interfaces can be inferred as contracts
- parent classes are never inferred as contracts
- abstract base classes are never inferred as contracts
- multiple implementations require explicit choice
- no numeric priority is used for default contract resolution
No parent class inference
Inheritance is treated as implementation detail, not as DI contract surface. This avoids accidental exposure of scaffolding, internal hierarchies, and long inheritance chains as injectable contracts.
Convention-based contract inference
A class may be auto-exposed under a contract only if:
- it is a valid service candidate
- it implements exactly one interface
- that interface belongs to allowed contract roots or namespaces
- no attribute or config overrides contract exposure
Outcomes
Zero interfaces
No inferred contract. The class remains resolvable only by concrete class path.
Exactly one interface
That interface may be inferred as the contract, provided it matches allowed contract roots.
More than one interface
No contract is inferred. Explicit declaration is required.
Allowed contract roots
Convention-based contract inference should be limited to configured contract namespaces or roots. This avoids exposing technical or incidental interfaces such as generic PHP or support-level interfaces.
A typical project-level convention is to treat interfaces under a contract namespace such as ...\Contract\ as eligible for inference.
Multiple implementations of the same contract
If more than one service exposes the same contract, resolution is valid only if exactly one implementation is explicitly marked as the default.
Valid cases
- exactly one implementation exists
- multiple implementations exist and exactly one is explicitly marked as default
Invalid cases
- multiple implementations exist and none is marked as default
- multiple implementations exist and more than one is marked as default
All invalid cases must fail at compile time.
No priority-based default selection
Numeric priority is intentionally excluded from default contract selection. The framework does not guess which implementation should win.
If multiple candidates exist, the developer must declare the intended default explicitly.
Priority may exist later for ordered collections or aggregation scenarios, but not for ordinary single-contract default resolution.
Scalar binding rules
Scalar values are compile-time concerns. They must never remain unresolved until runtime.
Core scalar philosophy
Scalar dependencies are too ambiguous to autowire safely. A string, integer, float, or boolean carries no architectural intent by itself.
Therefore, scalar resolution must be deterministic and complete during compilation.
Scalar resolution order
For every scalar constructor dependency, the RegistryLoader applies the following precedence order:
- explicit attribute override
- explicit configuration binding
- exact environment-variable inference
- constructor default value
- hard compile-time failure
Compile-time only
Scalars must always be resolved during compile time.
This guarantees:
- deterministic compiled registries
- early failure for missing configuration
- fewer production surprises
- no runtime negotiation with process environment
Default environment-variable inference
If no explicit scalar binding exists, the loader may attempt exact env inference.
The default convention is:
App\Service\DbClient::dsn
-> APP_SERVICE_DBCLIENT_DSN
This convention must be exact and deterministic.
There is no fuzzy matching based only on parameter names such as HOST, PORT, or DSN.
Constructor defaults
If no attribute, config binding, or env value exists, the loader may use the constructor’s declared default value.
Failure rule
If a required scalar cannot be resolved after all resolution stages, compilation must fail.
The framework never injects null silently and never falls back to guesswork.
Attribute model
The attribute surface should remain intentionally small. Attributes are used to express meaningful architectural decisions, not to recreate an annotation-heavy mini language.
Recommended attribute surface
#[Service(...)]
Used to define or override service-level metadata.
Supported concerns may include:
- explicit identifier
- explicit contracts
- lifecycle override
- environments
- explicit default marker
- enable/disable behavior
#[Scalar(...)]
Used on constructor parameters to override scalar binding metadata.
Typical use:
- explicit scalar key override
- explicit environment key override
#[IgnoreService]
Used to exclude a discovered class from service registration.
Attribute philosophy
Attributes should:
- refine convention
- express intent where convention is insufficient
- remain small and readable
Attributes should not become a general-purpose configuration language.
Factory handling
Factories are supported, but they are intentionally explicit.
Rules
- factories are never inferred by convention in version 1
- factories must be declared through attributes or explicit configuration
- factory-based services default to
transient - pooled lifecycle is never inferred and must be explicit
Why factories are explicit-only
Factory methods are a common source of hidden state and side effects. Convention-based factory discovery would introduce ambiguity and surprise. The framework chooses predictability over convenience.
Error model
Errors are a first-class part of the design. In a strict compile-time system, error messages are effectively part of the user interface.
Error principles
Errors must be:
- precise
- actionable
- localizable to class and dependency
- explicit about the violated rule
Example failure categories
- contract has multiple implementations and no default
- contract has multiple explicit defaults
- unresolved scalar dependency
- invalid lifecycle declaration
- pooled lifecycle declared without explicit opt-in
- invalid service candidate
- conflicting explicit configuration
- ignored class referenced as a service
Example error style
Good errors should look like:
Contract App\Contract\CacheInterface has 2 implementations and no explicit default.Scalar App\Service\DbClient::dsn could not be resolved from attribute, config, env, or constructor default.Class App\Internal\Foo is excluded by #[IgnoreService] but was referenced by explicit service config.
The goal is immediate diagnosis, not generic exception noise.
Compile-time validation expectations
The RegistryLoader should validate the full descriptor graph before handing it to DIRegistry.
Validation should include at least:
- service candidate validity
- contract ambiguity
- duplicate explicit defaults
- invalid lifecycle combinations
- scalar resolution completeness
- explicit metadata conflicts
- unsupported convention outcomes
The loader should fail before runtime whenever architectural ambiguity or invalid state exists.
Loading into DIRegistry
The RegistryLoader does not stop at producing abstract metadata in the void.
Its purpose is to produce normalized descriptor definitions and then hand them into DIRegistry for compilation and registry population.
That means the loading flow is not merely:
- discover classes
- infer metadata
- validate definitions
It must continue with the actual transfer into the DI system.
Effective loading flow
The full compile pipeline is:
filesystem
-> FileLocator
-> ClassLocator
-> RegistryLoader
-> service candidate evaluation
-> convention baseline creation
-> attribute refinement
-> config override
-> scalar resolution
-> contract validation
-> normalized descriptor definition set
-> DIRegistry RegistryCompiler
-> compile normalized definitions into immutable descriptor graph
-> build indexes / internal lookup structures
-> validate registry-level constraints
-> Compiled DIRegistry
What “loading into the registry” actually means
The RegistryLoader must convert its final internal representation into the exact normalized definition shape expected by DIRegistry.
It then passes that complete normalized definition set to RegistryCompiler.
RegistryCompiler is responsible for:
- compiling raw normalized definitions into actual registry descriptors
- building the immutable descriptor repository
- constructing resolution indexes
- enforcing final registry-level validation rules
So the RegistryLoader does not manually mutate runtime registry state entry by entry like an ad-hoc service bag. It prepares the full descriptor definition graph and then loads the graph into DIRegistry through the compiler boundary.
Compiler boundary as the official integration point
The integration point between RegistryLoader and DIRegistry is the compiler boundary.
That boundary exists for a reason:
- RegistryLoader owns transformation from class world to definition world
- DIRegistry owns transformation from definition world to compiled descriptor registry
This separation keeps both libraries honest.
The RegistryLoader must therefore output definitions in the canonical format required by DIRegistry and invoke compilation explicitly.
Output of the RegistryLoader
The practical output of the RegistryLoader should be one of the following, depending on API style:
Option A: compiled registry as final product
The RegistryLoader returns a compiled DIRegistry instance, having internally:
- collected classes
- built normalized definitions
- passed them into
RegistryCompiler - returned the compiled registry
This is the most ergonomic option for framework bootstrapping.
Option B: normalized definitions as an intermediate product
The RegistryLoader returns the normalized definition set, and a higher bootstrap layer passes it to RegistryCompiler.
This is more explicit, but also more fragmented.
Recommended approach
For framework use, the preferred behavior is:
- RegistryLoader internally builds normalized definitions
- RegistryLoader invokes
RegistryCompiler - RegistryLoader returns a compiled
DIRegistry
That way, “loading” actually means loading, not merely preparing data and hoping something else remembers to finish the job.
Why this matters
Without this final step, the RegistryLoader would be incomplete. It would only be a descriptor-definition builder, not the actual bridge from class discovery into a usable compiled dependency registry.
The whole point of the component is to connect:
- discovered classes
- inferred and explicit metadata
- scalar/config resolution
- contract/lifecycle validation
with the actual compiled registry consumed later by:
DescriptorResolverDescriptorInstantiatorRuntimeInstanceStore
So the final act of the RegistryLoader is:
produce normalized definitions, compile them through
DIRegistry, and hand back a usable compiled registry.
Runtime boundary
The RegistryLoader ends where compiled DIRegistry begins.
Once normalized descriptor definitions are produced, validated, and compiled, responsibility transfers to DIRegistry for:
- descriptor storage/indexing
- descriptor resolution
- object instantiation
- runtime instance storage
This boundary must remain clean.
The RegistryLoader must never become:
- a runtime resolver
- a hidden container runtime
- an instance cache
- a lazy-instantiation engine
Its job is to:
- transform class discovery into normalized definitions
- resolve compile-time metadata
- validate the graph
- compile the graph into
DIRegistry - stop
Version 1 scope
Version 1 should remain intentionally focused.
Included in version 1
- hybrid convention + attribute + config loading
- readonly-aware lifecycle inference
- explicit factory support
- compile-time scalar binding
- strict interface-only contract inference
- explicit default selection for multiple implementations
- hard compile-time failure on ambiguity
Deliberately excluded from version 1
- parent class contract inference
- priority-based default resolution
- convention-based factory inference
- runtime scalar resolution
- silent fallbacks
- property injection
- contextual binding
- autoconfiguration magic
- service decoration
- lazy proxies
- tag ecosystems
The system should first be correct, explicit, and stable. Additional features should be added only if they preserve determinism and strictness.
Summary
The RegistryLoader is the compile-time transformation layer between class discovery and dependency-injection runtime.
It exists to produce a normalized, validated descriptor-definition graph from:
- discovered classes
- attributes
- explicit configuration
Its design is based on a few non-negotiable rules:
- compile-time first
- deterministic behavior
- explicit conflict resolution
- no runtime scalar guessing
- no priority roulette
- no inheritance-as-contract nonsense
- no silent ambiguity handling
The result is a DI preparation layer that actively enforces architecture rather than merely accommodating it.
That is the intended role of the RegistryLoader inside the LiquidRazor Framework.