Skip to content

Compose all extensions across providers passed to container() #48

Open

Description

@coisa

Maintainer decision: default bug fix

The maintainer has explicitly requested this correction in container(...),
ContainerFactory::create(...) and ContainerBuilder::build() by default.
There must be no opt-in method, mode or flag.

The missing global extension pass predates extension-only support. The earlier
requirement for a factory hid part of the problem; fixing that requirement exposed
it. This is a correction of the existing composition bug, not a new opt-in feature.

  • Select the first callable factory for each ID in provider registration order.
    An intentional null result is a present shared entry and does not select a later factory.
  • After selection, apply all provider extensions in registration order, including
    extension-only providers registered before the factory. With no factory, start at null.
  • Use one build-local aggregate provider/adapter; retain lazy resolution, final-value
    caching, aliases, exact-class extensions, cycle detection and retry after failure.
  • Explicit AggregateServiceProvider retains its existing last-duplicate-factory
    policy; that policy must not be inherited accidentally by ordinary composition.
  • Selected factory/extension failures propagate with their causes; later factories,
    external sources or autowiring must not silently replace a broken selected entry.
  • Direct and configuration-discovered providers participate. PSR-11/config sources
    remain independent. Private source metadata in the existing AggregateContainer
    and provider state in the existing ServiceProviderContainer preserve each
    selected factory's original source position, including precedence against
    interleaved external sources. Extension-only defaults use the first extension's
    position only when no provider supplies a factory. Provider extensions do not
    decorate values selected from external sources; inferred class aliases retain
    their factory's source position. Dual-role PSR-11/provider objects remain PSR-11 sources. Configured lists
    retain the existing one-level expansion and optional-list error boundary.
  • Update tests, RST/PHPDoc, runnable examples, bundled consumer skill and an
    independent fixed/patch changelog fragment together.

Implementation submitted in PR #49,
updated at commit fbb7df58b9fbdf4759401fcaf7b7454bba80cfca. The PR is open as a draft.

The refactor removes FirstFactoryServiceProvider and ProviderSourceContainer.
Composition state is private to existing classes; no additional runtime classes or
per-provider wrappers are exposed. Factory precedence, shared caches, aliases,
source positions and failure propagation are preserved.

Local validation passed 285 tests / 988 assertions on PHP 8.3.35, 8.4.26 and
8.5.11, with 93.92% line coverage. All four isolated production combinations
(PHP 8.3/8.5 × minimum/current dependencies) passed locally: 33 tests and 205
assertions each. The executable example, focused ECS/Rector and changelog
contribution checks passed. GrumPHP Composer/PHP/JSON/YAML checks passed independently.

The local commit hook's broad DevTools composite remains blocked by existing
tooling/baseline failures; its bypass is disclosed in the PR. Remote checks for
this new commit are running. This issue remains open; no merge or release occurred.

The reproduction and investigation below describe the original affected behavior.

Summary and intent

Support composing all Interop extensions across providers passed to container(...), instead of ending provider processing at the first source that resolves the entry.

The goal is to select a factory/value for an entry and then apply every participating provider's extension in a defined order, including an extension provider registered before the factory provider. This is a request to correct the default composition bug while preserving first-callable-factory precedence, extension-only support and the existing container architecture.

Environment and verified scope

  • PHP 8.5.11; fast-forward/container 1.10.1, source a75dda2c8a9bd9cb2ef5bb25abc1a6b09de8a298.
  • fast-forward/config 1.7.3; container-interop/service-provider 0.4.1.
  • The latest published Container release reported by GitHub during this investigation is v1.10.1. Current main at 991363df0fcac1147e4e65959446d581c41ebb6f has byte-identical ContainerBuilder, ServiceProviderContainer and AggregateContainer source files for this path.
  • The minimal example below ran against the real installed package: exit 0, empty stderr. A wider 25-case consumer probe also checked real provider selection, cached values/nulls, aliases and explicit factory failures; this issue keeps only the smallest reproducer.

Minimal executable reproduction

In an empty temporary project:

composer --no-plugins --no-scripts require fast-forward/container:1.10.1 --no-interaction
php reproduce.php

Save this as reproduce.php:

<?php

declare(strict_types=1);

use FastForward\Container\ServiceProvider\AggregateServiceProvider;
use Interop\Container\ServiceProviderInterface;
use Psr\Container\ContainerInterface;

use function FastForward\Container\container;

require ($argv[1] ?? __DIR__ . '/vendor/autoload.php');

$factoryProvider = new class implements ServiceProviderInterface {
    public function getFactories(): array
    {
        return ['demo' => static fn(ContainerInterface $container): string => 'factory'];
    }

    public function getExtensions(): array
    {
        return [];
    }
};

