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 strategies —
LoadClassFromSubDirectoryfor the classicv2/layout,LoadClassFromExtensionsfor multi-segment layouts with a clear separation between generator and custom code (default in Jardis Builder projects:segmentNames: ['', 'Platform']) - Fallback chain —
v2not available? Automatically fall back tov1or 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
ClassResolutionCachememoizes hits and misses, eliminating repeatedclass_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:
// 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:
| Sprint | v2 overrides | Effect |
|---|---|---|
| 1 | Payment, PaymentValidator | New payment logic, rest stays v1 |
| 2 | Invoice, InvoiceRenderer | New invoice format |
| 3 | Customer | Extended customer data |
| 4 | Label 'v2' → 'current' | v2 becomes default, v1 becomes 'legacy' |
Each sprint is independently deployable and rollbackable. No all-or-nothing migration.
Installation
composer require jardissupport/classversionGitHub: 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:
| Resolver | Injection point | Typical layout |
|---|---|---|
LoadClassFromSubDirectory | directly in front of the class name | App\Domain\v2\Payment |
LoadClassFromExtensions | at a configurable depth, with one or more intermediate segments | App\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\CalculatorThe class only needs to be declared in the correct namespace: PSR-4 takes care of the rest.
Basic Usage
Configuration
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
// 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 → returnIf 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:
$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
$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
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\CreateOrderThe 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 undersrc/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:
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.
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:
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 baseOn 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 toPlatform/) - 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:
// 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 chainThe 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 trailDirectory 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 decoratorAPI Reference
ClassVersion
new ClassVersion(
ClassVersionConfigInterface $versionConfig,
ClassVersionInterface $classFinder,
?ClassVersionInterface $proxyClassFinder = null,
?ClassResolutionCache $cache = null,
)| Method | Signature | Return |
|---|---|---|
__invoke | (string $className, ?string $version = null): mixed | object (proxy) or class-string |
ClassVersionConfig
| Method | Signature | Description |
|---|---|---|
__construct | (array $version = [], array $fallbacks = []) | Labels and fallback map |
version | (?string $version = null): ?string | Resolve label → version key |
fallbackChain | (?string $version = null): array | Full fallback chain |
LoadClassFromSubDirectory
| Method | Signature | Description |
|---|---|---|
__construct | (ClassVersionConfigInterface $config) | — |
__invoke | (string $className, ?string $version = null): mixed | FQCN of the versioned class |
LoadClassFromExtensions
| Method | Signature | Description |
|---|---|---|
__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): mixed | FQCN from extensions or base layer |
LoadClassFromProxy
| Method | Signature | Description |
|---|---|---|
__invoke | (string $className, ?string $version = null): mixed | Proxy instance or null |
addProxy | (string $className, object $proxy, ?string $version = null): self | Register instance |
removeProxy | (string $className, ?string $version = null): self | Remove instance |
ClassResolutionCache
| Method | Signature | Description |
|---|---|---|
remember | (string $key, callable $producer): mixed | Returns cached value or runs producer once |
clear | (): void | Clears hits and misses |
TracingClassVersion
| Method | Signature | Description |
|---|---|---|
__invoke | (string $className, ?string $version = null): mixed | Delegates + trace |
getTrace | (): array | All trace entries |
clearTrace | (): void | Reset trace |
Complete Example
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;
}