Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
ServiceProviderContainer
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
4 / 4
17
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 applyServiceExtensions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/container.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/container
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\Container;
20
21use FastForward\Container\Exception\ContainerException;
22use FastForward\Container\Exception\NotFoundException;
23use Interop\Container\ServiceProviderInterface;
24use Psr\Container\ContainerExceptionInterface;
25use Psr\Container\ContainerInterface as PsrContainerInterface;
26
27/**
28 * Implements a PSR-11 compliant dependency injection container using a container-interop/service-provider.
29 *
30 * This container SHALL resolve services by delegating to the factories and extensions defined in the
31 * provided ServiceProviderInterface instance.
32 *
33 * Services are lazily instantiated on first request and
34 * cached for subsequent retrieval, enforcing singleton-like behavior within the container scope.
35 *
36 * The container supports service extension mechanisms by allowing callable extensions to modify or
37 * enhance services after construction, based on the service identifier or its concrete class name.
38 *
39 * If an optional wrapper container is provided, it SHALL be passed to service factories and extensions,
40 * allowing for delegation or decoration of service resolution. If omitted, the container defaults to itself.
41 */
42 class ServiceProviderContainer implements ContainerInterface
43{
44    /**
45     * The container instance used for service resolution and extension application.
46     *
47     * This property MAY reference another container for delegation, or default to this container instance.
48     */
49    private  PsrContainerInterface $wrapperContainer;
50
51    /**
52     * Cache of resolved services keyed by their identifier or class name.
53     *
54     * This array SHALL store already constructed services to enforce singleton-like behavior within the container scope.
55     *
56     * @var array<string, mixed>
57     */
58    private array $cache;
59
60    /**
61     * Constructs a new ServiceProviderContainer instance.
62     *
63     * This constructor SHALL initialize the container with a service provider and an optional delegating container.
64     * If no wrapper container is provided, the container SHALL delegate to itself.
65     *
66     * @param ServiceProviderInterface $serviceProvider the service provider supplying factories and extensions
67     * @param PsrContainerInterface|null $wrapperContainer An optional container for delegation. Defaults to self.
68     */
69    public function __construct(
70        private  ServiceProviderInterface $serviceProvider,
71        ?PsrContainerInterface $wrapperContainer = null,
72    ) {
73        $this->wrapperContainer = $wrapperContainer ?? $this;
74    }
75
76    /**
77     * Determines if the container can return an entry for the given identifier.
78     *
79     * This method MUST return true if the entry exists in the cache or factories, false otherwise.
80     *
81     * @param string $id identifier of the entry to look for
82     *
83     * @return bool true if the entry exists, false otherwise
84     */
85    public function has(string $id): bool
86    {
87        return isset($this->cache[$id]) || \array_key_exists($id, $this->serviceProvider->getFactories());
88    }
89
90    /**
91     * Retrieves a service from the container by its identifier.
92     *
93     * This method SHALL return a cached instance if available, otherwise it resolves
94     * the service using the factory provided by the service provider.
95     *
96     * If the service has a corresponding extension, it SHALL be applied post-construction.
97     *
98     * @param string $id the identifier of the service to retrieve
99     *
100     * @return mixed the service instance associated with the identifier
101     *
102     * @throws NotFoundException if no factory exists for the given identifier
103     * @throws ContainerException if service construction fails due to container errors
104     */
105    public function get(string $id): mixed
106    {
107        if (isset($this->cache[$id])) {
108            return $this->cache[$id];
109        }
110
111        $factory = $this->serviceProvider->getFactories();
112
113        if (! \array_key_exists($id, $factory) || ! \is_callable($factory[$id])) {
114            throw NotFoundException::forServiceID($id);
115        }
116
117        try {
118            $service = \call_user_func($factory[$id], $this->wrapperContainer);
119            $class   = $service::class;
120            $this->applyServiceExtensions($id, $class, $service);
121        } catch (ContainerExceptionInterface $containerException) {
122            throw ContainerException::forInvalidService($id, $containerException);
123        }
124
125        $this->cache[$id] = $service;
126
127        if ($id !== $class && ! isset($this->cache[$class])) {
128            $this->cache[$class] = $service;
129        }
130
131        return $service;
132    }
133
134    /**
135     * Applies service extensions to the constructed service instance.
136     *
137     * This method SHALL inspect the set of extensions returned by the service provider,
138     * checking both the original service identifier and the concrete class name of the
139     * service instance. If a corresponding extension is found, it MUST be a callable and
140     * SHALL be invoked with the container and service instance as arguments.
141     *
142     * Extensions MAY be used to modify or enhance services after creation. Invalid extensions
143     * (non-callables) SHALL be ignored silently.
144     *
145     * @param string $id the identifier of the resolved service
146     * @param string $class the fully qualified class name of the service
147     * @param mixed $service the service instance to apply extensions to
148     *
149     * @return void
150     *
151     * @throws ContainerException if an extension callable fails during execution
152     */
153    private function applyServiceExtensions(string $id, string $class, mixed $service): void
154    {
155        $extensions = $this->serviceProvider->getExtensions();
156
157        if (\array_key_exists($id, $extensions) && \is_callable($extensions[$id])) {
158            $extensions[$id]($this->wrapperContainer, $service);
159        }
160
161        if ($id !== $class
162            && ! isset($this->cache[$class])
163            && \array_key_exists($class, $extensions)
164            && \is_callable($extensions[$class])
165        ) {
166            $extensions[$class]($this->wrapperContainer, $service);
167        }
168    }
169}