Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/compose-provider-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Fixes cross-provider extension composition by default in `container()`, `ContainerFactory::create()` and `ContainerBuilder::build()`. The first callable factory keeps precedence and all provider extensions run in registration order after selection, including earlier extension-only providers. Selected provider failures propagate instead of being masked by fallback. Explicit `AggregateServiceProvider` retains its last-factory precedence.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,4 @@ jobs:
run: |
CONTAINER_CONSUMER_AUTOLOAD="$CONSUMER_DIR/vendor/autoload.php" php "$HARNESS_DIR/vendor/bin/phpunit" \
--no-configuration --no-coverage --no-extensions \
--bootstrap tests/Integration/bootstrap.php tests/Integration/ProductionConsumerTest.php
--bootstrap tests/Integration/bootstrap.php tests/Integration/ProductionConsumerTest.php tests/Integration/ProviderCompositionTest.php
4 changes: 3 additions & 1 deletion docs/advanced/autowire.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ with autowiring.

Register sources before the first lookup: successful resolutions are cached.
If a delegate's ``get()`` throws a PSR-11 exception, aggregate fallback may try
another source. If every source fails, construction errors are preserved; see
another source. Selected provider factory/extension failures in helper, factory
and builder composition propagate immediately instead. If every independent
external source fails, construction errors are preserved; see
:doc:`special-patterns` for the full fallback rules.

Optional Dependencies
Expand Down
54 changes: 49 additions & 5 deletions docs/advanced/container-helper.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,49 @@ share their own caches and services across compositions.
The runnable ``examples/07-incremental-builder.php`` demonstrates conditional
registration and the independence of previously built containers.

Cross-provider composition
--------------------------

The helper, factory and builder compose all registered provider extensions by
default. No opt-in or additional entry point is required:

.. code-block:: php

$helper = container($extensionProvider, $factoryProvider);
$factory = ContainerFactory::create($extensionProvider, $factoryProvider);
$builder = (new ContainerBuilder())->add($extensionProvider, $factoryProvider)->build();

Providers are collected before resolving an entry. The first callable factory for
an ID wins, preserving factory precedence. All provider extensions run in
registration order after factory selection, including extensions registered
before that factory. If no provider has a factory, extensions start with null.
Successful final values are cached, including null; void extensions retain their
input and other returns replace it. Aliases and exact concrete-class extensions
retain the provider adapter's identity and cycle rules.

Direct and configuration-discovered providers share one build-local provider
adapter. ``AggregateContainer`` retains each source's position and inferred alias
origin as private state; ``ServiceProviderContainer`` owns definitions, extension
composition and caches. This preserves factory precedence against interleaved
PSR-11/Config sources without additional wrapper classes.
Extension-only defaults use the first extension position only when no provider has
a factory. External values remain independent and are not decorated by the group. Dual-role PSR-11/provider objects remain external PSR-11 sources.
Configured lists expand one level; invalid optional entries stop that list while
retaining prior entries and later explicit initializers. Class strings are
instantiated for each build.

Once a provider factory or extension is selected, construction/extension failures
propagate with their causes. Later factories, external sources and native
autowiring do not replace a broken selected entry. Failed/incomplete chains are
not cached and may be retried. Absence permits later sources. External PSR-11
sources retain aggregate fallback semantics.

Each build gets fresh adapters, provider caches and a completed aggregate wrapper
for cross-source dependencies. Supplied external containers and captured objects
may still share state. Explicit ``AggregateServiceProvider`` retains its distinct
last-factory-wins policy. Older releases may lack this correction; run
``examples/08-provider-composition.php`` against the installed library to verify it.

Supported initializers
----------------------

Expand All @@ -66,7 +109,7 @@ Initializer Behavior
==================================== ================================================================
``ConfigInterface`` Wrapped in ``ConfigContainer``
``Psr\Container\ContainerInterface`` Added directly to the aggregate
``ServiceProviderInterface`` Wrapped in ``ServiceProviderContainer``
``ServiceProviderInterface`` Collected into the build's shared provider adapter
``string`` Instantiated with ``new`` and then resolved using the same rules
==================================== ================================================================

