Skip to content

Latest commit

 

History

History
507 lines (361 loc) · 19.3 KB

UPGRADE.md

File metadata and controls

507 lines (361 loc) · 19.3 KB

Upgrade Guide

Unreleased

Source stamps include content hashes

Regenerate plan and factory caches after upgrading. Source stamps now include SHA-256 hashes, so same-size edits with unchanged timestamps invalidate stale entries. Older metadata-only formats are rejected. Matching explicit build stamps retain their existing whole-file trust behavior.

Persisted shared plans require a release stamp

Pass the same non-empty identifier of the loaded release to PlanCache::fromFile() and writeTo():

$stamp = Release::ID;
$plans = PlanCache::fromFile($file, $stamp);
// Share $plans with your containers.
$plans->writeTo($file, $stamp);

Without it, writes omit user-class plans and reads fall back to reflection. Native internal-class plans remain cacheable without a stamp. Existing in-memory sharing is unchanged. Load the release identifier at worker startup, keep that release's sources immutable and restart workers on deployment.

Regenerate plan and factory cache files after upgrading. The new format rejects older files whose metadata may describe a different release than the code that produced their plans. See shared plan persistence.

CLI strict flag

Replace report --strict=true or report --strict=1 with report --strict. The flag rejects all values, including false and empty values. Omit it to disable strict reporting.

Factories in data files

JSON and YAML files reject callable arrays in factory and singleton entries. Use a PHP definitions file for executable factories:

return [
    'service' => ['factory' => [ServiceFactory::class, 'create']],
];

Class-string singleton definitions remain supported in JSON and YAML. See definition formats.

Variadic dependencies

An absent variadic parameter now receives zero arguments. A global registration or an autowirable class no longer adds one argument implicitly. If a variadic dependency needs one service, select it explicitly with a typed or named contextual binding:

$container->when(EventBus::class)->needs(Handler::class)->give($handler);
// Or bind the parameter by name:
$container->when(EventBus::class)->needs('$handlers')->give($handler);

A runtime override such as make(EventBus::class, ['handlers' => $handler]) also supplies one value. Arrays are not spread automatically. See variadic parameters.

Optional class dependencies

Class-typed parameters with defaults now receive applicable registrations before falling back to their declared values. If a consumer must keep null even when its type is registered globally, bind that parameter by name:

$container->when(Report::class)
    ->needs('$logger')
    ->give(null);

See constructor registration precedence.

Named tag lookups

taggedByKey() no longer accepts integer positions. If a tag used integer-like names such as '1' and '2', change both registration and lookup to names PHP keeps as strings:

$container->tag([
    'v1' => V1::class,
    'v2' => V2::class,
], 'api.versions');

$handler = $container->taggedByKey('api.versions', 'v1');

See keyed tag names for the key policy.

Invokable object bindings

Objects passed to bind(), singleton(), or type-based contextual give() are now injected unchanged even when they declare __invoke. If you used such an object as a factory, wrap it in a closure:

// Before: the object was invoked as a factory.
$container->bind(HandlerInterface::class, $factory);

// After: explicitly invoke it to build the service.
$container->bind(HandlerInterface::class, Closure::fromCallable($factory));

The same wrapper works with singleton() and type-based give(). See bindings for the supported instance and factory forms.

Explicit array offsets

Array access accepts string and integer service ids. Append syntax now throws TypeError; give the entry an explicit id instead:

// Before: append silently registered the service under an empty id.
$container[] = $service;

// After: choose the service id.
$container['service'] = $service;

Boolean, floating-point, array and object offsets are also rejected with TypeError. Pass the intended string id explicitly. An explicit empty-string id ($container['']) continues to work. See array access.

Cache source stamps

Regenerate compiled plans and factories after upgrading. Format 2 records inherited and inlined source files; loaders reject older formats even when the build stamp matches. Rebuild from your configured container and entry points:

$classes = [UserService::class, OrderService::class];
$container->writeCompiledCache($classes, __DIR__ . '/cache/container.php');
$container->writeCompiledFactories($classes, __DIR__ . '/cache/factories.php');

Keep passing your existing build stamp as the third argument if you use one. PlanCache::fromFile() starts empty when given an older format. See staleness checks.

Keep source files available when building caches. Classes defined through eval() or whose source files are unavailable are omitted; runtime resolution still works. Native internal classes remain cacheable.

Cache ownership and permissions

Compiled PHP caches now require trusted ownership and permissions before they are loaded. If a deploy account writes the cache and a different account runs PHP, generate the cache as the runtime account or transfer ownership during deployment. For a runtime account named www-data and a cache directory whose canonical ancestors already belong to root or that runtime account:

chown www-data var/cache var/cache/container.php var/cache/factories.php
chmod 0600 var/cache/container.php var/cache/factories.php
chmod 0700 var/cache

If the deployment tree belongs to another account, keep caches outside it in a runtime-owned directory such as /var/cache/gacela, and update the configured load and write paths. Every canonical ancestor must belong to root or the runtime account. See cache file permissions. New and rewritten caches are private to their writer; a deployment that shares caches across accounts must arrange ownership before loading them.

Extending bindings

extend() now throws a ContainerException for an id registered with bind() or singleton(). Previously it returned normally but never applied the extension. Register the service through set() before extending it.

To keep a bind() service creating a new instance on every resolution, use factory():

// Before: the extension was ignored.
$container->bind(LoggerInterface::class, FileLogger::class);
$container->extend(LoggerInterface::class, static fn ($logger) => new LoggerDecorator($logger));

// After: each resolution creates and decorates a new logger.
$container->set(LoggerInterface::class, $container->factory(
    static fn () => new FileLogger('/var/log/app.log'),
));
$container->extend(LoggerInterface::class, static fn ($logger) => new LoggerDecorator($logger));

For a shared service previously registered with singleton(), pass the closure to set() without wrapping it in factory().

Binding factory results

Binding factories now return their values unchanged. A returned class-name string is a value; it no longer causes another object to be constructed. To register an object through its class name, pass the class name directly or construct the object in the factory:

// Before: the returned class name was instantiated implicitly.
$container->bind(LoggerInterface::class, static fn () => FileLogger::class);

// After: register the class directly.
$container->bind(LoggerInterface::class, FileLogger::class);

// Or return the constructed object.
$container->bind(LoggerInterface::class, static fn () => new FileLogger('/var/log/app.log'));

2.0.x → 2.1

One change in runtime behaviour, with nothing to write on your side.


A registration now answers a constructor parameter too

Nested resolution used to consult the bindings and nothing else. Everything the container answers from its instance registry, and everything reached through an alias, was therefore invisible to it — so a class asking for such an id by type was autowired a second copy:

$container->set(TaxRates::class, new TaxRates(0.21));

$container->get(TaxRates::class);   // the registered instance, as always
$container->get(Invoicing::class);  // 2.0: a *different*, autowired TaxRates
                                    // 2.1: the registered instance

Affects set(), factory(), protect(), extend(), alias() and a ['value' => …] or ['factory' => …] definition. Bindings, singleton() and lazy() already behaved this way, and so did most of these inside a scope — a scope delegates an id it does not own to its parent, which resolves it with get(). This brings a root container in line with that, and fixes the one case a scope did not cover either: an alias() whose target is an autowirable class, which the delegation reports no ownership of.

Action: none, and in most applications nothing observable changes — the two copies were usually equivalent. Three cases where it does:

  • A class that cannot be autowired. This is the failure that motivated the change: the container built the class anyway and threw DependencyInvalidArgumentException naming a scalar parameter of a class you never asked it to build, usually while resolving something unrelated. If you worked around it by also registering a bind(), that workaround is now redundant and still correct.
  • Instance identity. A consumer and a direct get() now hand back the same object. Code that depended on the consumer getting its own copy should register a factory(), which is honoured per injection.
  • A protect()ed closure under a class-string id. protect() means "hand this closure back rather than call it", so the closure is the service and a parameter typed as the class rejects it with a TypeError instead of silently receiving an autowired object. This is what a scope has always done. Register protected closures under a non-class id, which is what they are for.

Frozen state follows the same rule, because it is the same read: a service injected into a constructor is now isFrozen() afterwards, exactly as one handed out by get() is.


1.x → 2.0

Four changes can require action. The first two affect only code that implements a container interface; the third changes runtime behaviour for callers, with no code change on their side; the fourth is a rename.


1. ContainerInterface declares the whole surface

1.x promised nothing would be added to ContainerInterface, which is why most of the 1.2–1.5 feature set was reachable only through the concrete final class Container — and why 1.5 introduced FullContainerInterface as an additive answer. 2.0 merges them: createScope(), provides(), stats(), lazy(), load(), loadFile(), taggedByKey(), taggedKeys(), dependencyGraph(), compileReport(), writeCompiledFactories(), useCompiledFactories(), validate() and withSelfReference() are all on ContainerInterface now.

If you only ever type-hint or call a container, there is nothing to do.

If you implement ContainerInterface yourself — a test double, most likely — it must now declare those methods. The compiler will tell you exactly which. Three options:

// 1. Wrap a real container and forward. The interface then enforces
//    completeness for you: a method added upstream fails compilation here
//    rather than silently going missing.
final class MyContainer implements ContainerInterface
{
    public function __construct(private Container $inner) {}

    public function get(string $id): mixed { return $this->inner->get($id); }
    // ...
}

// 2. In tests, use the real container. It has no I/O and no global state.
$container = new Container();

// 3. Type-hint PSR-11 instead, if all you need is get()/has().
use Psr\Container\ContainerInterface as PsrContainer;

FullContainerInterface still exists and still works — it is now an empty, deprecated alias extending ContainerInterface, so a 1.5 type-hint keeps compiling and keeps accepting a Container. Switch to ContainerInterface when convenient; the alias goes at 3.0.


2. load() and loadFile() return the ids they registered

Were void:

$ids = $container->loadFile('config/services.php');
// ['App\Mailer', 'db.dsn', 'repository']

This is the only reliable answer to "what did this source register" — reading the ids back off the container afterwards catches bind() and set() entries and misses aliases, which live in a third registry.

Nothing breaks for a caller: ignoring a return value is always valid. Only an implementor of ContainerInterface has to change the signature.

The optional listener added in 1.5 still works, for a consumer that wants the ids one at a time as they are registered rather than as a list:

$container->loadFile($file, static fn (string $id) => $events->dispatch(new Registered($id)));

3. afterResolving() on a class or interface matches every instance

This one has no compiler error to guide you — it changes behaviour at runtime.

A hook registered against a class or interface id now fires for every resolved instance of it, where 1.x fired only when that exact id was the one asked for:

$container->afterResolving(LoggerAwareInterface::class, $hook);

$container->get(FileLogger::class);   // 1.x: hook does not fire
                                      // 2.0: hook fires

That is the point — it is what makes "after anything implementing X is built, do this" expressible at all. But if you registered a hook against a class id and relied on it firing only for a direct get() of that id, it now runs more often. Ids that are not a class or interface are unchanged and still match exactly.

Two smaller changes come with it:

  • Hooks fire for make() with overridden arguments, which previously skipped them entirely.
  • A callback that throws now removes the instance from the container, rather than leaving a half-wired service for the next caller. The exception propagates either way.

What to check: any afterResolving() call whose first argument is a class or interface name. If the hook is idempotent — the usual case, since it is wiring — there is nothing to do.


4. ContainerStats::memoryUsageBytes is processMemoryBytes

A rename, announced in 1.5. The value is unchanged — it always was memory_get_usage(true) for the whole PHP process, never this container's footprint, and the old name invited the wrong reading.

$container->stats()->memoryUsageBytes;        // 1.x
$container->stats()->processMemoryBytes;      // 2.0

$container->stats()->memoryUsageFormatted();  // 1.x
$container->stats()->processMemoryFormatted();// 2.0

getStats() is untouched and keeps its memory_usage key — that array shape has never been covered by semver, so nothing there moves.


0.10.0 → 1.0.0

Four changes can require action. Most applications need only the PHP bump.

From 1.0 onward, changes like these require a new major version — see the backward compatibility policy.


1. PHP 8.3 is now the minimum

Was >=8.1. PHP 8.1 and 8.2 are past end of security support, and raising the floor is itself a breaking change — so it had to happen before the freeze rather than during 1.x.

 "require": {
-    "php": ">=8.1"
+    "php": ">=8.3"
 }

Action: upgrade to PHP 8.3 or newer. 8.3, 8.4, and 8.5 are covered by CI.


2. Container is final

final was removed in 0.4.0 to allow decorating by subclassing. That is no longer supported: Container has no protected members, so a subclass could only ever reach the public API — which ContainerInterface already gives you.

Action: replace inheritance with composition.

// Before
final class MyContainer extends Container
{
    public function get(string $id): mixed
    {
        $this->logger->debug('resolving', ['id' => $id]);

        return parent::get($id);
    }
}

// After
final class MyContainer implements ContainerInterface
{
    public function __construct(
        private ContainerInterface $inner,
        private LoggerInterface $logger,
    ) {}

    public function get(string $id): mixed
    {
        $this->logger->debug('resolving', ['id' => $id]);

        return $this->inner->get($id);
    }

    // delegate the remaining methods to $this->inner
}

For a single service rather than the whole container, you probably do not need a wrapper at all — extend() decorates one binding, and afterResolving() runs a callback after an id resolves.


3. has() now reports what get() will actually resolve

has() previously consulted only the instance registry, so under autowiring it returned false for ids that get() resolves without complaint — which breaks the standard PSR-11 pattern.

$container = new Container();

// Before
$container->has(Greeter::class);   // false
$container->get(Greeter::class);   // ...returns a Greeter anyway

// After
$container->has(Greeter::class);   // true

has() is now true for stored instances, bindings, and any instantiable class. isset($container[$id]) follows it.

bound() is unchanged and still answers the narrower question — was this id explicitly registered?

has() bound()
stored instance ✅ ✅
binding ✅ ✅
autowirable class ✅ ❌
unbound interface ❌ ❌
abstract class ❌ ❌

Action: if you used has() to mean "was this registered?", switch to bound(). If you used it to guard a get() call, it now behaves correctly and needs no change.


4. ContainerInterface gained methods and extends ArrayAccess

Only affects you if you implement ContainerInterface yourself. Consumers that merely type-hint it need no change.

Added: when(), compile(), writeCompiledCache(), getStats(), plus the four ArrayAccess methods (offsetGet, offsetSet, offsetExists, offsetUnset).

This was deliberate: adding a method to a published interface breaks every implementer, so the interface had to reach its final shape before 1.0. It will not gain further methods during 1.x.

Action: implement the new methods, or delegate them to a wrapped Container.


Also worth knowing

  • @internal classes. The nine resolver internals (DependencyResolver, AliasRegistry, FactoryManager, …) are now explicitly outside the BC promise. If you imported one, move off it — it may change in any release.
  • getStats() return shape is explicitly excluded from BC. Do not build logic on it.
  • psr/container is now constrained to ^1.1 || ^2.0 rather than the unbounded >=1.1. Both are tested.
  • Compiled caches must be regenerated. The file written by writeCompiledCache() is tied to the version that produced it.

Nothing to do for

Binding registration, contextual bindings, tags, attributes, make(), getOrFail(), resolve(), runtime parameters, and afterResolving() are all unchanged from 0.10.0.