liquidrazor/registry-loader

Compile-time bridge from class discovery to a compiled DIRegistry for LiquidRazor.

Maintainers

Package info

github.com/LiquidRazor/RegistryLoader

Documentation

pkg:composer/liquidrazor/registry-loader

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-04-03 07:07 UTC

This package is auto-updated.

Last update: 2026-04-03 07:07:48 UTC


README

Overview

The RegistryLoader is the bridge between class discovery and a compiled dependency registry.

It exists to transform the output of:

  • liquidrazor/file-locator
  • liquidrazor/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:

  1. explicit configuration
  2. attributes
  3. 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:

  1. explicit attribute override
  2. explicit configuration binding
  3. exact environment-variable inference
  4. constructor default value
  5. 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:

  1. collected classes
  2. built normalized definitions
  3. passed them into RegistryCompiler
  4. 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:

  • DescriptorResolver
  • DescriptorInstantiator
  • RuntimeInstanceStore

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:

  1. transform class discovery into normalized definitions
  2. resolve compile-time metadata
  3. validate the graph
  4. compile the graph into DIRegistry
  5. 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.