Expand All @@ -77,13 +120,14 @@ How It Works

1. Each initializer is resolved to a container instance:
- If it's already a container, it's used as-is.
- If it's a service provider, it's wrapped in a ServiceProviderContainer.
- Service providers are collected into one build-local aggregate provider.
- If it's a config, it's wrapped in a ConfigContainer.
- If it's a string, the class is instantiated and then resolved as above.
- Unknown class names and unsupported constructed objects throw an InvalidArgumentException.
2. All resolved containers are appended to an AggregateContainer.
3. If a ConfigContainer is present, it may provide nested initializers, which are also resolved and appended.
4. The final result is always an AutowireContainer wrapping the aggregate.
2. Provider definitions select the first callable factory and compose every extension in registration order.
3. The provider adapter and independent resolved containers are appended to an AggregateContainer.
4. If a ConfigContainer is present, it may provide nested initializers, which are also resolved and appended.
5. The final result is always an AutowireContainer wrapping the aggregate.

Resolution order
----------------
Expand Down
22 changes: 14 additions & 8 deletions docs/advanced/special-patterns.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,10 @@ container exception preserving the original cause. Failed resolutions are not
cached and can be retried. An ID with neither a callable factory nor a callable
ID-based extension remains unknown.

When combining providers, use ``AggregateServiceProvider`` to select factories
across all providers before applying their extensions in registration order.
An extension-only provider may precede the provider supplying its factory.
Passing providers separately to ``container()`` keeps first-match-wins semantics.
The helper, factory and builder select the first callable factory across providers,
then apply every extension in registration order by default. An extension-only
provider may precede the provider supplying its factory. Explicit
``AggregateServiceProvider`` also composes extensions but uses the last factory.
The final ``AutowireContainer::has()`` also checks entry discovery without calling
``get()``. A ``true`` result does not guarantee that construction will succeed;
resolution errors are raised by ``get()``.
Expand All @@ -128,11 +128,13 @@ Provider merge semantics

There are two distinct composition models:

- ``container($providerA, $providerB)`` keeps providers separated and resolves from the first one that matches.
- ``container($providerA, $providerB)`` selects the first callable factory and composes all extensions.
- ``AggregateServiceProvider($providerA, $providerB)`` merges factories first, so later factory keys overwrite earlier ones.

Use the first model for overrides and fallbacks. Use the second model when you want to
ship one combined provider object.
Use the first model when earlier factories should take precedence. Use the second
to ship one combined provider object with later-factory precedence. Selected
provider failures propagate in helper/factory/builder composition; explicit
PSR-11 sources retain aggregate fallback semantics.

Aggregate container aliases and self-resolution
-----------------------------------------------
Expand Down Expand Up @@ -167,7 +169,11 @@ Fallback and recovery behavior

When ``AggregateContainer`` checks its child containers, it keeps going when a child says
it has the entry but then throws a PSR not-found or container exception. This allows later
containers to recover from partial or inconsistent registrations.
containers to recover from partial or inconsistent registrations. The helper,
factory and builder register selected provider entries as required sources through
``appendRequired()``: their factory/extension failures propagate immediately,
including nested not-found errors. This prevents a broken selected definition
from becoming a successful fallback or autowired default.

Wrapper container for delegation
--------------------------------
Expand Down
13 changes: 10 additions & 3 deletions docs/api/core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ inside the FastForward ecosystem while staying compatible with PSR-11 consumers.
``AggregateContainer``
----------------------

``AggregateContainer`` combines multiple PSR-11 containers and resolves them in order.
``AggregateContainer`` combines ordered PSR-11 containers and Interop providers.
Provider registrations share one ``ServiceProviderContainer``; source positions,
required-entry policy and inferred alias origins are private aggregate state.

Built-in aliases:

Expand All @@ -125,7 +127,8 @@ Resolution rules:

- If an entry was already resolved once, the cached value is returned.
- Otherwise, child containers are checked from first to last.
- If one child reports ``has($id)`` but throws a PSR not-found or container exception during ``get($id)``, the aggregate keeps checking later containers.
- External PSR-11 sources permit fallback after ``get()`` throws a PSR not-found or container exception.
- Provider sources select the first callable factory and apply all extensions in registration order. Selected provider failures propagate immediately; external values remain independent.
- If no source succeeds, the first construction exception is rethrown. Lookups that only encounter missing entries throw ``NotFoundException``.

Useful methods:
Expand Down Expand Up @@ -176,7 +179,11 @@ interface bindings, scalar arguments and fallback behavior.
``ServiceProviderContainer``
----------------------------

``ServiceProviderContainer`` turns a ``ServiceProviderInterface`` into a PSR-11 container.
``ServiceProviderContainer`` turns a ``ServiceProviderInterface`` or an ordered list
of providers into a PSR-11 container. A list selects the first callable factory and
applies every extension in registration order. Passing an explicit
``AggregateServiceProvider`` retains that object's last-factory policy. Metadata is
read lazily; all results and class-extension state belong to this adapter.

Important behavior:

Expand Down
9 changes: 7 additions & 2 deletions docs/api/providers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ Use ``AggregateServiceProvider`` when:

Pass providers separately to ``container()`` when:

- you want first-match-wins resolution between providers
- you want the first callable factory for each ID to win
- you want easy override layering
- you want to keep provider boundaries visible in the final aggregate container
- you want all registered provider extensions applied after factory selection

The helper, factory and builder compose providers by default. They share one
provider adapter per build. Selected construction or extension failures propagate;
PSR-11/config sources remain independent. See :doc:`../advanced/container-helper`.
An explicitly supplied ``AggregateServiceProvider`` retains last-factory precedence.
6 changes: 4 additions & 2 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ How do collisions work when multiple providers define the same ID?

It depends on how you combine them:

- ``container($providerA, $providerB)``: the first matching provider wins
- ``container($providerA, $providerB)``: the first callable factory wins
- ``new AggregateServiceProvider($providerA, $providerB)``: later factory keys overwrite earlier ones

This difference is intentional, so choose the model that matches your override strategy.
Both forms apply all provider extensions after factory selection, in registration
order. This factory-precedence difference is intentional; choose the model that
matches your override strategy.

Can I resolve the container itself from inside a factory?
---------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion docs/getting-started/basic-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ or when you need to reuse an existing PSR-11 container:

$container = container($catalogProvider, $legacyContainer);

When multiple initializers can resolve the same service ID, the first successful
Provider factories use first-callable precedence and all extensions run afterward
in registration order. External sources remain independent. When multiple external
initializers can resolve the same service ID, the first successful
source wins. Explicit sources are checked before autowiring, including for nested
and optional object dependencies. This makes ``container($override, $defaults)``
a useful pattern for tests and environment-specific overrides. Configure the sources
Expand Down
5 changes: 3 additions & 2 deletions docs/providers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ different:
* - Approach
- Resolution behavior
* - ``container($providerA, $providerB)``
- The first provider that successfully resolves the ID wins
- The first callable factory wins; all extensions run in registration order
* - ``new AggregateServiceProvider(...)``
- Factories are merged first, so later providers overwrite earlier keys

Use ``container($providerA, $providerB)`` when you want layered fallback or overrides.
Use ``container($providerA, $providerB)`` when earlier factory definitions should take precedence.
Both forms apply every participating extension after factory selection.
Use ``AggregateServiceProvider`` when you want to publish one merged provider as a single unit.

.. toctree::
Expand Down
15 changes: 13 additions & 2 deletions docs/providers/using-providers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ Two details are easy to miss:
- Factories are merged in provider order, so later providers overwrite earlier keys when the same service ID exists in both.
- Extensions with the same key are composed in provider order instead of overwritten.

If you want first-match-wins semantics instead of merge semantics, do not aggregate the
providers first. Pass them separately to ``container($providerA, $providerB)``.
Pass providers directly to ``container($providerA, $providerB)`` to preserve the
first callable factory for each ID. Both forms compose every extension in order;
explicit ``AggregateServiceProvider`` uses the last duplicate factory.