$extensionProvider = new class implements ServiceProviderInterface {
    public function getFactories(): array
    {
        return [];
    }

    public function getExtensions(): array
    {
        return [
            'demo' => static fn(ContainerInterface $container, ?string $previous): string =>
                ($previous ?? 'missing') . '|extended',
        ];
    }
};

$cases = [
    'separate: factory, extension' => [$factoryProvider, $extensionProvider],
    'separate: extension, factory' => [$extensionProvider, $factoryProvider],
    'merged: factory, extension' => [new AggregateServiceProvider($factoryProvider, $extensionProvider)],
    'merged: extension, factory' => [new AggregateServiceProvider($extensionProvider, $factoryProvider)],
];

foreach ($cases as $label => $providers) {
    $services = container(...$providers);
    echo $label, ': ', $services->get('demo'), PHP_EOL;
}

Actual output

separate: factory, extension: factory
separate: extension, factory: missing|extended
merged: factory, extension: factory|extended
merged: extension, factory: factory|extended

With the factory provider first, resolution returns before consulting the other provider's extension. With the extension provider first, its local missing factory supplies null, so it produces an entry and the later factory is never reached.

Desired capability

Both provider orders must produce factory|extended by default through the helper, factory and builder, without an opt-in mode. Multiple extension providers must all participate, with extension order explicitly defined. Factory conflicts need their own declared precedence; this example has only one factory and does not require reversing the existing precedence.

The versioned Interop contract specifies a factory pass across providers followed by an extension pass, so an earlier provider can extend a later provider's entry. Its null-entry contract also requires an intentional null result to remain a present shared entry.

Source diagnosis and chronology

ContainerBuilder adapts each provider separately. Each ServiceProviderContainer reads only its own maps. The surrounding AggregateContainer returns the first successful source. Consequently it aggregates PSR-11 lookups rather than running a global Interop extension pass.

The maintainer highlighted why this is more visible after extension-only support. That chronology is also supported by the source diff in PR 12, merged on 2026-10-12: previously an adapter required a factory before resolving an entry and only reached extension application after constructing it; the correction made an extension-only entry discoverable and allowed it to start with null.

The global composition limitation already existed: a successful first provider prevented later providers' extensions from running. Extension-only support now also makes a provider containing only an extension eligible to resolve and potentially shadow a later factory. This is not described as a new regression caused by that fix, and extension-only/null/return-value support must be retained.

Closed issue 11 deliberately preserved the distinction between separate first-source composition and merged definitions. Its completed fix should not be reopened or silently reinterpreted. The maintainer decision above now explicitly corrects the default global extension pass while preserving factory precedence. The completed extension-only/null/return-value fixes remain required.

Current workaround and compatibility decision

Explicitly merging the two providers already works:

$services = container(
    new AggregateServiceProvider($factoryProvider, $extensionProvider)
);

AggregateServiceProvider selects later duplicate factories and composes extensions in registration order. This differs from the helper's first-successful-source behavior.

The maintainer decision is to fix ordinary helper/factory/builder composition by default, with the first callable factory winning and every extension participating afterward. No opt-in API is wanted. Explicit AggregateServiceProvider retains last-factory precedence. The mixed-source boundary is stated in the decision above and must be covered by regressions.

Acceptance criteria

  • Decide the policy: default bug fix, no opt-in; first callable factory wins; all extensions run in registration order. Preserve explicit AggregateServiceProvider's distinct precedence.
  • Add meaningful PHPUnit regressions for both provider orders above and multiple extension providers, proving every selected extension runs exactly once after factory selection.
  • Preserve absent-factory defaults, explicit null/scalar/object values, replacement returns, declared-void compatibility and cached final identity/null. Do not equate an intentional null result with absence accidentally.
  • Verify aliases, exact class extensions, cycles, retry after failed resolution, and that failed/incomplete extension chains are not cached.
  • Define construction/extension failure propagation. The current aggregate can try later sources after a PSR-11 construction failure; do not accidentally turn a broken required factory or extension into a silent successful default.
  • Cover mixed external PSR-11 containers, Config sources and nested configured providers, class-string initializers, fresh builder builds and wrapper injection without changing unrelated autowiring behavior.
  • Update the relevant RST guides, runnable examples, PHPDoc, bundled fast-forward-container skill/references/examples and an independent changelog fragment together. State the actual composition/compatibility policy clearly.
  • Validate supported PHP runtimes and isolated production dependency graphs through the existing suite/CI.

No Error Handler request negotiation, transient-lifetime API, publication or repository-setting changes are part of this issue.

Activity

  1. changed the title [-][RFC / Architecture] Compose all extensions across providers passed to container()[/-] [+][Bug] Apply all provider extensions by default while preserving factory precedence[/+] on Oct 21, 2026
  2. changed the title [-][Bug] Apply all provider extensions by default while preserving factory precedence[/-] [+]Compose all extensions across providers passed to container()[/+] on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions