Skip to content

ClassVersion

Versioned classes via namespace injection, no renaming, no configuration files, no magic.

Introduction

When a class evolves (new fields, different validation, changed behavior), you face a dilemma: modify the existing class and risk breaking backward compatibility? Or introduce a new class under a new name and update imports in hundreds of places?

jardissupport/classversion solves this elegantly: the class name stays the same, the version is injected as a namespace segment. App\Domain\Payment becomes App\Domain\v2\Payment, automatically, with a fallback chain and proxy support. The calling code does not change.

This is the heart of Jardis' evolution strategy: the builder writes its output exclusively under {Agg}/Platform/, custom code sits directly under {Agg}/ parallel to Platform/, either versionless (baseline override) or per version. On regeneration only Platform/ is rewritten; everything alongside it stays untouched. Old and new versions coexist: no all-or-nothing migration.

  • Two resolver strategiesLoadClassFromSubDirectory for the classic v2/ layout, LoadClassFromExtensions for multi-segment layouts with a clear separation between generator and custom code (default in Jardis Builder projects: segmentNames: ['', 'Platform'])
  • Fallback chainv2 not available? Automatically fall back to v1 or the generator base
  • Labels/aliases'current', 'legacy', '2.0' instead of hardcoded directory names
  • Proxy registry — pre-registered instances override class resolution
  • Resolution cache — optional ClassResolutionCache memoizes hits and misses, eliminating repeated class_exists() syscalls
  • Tracing decorator — log every resolution step (debugging)

System-Wide Versioning

ClassVersion is not a tool for individual classes. It is a versioning strategy for entire software systems. The key insight: the version is not set per class, but per invocation context. A BoundedContext passes a version to handle(), and ClassVersion resolves every class in that context to the right version.

Every Class Evolves Independently

A system with 50 classes doesn't need 50 coordinated upgrades. When Payment is extended to v2, the other 49 classes stay on their current version. The fallback chain handles this automatically:

handle('Payment', 'v2')        → App\Domain\v2\Payment       (v2 exists)
handle('Invoice', 'v2')        → App\Domain\v1\Invoice        (no v2, fallback v1)
handle('Customer', 'v2')       → App\Domain\Customer           (no v2/v1, base class)

Runtime Coexistence

Different versions can run simultaneously in the same process. A v1 API endpoint uses 'v1', a v2 endpoint uses 'v2': every class is resolved to the correct implementation:

php
// API v1 — Legacy clients
class OrderV1Controller
{
    public function create(array $data): DomainResponse
    {
        return (new PlaceOrderContext($this->kernel(), $data, version: 'v1'))();
    }
}

// API v2 — New clients
class OrderV2Controller
{
    public function create(array $data): DomainResponse
    {
        return (new PlaceOrderContext($this->kernel(), $data, version: 'v2'))();
    }
}

The same PlaceOrderContext, but internally the validator, repository and hydrator are each resolved in the matching version. No if/else, no feature flags. The architecture handles it.

Gradual Migration

Instead of big-bang releases, migration happens class by class:

Sprintv2 overridesEffect
1Payment, PaymentValidatorNew payment logic, rest stays v1
2Invoice, InvoiceRendererNew invoice format
3CustomerExtended customer data
4Label 'v2''current'v2 becomes default, v1 becomes 'legacy'

Each sprint is independently deployable and rollbackable. No all-or-nothing migration.

Installation

bash
composer require jardissupport/classversion

GitHub: jardisSupport/classversion

The Concept

ClassVersion uses PSR-4 autoloading: the version segment becomes the subdirectory. Which spot in the namespace the segment is injected at depends on the resolver strategy:

ResolverInjection pointTypical layout
LoadClassFromSubDirectorydirectly in front of the class nameApp\Domain\v2\Payment
LoadClassFromExtensionsat a configurable depth, with one or more intermediate segmentsApp\Sales\Orders\v2\Command\CreateOrder (custom) or App\Sales\Orders\Platform\Command\CreateOrder (generator)

For small libraries and simple packages, LoadClassFromSubDirectory is enough. For generated code bases with a clear separation between the generator base and custom code (Jardis Builder projects), LoadClassFromExtensions is the right choice.

Namespace Injection (SubDirectory)

The algorithm is simple: the version segment is inserted before the class name in the namespace:

App\Domain\Payment + v2  →  App\Domain\v2\Payment
App\Service\Calculator + v1  →  App\Service\v1\Calculator

The class only needs to be declared in the correct namespace: PSR-4 takes care of the rest.

Basic Usage

Configuration

php
use JardisSupport\ClassVersion\ClassVersion;
use JardisSupport\ClassVersion\Data\ClassVersionConfig;
use JardisSupport\ClassVersion\Reader\LoadClassFromSubDirectory;

$config = new ClassVersionConfig(
    version: [
        'v2' => ['v2', '2.0', 'current'],
        'v1' => ['v1', '1.0', 'legacy'],
    ],
    fallbacks: [
        'v2' => ['v1'],  // v2 not found → try v1
    ],
);

$classVersion = new ClassVersion(
    $config,
    new LoadClassFromSubDirectory($config),
);

Resolving a Class

php
// Returns the FQCN of the versioned class
$class = $classVersion('App\Domain\Payment', 'v2');
// → 'App\Domain\v2\Payment' (if class_exists)

// Labels work
$class = $classVersion('App\Domain\Payment', 'current');
// → 'App\Domain\v2\Payment' ('current' is alias for 'v2')

// Create instance
$payment = new $class(...$args);

Fallback Chain

If App\Domain\v2\Payment does not exist:

1. App\Domain\v2\Payment     → class_exists? NO
2. App\Domain\v1\Payment     → class_exists? YES → return

If v1 also does not exist, the base class App\Domain\Payment is used. If that also does not exist, InvalidArgumentException is thrown.

Configuration in Detail

Labels (Aliases)

Labels map readable names to version keys:

php
$config = new ClassVersionConfig(
    version: [
        'v2' => ['v2', '2.0', 'current', 'latest'],
        'v1' => ['v1', '1.0', 'legacy'],
    ],
);

$config->version('current');  // 'v2'
$config->version('legacy');   // 'v1'
$config->version('2.0');      // 'v2'
$config->version('unknown');  // 'unknown' (passthrough)

Fallback Chains

php
$config = new ClassVersionConfig(
    version: [
        'v3' => ['v3', 'next'],
        'v2' => ['v2', 'current'],
        'v1' => ['v1', 'legacy'],
    ],
    fallbacks: [
        'v3' => ['v2', 'v1'],  // v3 → v2 → v1 → base class
        'v2' => ['v1'],         // v2 → v1 → base class
    ],
);

$config->fallbackChain('v3');  // ['v3', 'v2', 'v1']
$config->fallbackChain('v2');  // ['v2', 'v1']
$config->fallbackChain('v1');  // ['v1'] (no fallback defined)

The base class is always the implicit last fallback. It does not need to be in the chain.

Extensions Resolver

LoadClassFromExtensions is the second resolver, tailored to generated code bases with a clear separation between generator base and custom overrides. Instead of injecting the version segment directly in front of the class name, one or more intermediate segments (Extensions/, Overrides/, Platform/, …) are inserted at a configurable namespace depth, optionally with versioned sub-directories below them.

Configuration

php
use JardisSupport\ClassVersion\ClassVersion;
use JardisSupport\ClassVersion\Data\ClassVersionConfig;
use JardisSupport\ClassVersion\Reader\LoadClassFromExtensions;

$config = new ClassVersionConfig(
    version: [
        'v2' => ['v2', 'current'],
        'v1' => ['v1', 'legacy'],
    ],
    fallbacks: ['v2' => ['v1']],
);

$resolver = new ClassVersion(
    $config,
    new LoadClassFromExtensions(
        depth: 3,
        segmentNames: ['Extensions'],
        versionConfig: $config,
    ),
);

depth specifies how many namespace segments from the start form the "root" below which the segments from segmentNames are injected. segmentNames is a list of segment names (default ['Extensions']) probed in the given order (that order is the priority order). The empty string '' is a legal segment value and means "no intermediate segment inserted". versionConfig is optional: without config the version is used verbatim as the chain (no labels, no fallbacks).

Lookup Order

For the class App\Sales\Orders\Command\Handler\CreateOrder with depth: 3 (root = App\Sales\Orders) and segmentNames: ['Extensions']:

1. App\Sales\Orders\Extensions\v2\Command\Handler\CreateOrder   (versioned override, with fallback chain)
2. App\Sales\Orders\Extensions\Command\Handler\CreateOrder      (versionless baseline override)
3. App\Sales\Orders\Command\Handler\CreateOrder                 (generator base)

Classes with fewer than depth + 1 namespace segments skip steps 1–2 and resolve directly against the generator base: there is no "rest" below the root.

If neither override nor baseline nor base exists, InvalidArgumentException is thrown. The exception message lists all candidates that were tried.

Multi-Segment Layouts (Versions-First)

segmentNames may contain more than one entry. This is useful when a project-level override layer should sit alongside another layer (e.g. a platform or vendor layer). The resolver follows a strict "versions-first across segments" strategy: first the entire version fallback chain is probed across all segments, then the baselines of all segments are tried, and only as a last resort the generator base.

Example with segmentNames: ['', 'Platform'] (empty string = override directly at the root, no intermediate segment) for version v2 with fallback v1:

Versioned layer (all segments × all chain versions):
1. App\Sales\Orders\v2\Command\Handler\CreateOrder            (segment='',         version='v2')
2. App\Sales\Orders\Platform\v2\Command\Handler\CreateOrder   (segment='Platform', version='v2')
3. App\Sales\Orders\v1\Command\Handler\CreateOrder            (segment='',         version='v1')
4. App\Sales\Orders\Platform\v1\Command\Handler\CreateOrder   (segment='Platform', version='v1')

Baseline layer (versionless, all segments):
5. App\Sales\Orders\Command\Handler\CreateOrder               (segment='',         baseline = generator-base shape)
6. App\Sales\Orders\Platform\Command\Handler\CreateOrder      (segment='Platform', baseline)

Generator base (implicit final fallback):
7. App\Sales\Orders\Command\Handler\CreateOrder

The important consequence: a versioned hit in a later segment beats the baseline of an earlier segment. Asking for 'v2' when only Platform/v2/... exists (step 2) returns that class, even if a versionless override existed under the empty segment (step 5). Versions take priority over baselines, across segments.

When to Use It

The extensions layout is ideal when:

  • generator output and custom code must be physically separated (e.g. src/Sales/Orders/Platform/ as the generator-only directory; everything alongside it under src/Sales/Orders/ is the team's territory)
  • baseline overrides (versionless) should live alongside versioned overrides
  • multiple override layers with a clear priority are needed (project + platform, customizing + vendor, …)
  • regeneration runs must only overwrite the generator base, never the override directories

In Jardis projects this is the default, the generated {Domain}Context::classVersion() uses depth: 3 with segmentNames: ['', 'Platform']: project overrides live directly at the aggregate root (empty segment), platform overrides under Platform/. Versions are resolved across segments with priority over baselines.

Proxy Registry

Pre-registered object instances override class resolution, ideal for tests or hot-swap at runtime:

php
use JardisSupport\ClassVersion\Reader\LoadClassFromProxy;

$proxy = new LoadClassFromProxy($config);

// Register instance
$mockPayment = new MockPayment();
$proxy->addProxy('App\Domain\Payment', $mockPayment, 'v2');

// Returns the registered instance instead of a FQCN
$result = $proxy('App\Domain\Payment', 'v2');
// → $mockPayment (object, not class-string)

// Remove proxy
$proxy->removeProxy('App\Domain\Payment', 'v2');

The proxy finder is passed to the ClassVersion constructor as the optional third argument. If it is omitted, ClassVersion creates an empty LoadClassFromProxy instance internally. So the registry is always available and takes precedence over the actual class finder.

Resolution Cache

The optional ClassResolutionCache memoizes resolution results and exceptions. The goal: avoid repeated class_exists() and stat() syscalls for identical class lookups, especially valuable for negative lookups (base class exists, no override) that are the common case in generated code.

php
use JardisSupport\ClassVersion\ClassVersion;
use JardisSupport\ClassVersion\Support\ClassResolutionCache;

$cv = new ClassVersion(
    $config,
    new LoadClassFromExtensions(depth: 3, segmentNames: ['Extensions'], versionConfig: $config),
    null,                          // proxy finder is optional
    cache: new ClassResolutionCache(),
);

// First call: class_exists() + stat() syscalls
$cv(App\Sales\Orders\Command\CreateOrder::class, 'v2');

// Second call: cache hit, no syscall
$cv(App\Sales\Orders\Command\CreateOrder::class, 'v2');

// Clear the cache (e.g. in tests after autoload changes)
$cache = new ClassResolutionCache();
$cache->clear();

The cache stores hits (results) and misses (exceptions) separately. A cached miss re-throws the originally thrown exception on the second call, without re-running the producer. This keeps the cache transparent even when resolutions fail.

ClassResolutionCache does not implement ClassVersionInterface. It is a plain helper cache for ClassVersion::__invoke() and has no resolution logic of its own.

Tracing (Debugging)

The TracingClassVersion decorator logs every resolution step:

php
use JardisSupport\ClassVersion\Support\TracingClassVersion;

$traced = new TracingClassVersion($classVersion);

$traced('App\Domain\Payment', 'v2');
$traced('App\Service\Calculator', 'v1');

$trace = $traced->getTrace();
// [
//     [
//         'requested' => 'App\Domain\Payment',
//         'version'   => 'v2',
//         'resolved'  => 'App\Domain\v2\Payment',
//         'type'      => 'class-string',
//     ],
//     [
//         'requested' => 'App\Service\Calculator',
//         'version'   => 'v1',
//         'resolved'  => 'App\Service\v1\Calculator',
//         'type'      => 'class-string',
//     ],
// ]

$traced->clearTrace();

Jardis Context: Builder + Platform-Dir

In Jardis Builder projects, ClassVersion is the mechanism that enables coexistence of generated and custom code. The builder writes its output exclusively under {Agg}/Platform/; custom code lives directly under {Agg}/ parallel to Platform/:

src/Sales/Orders/Order/
├── Command/
│   └── Handler/
│       └── CreateOrder.php            ← custom override (baseline, versionless)
├── v2/
│   └── Command/
│       └── Handler/
│           └── CreateOrder.php        ← custom override (version-specific)
└── Platform/                          ← ★ generator output only — never edit
    ├── Command/
    │   └── Handler/
    │       └── CreateOrder.php        ← generator base (rewritten on every build)
    └── Repository/
        └── HydrateOrder.php           ← generator base

On regeneration, only Platform/ is rewritten. Everything alongside it under {Agg}/ stays untouched: that is the team's directory. Per class, the team decides:

  • No override needed → generator base under Platform/<rest> is used directly
  • Project-wide override → baseline file directly under <Agg>/<rest> (empty segment, parallel to Platform/)
  • Version-specific override → file under <Agg>/v{N}/<rest>

Integration with BoundedContext

The version flows automatically through the entire context. Every handle() call within a generated {Domain}Context uses the version that was passed at the facade entry point:

php
// Entry via the Domain/BC facade: a new context with an explicit version
return $sales->context(PlaceOrderContext::class, $data, version: 'v2');

// In PlaceOrderContext::__invoke()
$validator = $this->handle(ValidateOrder::class);      // → <Agg>\v2\…\ValidateOrder (if present)
$order     = $this->handle(CreateOrder::class, $data); // → <Agg>\v2\…\CreateOrder
$hydrator  = $this->handle(HydrateOrder::class);       // → <Agg>\Platform\…\HydrateOrder (generator base, no override)
// All three are resolved automatically through the fallback chain

The calling code knows nothing about versions. It works with logical class names. The version is a property of the context, not of individual classes.

Architecture

ClassVersion                        ← Composite orchestrator
├── LoadClassFromProxy              ← Strategy 1: proxy registry (takes precedence)
├── LoadClassFromSubDirectory | LoadClassFromExtensions
│                                    ← Strategy 2: class finder
│   └── ClassVersionConfig          ← Labels, aliases, fallback chains
└── ClassResolutionCache            ← optional: memoization of hits & misses

Support/TracingClassVersion         ← Decorator: audit trail

Directory Structure

src/
├── ClassVersion.php                    ← Orchestrator
├── Data/
│   └── ClassVersionConfig.php          ← Configuration (labels, fallbacks)
├── Reader/
│   ├── LoadClassFromProxy.php          ← Proxy registry
│   ├── LoadClassFromSubDirectory.php   ← Namespace injection
│   └── LoadClassFromExtensions.php     ← Extensions layout resolver
└── Support/
    ├── ClassResolutionCache.php        ← Memoization helper
    └── TracingClassVersion.php         ← Tracing decorator

API Reference

ClassVersion

php
new ClassVersion(
    ClassVersionConfigInterface $versionConfig,
    ClassVersionInterface $classFinder,
    ?ClassVersionInterface $proxyClassFinder = null,
    ?ClassResolutionCache $cache = null,
)
MethodSignatureReturn
__invoke(string $className, ?string $version = null): mixedobject (proxy) or class-string

ClassVersionConfig

MethodSignatureDescription
__construct(array $version = [], array $fallbacks = [])Labels and fallback map
version(?string $version = null): ?stringResolve label → version key
fallbackChain(?string $version = null): arrayFull fallback chain

LoadClassFromSubDirectory

MethodSignatureDescription
__construct(ClassVersionConfigInterface $config)
__invoke(string $className, ?string $version = null): mixedFQCN of the versioned class

LoadClassFromExtensions

MethodSignatureDescription
__construct(int $depth, array $segmentNames = ['Extensions'], ?ClassVersionConfigInterface $versionConfig = null)Explicit layout convention; segmentNames lists one or more override layers in priority order, '' = no intermediate segment
__invoke(string $className, ?string $version = null): mixedFQCN from extensions or base layer

LoadClassFromProxy

MethodSignatureDescription
__invoke(string $className, ?string $version = null): mixedProxy instance or null
addProxy(string $className, object $proxy, ?string $version = null): selfRegister instance
removeProxy(string $className, ?string $version = null): selfRemove instance

ClassResolutionCache

MethodSignatureDescription
remember(string $key, callable $producer): mixedReturns cached value or runs producer once
clear(): voidClears hits and misses

TracingClassVersion

MethodSignatureDescription
__invoke(string $className, ?string $version = null): mixedDelegates + trace
getTrace(): arrayAll trace entries
clearTrace(): voidReset trace

Complete Example

php
use JardisSupport\ClassVersion\ClassVersion;
use JardisSupport\ClassVersion\Data\ClassVersionConfig;
use JardisSupport\ClassVersion\Reader\LoadClassFromExtensions;
use JardisSupport\ClassVersion\Reader\LoadClassFromProxy;
use JardisSupport\ClassVersion\Support\ClassResolutionCache;
use JardisSupport\ClassVersion\Support\TracingClassVersion;

// Configuration
$config = new ClassVersionConfig(
    version: [
        'v2' => ['v2', 'current'],
        'v1' => ['v1', 'legacy'],
    ],
    fallbacks: [
        'v2' => ['v1'],
    ],
);

// Platform-Dir layout (Jardis default) with proxy registry and resolution cache
$cv = new ClassVersion(
    $config,
    new LoadClassFromExtensions(depth: 3, segmentNames: ['', 'Platform'], versionConfig: $config),
    new LoadClassFromProxy($config),
    cache: new ClassResolutionCache(),
);

// Optional: tracing for debug sessions
$cv = new TracingClassVersion($cv);

// Case 1: versioned custom override exists (segment='', version v2)
$class = $cv('App\Sales\Orders\Repository\HydrateOrder', 'current');
// → 'App\Sales\Orders\v2\Repository\HydrateOrder'

// Case 2: no v2 override, versionless custom override (baseline) present
$class = $cv('App\Sales\Orders\Repository\ValidateOrder', 'current');
// → 'App\Sales\Orders\Repository\ValidateOrder' (segment='', versionless)

// Case 3: no custom override → generator base under Platform/
$class = $cv('App\Sales\Orders\Repository\PersistOrder', 'current');
// → 'App\Sales\Orders\Platform\Repository\PersistOrder'

// Create instance
$hydrator = new $class();

// Inspect the trace
foreach ($cv->getTrace() as $entry) {
    echo $entry['requested'] . ' [' . ($entry['version'] ?? 'default') . ']'
        . ' → ' . $entry['type'] . PHP_EOL;
}