``ServiceProviderContainer``
----------------------------
Expand All @@ -101,3 +102,13 @@ Important behavior:
- An ID-based extension can provide an entry without a factory; it receives ``null`` as the previous value.
- Each extension's return value becomes the next value in the resolution chain. Extensions declared ``void`` retain the previous value for compatibility.
- Factories and extensions receive the wrapper container, which defaults to the ``ServiceProviderContainer`` instance itself.

Cross-provider extensions
-------------------------

The helper, factory and builder compose all provider extensions by default,
after selecting the first callable factory for the ID. Earlier extension-only
providers no longer shadow later factories. Extensions run in registration order.
Selected factories retain their source positions, including against interleaved
external PSR-11/config sources. External entries are independent. Selected provider failures propagate instead of falling back.
See :doc:`../advanced/container-helper` for mixed-source and lifetime boundaries.
19 changes: 19 additions & 0 deletions examples/08-provider-composition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/**
* This file is part of php-fast-forward/container.
*
* This source file is subject to the license bundled
* with this source code in the file LICENSE.
*
* @copyright Copyright (c) 2025-2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
* @license https://opensource.org/licenses/MIT MIT License
*
* @see https://github.com/php-fast-forward/container
* @see https://github.com/php-fast-forward
* @see https://datatracker.ietf.org/doc/html/rfc2119
*/

require __DIR__ . '/../resources/skills/fast-forward-container/examples/provider-composition.php';
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ The examples are ordered from the most common onboarding flow to the more specia

- `php examples/07-incremental-builder.php --with-feature`: register sources in stages with `ContainerBuilder`, enable an optional provider, and keep previous builds unchanged. Omit the flag to run without the feature.

- `php examples/08-provider-composition.php`: use default cross-provider extensions, verifying both factory/provider orders and shared final values.

Auxiliary files:

- `examples/bootstrap.php`: loads the Composer autoloader and provides small CLI helper functions shared by the examples.
5 changes: 3 additions & 2 deletions resources/skills/fast-forward-container/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ It must print `configuration: ok`; mismatches throw regardless of PHP's `assert(

## Preserve resolution and lifetime rules

- Separate sources are tried in order: `container($testOverrides, $config, $defaults)`. The first successful source wins. Explicit entries precede native autowiring, including nested and optional dependencies. Later sources may recover from PSR-11 errors thrown by a source's `get()` after its `has()` succeeds. Errors from `has()`, including provider metadata failures, stop the lookup immediately.
- `AggregateServiceProvider($base, $overrides)` merges factories: **later duplicate factory keys win**. Duplicate extensions compose in provider order. This differs from first-source precedence.
- Helper, factory and builder compose all direct/configuration-discovered providers by default. The first callable factory wins; every provider extension runs in registration order after selection, including extensions before the factory. Selected factory/extension failures propagate without later-provider, external or autowiring fallback. Successful final values, including null, are cached; failed chains may be retried.
- Selected provider factories retain their original source positions against interleaved external PSR-11/config sources. All providers share one build-local adapter; AggregateContainer keeps source positions and alias origins internally. Extension-only defaults use the first extension position only when no provider has a factory. External values remain independent and are not decorated. External sources retain fallback after PSR-11 `get()` failures, while `has()` failures propagate.
- Explicit `AggregateServiceProvider($base, $overrides)` retains **later duplicate factory keys win**. Both forms compose extensions in provider order. Older supported versions may lack the default composition correction; run `examples/provider-composition.php` against the installed autoloader before relying on it.
- Numeric string IDs remain opaque through provider merges and follow the same precedence as other IDs.
- Interfaces and required scalars need explicit registration/factories. Bind the containing service for scalar arguments. `?Type $value` without a default remains required.
- Optional named objects try explicit entries and concrete autowiring before their defaults. Missing/uninferable graphs may use the default. Constructor/factory/type/autoload/default-expression failures and cycles propagate when no later source succeeds. Do not blanket-catch failures and silently substitute null.
Expand Down
Loading
Loading