Lines 76.47% 234 / 306
Methods 59.25% 32 / 54
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 2 / 2 100.00% 1 / 1 1
 get 100.00% 1 / 1 100.00% 1 / 1 1
 make 100.00% 1 / 1 100.00% 1 / 1 1
 has 100.00% 7 / 7 100.00% 1 / 1 5
 bound 100.00% 4 / 4 100.00% 1 / 1 4
 resolved 100.00% 1 / 1 100.00% 1 / 1 1
 singleton 100.00% 8 / 8 100.00% 1 / 1 3
 scoped 60.00% 3 / 5 0.00% 0 / 1 2.26
 instance 100.00% 12 / 12 100.00% 1 / 1 3
 bind 87.50% 7 / 8 0.00% 0 / 1 3.02
 bindIf 75.00% 3 / 4 0.00% 0 / 1 4.25
 alias 100.00% 1 / 1 100.00% 1 / 1 1
 extend 66.66% 2 / 3 0.00% 0 / 1 2.15
 tag 100.00% 3 / 3 100.00% 1 / 1 3
 tagged 100.00% 4 / 4 100.00% 1 / 1 2
 when 100.00% 1 / 1 100.00% 1 / 1 1
 addContextualBinding 100.00% 1 / 1 100.00% 1 / 1 1
 resolving 66.66% 2 / 3 0.00% 0 / 1 3.33
 afterResolving 66.66% 2 / 3 0.00% 0 / 1 3.33
 rebinding 100.00% 3 / 3 100.00% 1 / 1 2
 call 90.90% 10 / 11 0.00% 0 / 1 5.02
 remove 100.00% 5 / 5 100.00% 1 / 1 3
 removeAlias 100.00% 1 / 1 100.00% 1 / 1 1
 forgetInstance 100.00% 1 / 1 100.00% 1 / 1 1
 forgetInstances 0.00% 0 / 1 0.00% 0 / 1 2
 flush 100.00% 6 / 6 100.00% 1 / 1 1
 cachePlans 0.00% 0 / 11 0.00% 0 / 1 20
 loadCachedPlans 0.00% 0 / 13 0.00% 0 / 1 72
 isValidPlan 0.00% 0 / 7 0.00% 0 / 1 30
 resolve 100.00% 11 / 11 100.00% 1 / 1 6
 resolveFactory 100.00% 6 / 6 100.00% 1 / 1 1
 resolveBinding 100.00% 4 / 4 100.00% 1 / 1 1
 resolveFactoryCallable 50.00% 6 / 12 0.00% 0 / 1 4.12
 resolveContextual 66.66% 2 / 3 0.00% 0 / 1 2.15
 getContextualConcrete 100.00% 4 / 4 100.00% 1 / 1 2
 build 78.94% 15 / 19 0.00% 0 / 1 5.23
 resolveDependencies 58.82% 10 / 17 0.00% 0 / 1 12.47
 applyExtenders 100.00% 3 / 3 100.00% 1 / 1 2
 fireResolvingCallbacks 100.00% 5 / 5 100.00% 1 / 1 1
 fireCallbackArray 100.00% 2 / 2 100.00% 1 / 1 2
 rebound 0.00% 0 / 3 0.00% 0 / 1 6
 normalizeHandler 90.00% 9 / 10 0.00% 0 / 1 7.05
 getParameterPlan 100.00% 15 / 15 100.00% 1 / 1 4
 planKey 80.00% 4 / 5 0.00% 0 / 1 4.13
 getReflection 80.00% 4 / 5 0.00% 0 / 1 3.07
 resolveParameter 92.30% 12 / 13 0.00% 0 / 1 6.02
 castParameter 62.50% 5 / 8 0.00% 0 / 1 13.27
 getTypeName 100.00% 4 / 4 100.00% 1 / 1 2
 isInstantiable 100.00% 3 / 3 100.00% 1 / 1 2
 isPlanSerializable 0.00% 0 / 4 0.00% 0 / 1 12
 forget 100.00% 1 / 1 100.00% 1 / 1 1
 resolveAlias 100.00% 3 / 3 100.00% 1 / 1 2
 resolvesTo 100.00% 7 / 7 100.00% 1 / 1 4
 abstractFromCallable 100.00% 8 / 8 100.00% 1 / 1 3
40class Container implements ContainerInterface
41{
42    /**
43     * Instances keyed by identifier.
44     *
45     * @var array<string, object>
46     */
47    private array $instances = [];
48
49    /**
50     * Lazy factory closures keyed by identifier.
51     *
52     * Each closure is invoked on first {@see get()}, then the result is
53     * cached as a shared singleton and the factory is removed.
54     *
55     * @var array<string, Closure>
56     */
57    private array $factories = [];
58
59    /**
60     * Non-shared factory closures keyed by identifier.
61     *
62     * Unlike {@see $factories}, each closure here is invoked on every
63     * {@see get()} call, so every resolution returns a fresh instance.
64     *
65     * @var array<string, Closure>
66     */
67    private array $bindings = [];
68
69    /**
70     * Alias identifiers mapped to the abstract they resolve to.
71     *
72     * @var array<string, string>
73     */
74    private array $aliases = [];
75
76    /**
77     * Contextual bindings: concrete => [abstract => implementation].
78     *
79     * @var array<string, array<string, string|Closure>>
80     */
81    private array $contextual = [];
82
83    /**
84     * Extenders keyed by identifier.
85     *
86     * Each extender decorates the resolved instance before it is returned.
87     *
88     * @var array<string, array<int, Closure>>
89     */
90    private array $extenders = [];
91
92    /**
93     * Tags mapping a tag name to the abstracts it contains.
94     *
95     * @var array<string, array<int, string>>
96     */
97    private array $tags = [];
98
99    /**
100     * Identifiers that have been resolved at least once.
101     *
102     * @var array<string, bool>
103     */
104    private array $resolved = [];
105
106    /**
107     * Callbacks fired before an instance is returned, keyed by identifier.
108     *
109     * @var array<string, array<int, Closure>>
110     */
111    private array $resolvingCallbacks = [];
112
113    /**
114     * Callbacks fired after an instance is resolved, keyed by identifier.
115     *
116     * @var array<string, array<int, Closure>>
117     */
118    private array $afterResolvingCallbacks = [];
119
120    /**
121     * Callbacks fired when an identifier is rebound, keyed by identifier.
122     *
123     * @var array<string, array<int, Closure>>
124     */
125    private array $reboundCallbacks = [];
126
127    /**
128     * Stack of classes currently being built, for circular detection and
129     * contextual binding resolution.
130     *
131     * @var array<int, string>
132     */
133    private array $buildStack = [];
134
135    /**
136     * Cached reflection parameter plans keyed by "Class::method" or closure id.
137     *
138     * @var array<string, array<int, array<string, mixed>>>
139     */
140    private array $reflectionPlans = [];
141
142    /**
143     * Abstracts registered as scoped singletons, flushed on {@see flush()}.
144     *
145     * @var array<int, string>
146     */
147    private array $scopedInstances = [];
148
149    /**
150     * Create a new container.
151     *
152     * The container registers itself as a resolvable instance so factories
153     * and callables can type-hint it for injection.
154     */
155    public function __construct()
156    {
157        $this->instance(Container::class, $this);
158        $this->instance(ContainerInterface::class, $this);
159    }
160
161    /**
162     * Get a container entry by its identifier.
163     *
164     * Resolves in order: cached instance, contextual binding, singleton
165     * factory, non-shared binding, then an autowired build. Autowired results
166     * are returned fresh (never cached) unless registered as a singleton.
167     *
168     * @template T
169     * @param class-string<T>|string $id Identifier (class name or alias) for the entry
170     * @return T The resolved entry
171     * @throws NotFoundExceptionInterface If no entry can be resolved
172     * @throws ContainerExceptionInterface If the entry exists but cannot be resolved
173     */
174    public function get(string $id): mixed
175    {
176        return $this->resolve($id);
177    }
178
179    /**
180     * Resolve an entry, autowiring its constructor dependencies.
181     *
182     * Behaves like {@see get()} but is the explicit "build" entry point.
183     * Autowired results are returned fresh and never cached unless the entry
184     * was registered as a singleton.
185     *
186     * ```php
187     * $service = $container->make(MyService::class); // autowires deps
188     * $service = $container->make(MyService::class, ['config' => $config]);
189     * ```
190     *
191     * @template T
192     * @param class-string<T>|string $abstract Identifier (class name or alias) to resolve
193     * @param array $parameters Explicit values keyed by constructor parameter name
194     * @return T The resolved entry
195     * @throws ContainerExceptionInterface If the entry cannot be resolved
196     */
197    public function make(string $abstract, array $parameters = []): mixed
198    {
199        return $this->resolve($abstract, $parameters);
200    }
201
202    /**
203     * Determine whether the container can resolve an identifier.
204     *
205     * Returns true when the identifier is registered (instance, factory,
206     * binding, or alias) or when it names an instantiable concrete class that
207     * can be autowired.
208     *
209     * @param string $id Identifier (class name or alias) for the entry
210     * @return bool True if an entry can be resolved for the identifier
211     */
212    public function has(string $id): bool
213    {
214        $id = $this->resolveAlias($id);
215
216        if (isset($this->instances[$id])
217            || isset($this->factories[$id])
218            || isset($this->bindings[$id])
219            || isset($this->contextual[$id])) {
220            return true;
221        }
222
223        return $this->isInstantiable($id);
224    }
225
226    /**
227     * Determine whether an identifier has been explicitly bound.
228     *
229     * Unlike {@see has()}, this does not consider autowirable concretes â€” it
230     * only reports explicit registrations.
231     *
232     * @param string $abstract Identifier (class name or alias) to check
233     * @return bool True if the identifier is explicitly bound
234     */
235    public function bound(string $abstract): bool
236    {
237        return isset($this->instances[$abstract])
238            || isset($this->factories[$abstract])
239            || isset($this->bindings[$abstract])
240            || isset($this->aliases[$abstract]);
241    }
242
243    /**
244     * Determine whether an identifier has been resolved at least once.
245     *
246     * @param string $abstract Identifier (class name or alias) to check
247     * @return bool True if the identifier has been resolved
248     */
249    public function resolved(string $abstract): bool
250    {
251        return isset($this->resolved[$abstract]);
252    }
253
254    /**
255     * Register a shared singleton entry.
256     *
257     * The entry is registered as a lazy factory and only instantiated on the
258     * first {@see get()}; the result is then cached as a shared singleton.
259     * When given a concrete class string, its constructor dependencies are
260     * autowired.
261     *
262     * ```php
263     * $container->singleton(Mailer::class);                          // lazy, shared
264     * $container->singleton(MailerInterface::class, SmtpMailer::class);
265     * $container->singleton(Mailer::class, fn () => new Mailer(...));
266     * $container->singleton(fn (): Mailer => new Mailer(...));       // keyed by Mailer::class
267     * ```
268     *
269     * @param string|callable $abstract Identifier (class name or alias) to register the entry under, or a factory callable whose return type names the identifier
270     * @param string|callable|null $concrete Class name to instantiate lazily, or a factory callable returning the instance; defaults to $abstract
271     * @return void
272     * @throws ContainerExceptionInterface If the class cannot be instantiated, or a callable abstract has no class return type
273     */
274    public function singleton(string|callable $abstract, string|callable|null $concrete = null): void
275    {
276        if (\is_callable($abstract)) {
277            $concrete = $abstract;
278            $abstract = $this->abstractFromCallable($abstract);
279        }
280
281        $concrete ??= $abstract;
282        $this->forget($abstract);
283
284        $this->factories[$abstract] = \is_string($concrete)
285            ? fn () => $this->build($concrete)
286            : Closure::fromCallable($concrete);
287    }
288
289    /**
290     * Register a scoped singleton entry.
291     *
292     * Behaves like {@see singleton()} but the resolved instance is cleared by
293     * {@see flush()}, so a new instance is built on the next resolution after
294     * a flush.
295     *
296     * @param string|callable $abstract Identifier (class name or alias) to register the entry under, or a factory callable whose return type names the identifier
297     * @param string|callable|null $concrete Class name to instantiate lazily, or a factory callable returning the instance; defaults to $abstract
298     * @return void
299     * @throws ContainerExceptionInterface If the class cannot be instantiated, or a callable abstract has no class return type
300     */
301    public function scoped(string|callable $abstract, string|callable|null $concrete = null): void
302    {
303        if (\is_callable($abstract)) {
304            $concrete = $abstract;
305            $abstract = $this->abstractFromCallable($abstract);
306        }
307
308        $this->scopedInstances[] = $abstract;
309        $this->singleton($abstract, $concrete);
310    }
311
312    /**
313     * Register an existing object instance under an identifier.
314     *
315     * ```php
316     * $container->instance(LoggerInterface::class, $logger);
317     * $container->instance($logger); // keyed by Logger::class
318     * ```
319     *
320     * @param string|object $abstract Identifier (class name or alias) to register the instance under, or the instance itself (keyed by its class name)
321     * @param object|null $instance The instance to register; required when $abstract is a string
322     * @return object The registered instance
323     * @throws ContainerExceptionInterface If $abstract is a string and no instance is given
324     */
325    public function instance(string|object $abstract, ?object $instance = null): object
326    {
327        if (\is_object($abstract)) {
328            $instance = $abstract;
329            $abstract = $abstract::class;
330        }
331
332        if ($instance === null) {
333            throw new ContainerException(
334                'instance() requires an instance when given a string identifier, e.g. ' .
335                'instance(LoggerInterface::class, $logger).'
336            );
337        }
338
339        $this->forget($abstract);
340        $this->instances[$abstract] = $instance;
341        $this->resolved[$abstract] = true;
342
343        return $instance;
344    }
345
346    /**
347     * Register a non-shared entry backed by a factory.
348     *
349     * Unlike {@see singleton()}, the factory is invoked on every {@see get()}
350     * call, so each resolution returns a fresh instance. When given a concrete
351     * class string, its constructor dependencies are autowired on each build.
352     *
353     * ```php
354     * $container->bind(Connection::class, static fn () => new Connection($dsn));
355     * $container->bind(fn (): Connection => new Connection($dsn)); // keyed by Connection::class
356     *
357     * $a = $container->get(Connection::class);
358     * $b = $container->get(Connection::class); // $a !== $b
359     * ```
360     *
361     * @param string|callable $abstract Identifier (class name or alias) to register the entry under, or a factory callable whose return type names the identifier
362     * @param string|callable|null $concrete Class name to instantiate per resolution, or a factory callable; defaults to $abstract
363     * @return void
364     * @throws ContainerExceptionInterface If a callable abstract has no class return type
365     */
366    public function bind(string|callable $abstract, string|callable|null $concrete = null): void
367    {
368        if (\is_callable($abstract)) {
369            $concrete = $abstract;
370            $abstract = $this->abstractFromCallable($abstract);
371        }
372
373        $concrete ??= $abstract;
374        $this->forget($abstract);
375
376        $this->bindings[$abstract] = \is_string($concrete)
377            ? fn () => $this->build($concrete)
378            : Closure::fromCallable($concrete);
379    }
380
381    /**
382     * Register a binding only if the identifier is not already bound.
383     *
384     * @param string|callable $abstract Identifier (class name or alias) to register the entry under, or a factory callable whose return type names the identifier
385     * @param string|callable|null $concrete Class name to instantiate, or a factory callable; defaults to $abstract
386     * @return void
387     * @throws ContainerExceptionInterface If a callable abstract has no class return type
388     */
389    public function bindIf(string|callable $abstract, string|callable|null $concrete = null): void
390    {
391        $key = \is_string($abstract) ? $abstract : null;
392
393        if ($key !== null && $this->bound($key)) {
394            return;
395        }
396
397        $this->bind($abstract, $concrete);
398    }
399
400    /**
401     * Register an alias so a second identifier resolves to the same entry as
402     * an already-registered abstract, without re-instantiating it.
403     *
404     * ```php
405     * $container->singleton(MailerInterface::class, SmtpMailer::class);
406     * $container->alias(MailerInterface::class, SmtpMailer::class);
407     *
408     * $a = $container->get(MailerInterface::class);
409     * $b = $container->get(SmtpMailer::class); // $a === $b
410     * ```
411     *
412     * Aliases are resolved lazily at {@see get()} time, so the abstract does
413     * not need to be registered yet when the alias is created.
414     *
415     * @param string $abstract The identifier the alias points to
416     * @param string $alias The additional identifier to resolve to $abstract
417     * @return void
418     */
419    public function alias(string $abstract, string $alias): void
420    {
421        $this->aliases[$alias] = $abstract;
422    }
423
424    /**
425     * Register an extender that decorates a resolved instance.
426     *
427     * The extender receives the resolved instance and the container, and must
428     * return the (possibly decorated) instance. Extenders run after the
429     * instance is built and before it is returned.
430     *
431     * ```php
432     * $container->extend(Logger::class, fn (Logger $logger, $container) => new DecoratedLogger($logger));
433     * ```
434     *
435     * @param string $abstract Identifier whose resolved instances to decorate
436     * @param Closure $extender Callable receiving (instance, container) and returning the instance
437     * @return void
438     */
439    public function extend(string $abstract, Closure $extender): void
440    {
441        $this->extenders[$abstract][] = $extender;
442
443        if (isset($this->instances[$abstract])) {
444            $this->rebound($abstract);
445        }
446    }
447
448    /**
449     * Assign one or more tags to one or more abstracts.
450     *
451     * @param string|array $abstracts Identifier(s) to tag
452     * @param array $tags Tag name(s) to assign
453     * @return void
454     */
455    public function tag(string|array $abstracts, array $tags): void
456    {
457        foreach ((array) $abstracts as $abstract) {
458            foreach ($tags as $tag) {
459                $this->tags[$tag][] = $abstract;
460            }
461        }
462    }
463
464    /**
465     * Resolve every abstract assigned to a tag.
466     *
467     * @param string $tag The tag name
468     * @return array<int, mixed> The resolved instances
469     * @throws ContainerExceptionInterface If any tagged abstract cannot be resolved
470     */
471    public function tagged(string $tag): array
472    {
473        $results = [];
474
475        foreach ($this->tags[$tag] ?? [] as $abstract) {
476            $results[] = $this->make($abstract);
477        }
478
479        return $results;
480    }
481
482    /**
483     * Begin a contextual binding for a concrete class.
484     *
485     * ```php
486     * $container->when(PhotoController::class)
487     *     ->needs(Filesystem::class)
488     *     ->give(LocalFilesystem::class);
489     * ```
490     *
491     * @param string $concrete The class whose dependencies to override
492     * @return ContextualBindingBuilder A builder to declare the needs/give pair
493     */
494    public function when(string $concrete): ContextualBindingBuilder
495    {
496        return new ContextualBindingBuilder($this, $concrete);
497    }
498
499    /**
500     * Register a contextual binding.
501     *
502     * @param string $concrete The class whose dependency to override
503     * @param string $abstract The abstract being overridden
504     * @param string|Closure $implementation The concrete or factory to give
505     * @return void
506     */
507    public function addContextualBinding(string $concrete, string $abstract, string|Closure $implementation): void
508    {
509        $this->contextual[$concrete][$abstract] = $implementation;
510    }
511
512    /**
513     * Register a callback to run before an instance is returned.
514     *
515     * @param string|callable $abstract Identifier to hook, or a global callback when no identifier is given
516     * @param Closure|null $callback The callback receiving (instance, container)
517     * @return void
518     */
519    public function resolving(string|callable $abstract, ?Closure $callback = null): void
520    {
521        if (\is_string($abstract) && $callback !== null) {
522            $this->resolvingCallbacks[$abstract][] = $callback;
523        } else {
524            $this->resolvingCallbacks['*'][] = $abstract;
525        }
526    }
527
528    /**
529     * Register a callback to run after an instance is resolved.
530     *
531     * @param string|callable $abstract Identifier to hook, or a global callback when no identifier is given
532     * @param Closure|null $callback The callback receiving (instance, container)
533     * @return void
534     */
535    public function afterResolving(string|callable $abstract, ?Closure $callback = null): void
536    {
537        if (\is_string($abstract) && $callback !== null) {
538            $this->afterResolvingCallbacks[$abstract][] = $callback;
539        } else {
540            $this->afterResolvingCallbacks['*'][] = $abstract;
541        }
542    }
543
544    /**
545     * Register a callback to run when an identifier is rebound.
546     *
547     * If the identifier is already resolved, the callback fires immediately.
548     *
549     * @param string $abstract Identifier to watch
550     * @param Closure $callback The callback receiving (instance, container)
551     * @return void
552     */
553    public function rebinding(string $abstract, Closure $callback): void
554    {
555        $this->reboundCallbacks[$abstract][] = $callback;
556
557        if (isset($this->instances[$abstract])) {
558            $callback($this->instances[$abstract], $this);
559        }
560    }
561
562    /**
563     * Invoke a callable, resolving its parameters from the container.
564     *
565     * Each parameter is resolved in order: by name from $parameters, then by
566     * type from the container, then a default value, then null if nullable.
567     * Otherwise a {@see ContainerException} is thrown. Primitive values are
568     * cast to the parameter's declared type where possible.
569     *
570     * Handlers may be a closure, [Class, 'method'], 'Class@method', or an
571     * invokable class/object. Class-based handlers are instantiated via
572     * {@see make()} (constructor injection) before the method is invoked.
573     *
574     * ```php
575     * $result = $container->call([$controller, 'show'], ['id' => 5]);
576     * $result = $container->call(fn (Logger $log) => $log->info('hi'));
577     * ```
578     *
579     * @template T
580     * @param callable(): T|string|array $callback The callable to invoke
581     * @param array $parameters Explicit values keyed by parameter name
582     * @param string|null $defaultMethod Method to invoke when $callback is an invokable class string
583     * @return T The callable's return value
584     * @throws ContainerExceptionInterface If a parameter cannot be resolved
585     */
586    public function call(callable|string|array $callback, array $parameters = [], ?string $defaultMethod = null): mixed
587    {
588        try {
589            [$class, $method] = $this->normalizeHandler($callback, $defaultMethod);
590
591            $plan = $this->getParameterPlan($class, $method);
592
593            $resolved = [];
594            foreach ($plan as $param) {
595                $resolved[] = $this->resolveParameter($param, $parameters);
596            }
597
598            if ($class !== null) {
599                $instance = \is_object($class) ? $class : $this->make($class);
600                return $instance->{$method}(...$resolved);
601            }
602
603            return $method(...$resolved);
604        } catch (NotFoundException $e) {
605            throw new ContainerException($e->getMessage(), 0, $e);
606        }
607    }
608
609    /**
610     * Remove an entry and every alias that resolves to it.
611     *
612     * The identifier is resolved first (like {@see get()} and {@see has()}),
613     * so passing an alias removes the underlying entry and all aliases that
614     * point to it. To remove a single alias without touching the entry, use
615     * {@see removeAlias()}.
616     *
617     * @param string $abstract The identifier (or alias) of the entry to remove
618     * @return void
619     */
620    public function remove(string $abstract): void
621    {
622        $abstract = $this->resolveAlias($abstract);
623        $this->forget($abstract);
624
625        foreach ($this->aliases as $alias => $target) {
626            if ($this->resolvesTo($alias, $abstract)) {
627                unset($this->aliases[$alias]);
628            }
629        }
630    }
631
632    /**
633     * Remove a single alias without touching the entry it points to.
634     *
635     * @param string $alias The alias identifier to remove
636     * @return void
637     */
638    public function removeAlias(string $alias): void
639    {
640        unset($this->aliases[$alias]);
641    }
642
643    /**
644     * Forget a single resolved instance.
645     *
646     * The next resolution of the identifier will build a fresh instance.
647     *
648     * @param string $abstract Identifier whose instance to forget
649     * @return void
650     */
651    public function forgetInstance(string $abstract): void
652    {
653        unset($this->instances[$abstract]);
654    }
655
656    /**
657     * Forget all resolved instances.
658     *
659     * @return void
660     */
661    public function forgetInstances(): void
662    {
663        $this->instances = [];
664    }
665
666    /**
667     * Flush the container, clearing instances, bindings, factories, aliases,
668     * and resolution state.
669     *
670     * @return void
671     */
672    public function flush(): void
673    {
674        $this->aliases = [];
675        $this->resolved = [];
676        $this->bindings = [];
677        $this->instances = [];
678        $this->factories = [];
679        $this->scopedInstances = [];
680    }
681
682    /**
683     * Persist the cached reflection plans to a PHP file.
684     *
685     * Plans whose defaults contain objects are excluded, as they cannot be
686     * represented by var_export.
687     *
688     * @param string $path The file path to write (e.g. storage/cache/container.php)
689     * @return void
690     */
691    public function cachePlans(string $path): void
692    {
693        $plans = [];
694
695        foreach ($this->reflectionPlans as $key => $plan) {
696            if ($this->isPlanSerializable($plan)) {
697                $plans[$key] = $plan;
698            }
699        }
700
701        $dir = \dirname($path);
702        if (!\is_dir($dir)) {
703            \mkdir($dir, 0777, true);
704        }
705
706        // A marker header lets loadCachedPlans() verify the file was written
707        // by cachePlans() before `require`-ing it, so a tampered or
708        // attacker-written file in the cache dir cannot execute arbitrary PHP.
709        \file_put_contents(
710            $path,
711            '<?php /* lucent-container-plans v1 */ return ' . \var_export($plans, true) . ';'
712        );
713    }
714
715    /**
716     * Load cached reflection plans from a PHP file.
717     *
718     * @param string $path The file path to read (e.g. storage/cache/container.php)
719     * @return void
720     */
721    public function loadCachedPlans(string $path): void
722    {
723        if (!\file_exists($path)) {
724            return;
725        }
726
727        // Only load plans written by cachePlans() (identified by the marker
728        // header) and confined to the project root. `require` executes the
729        // file, so a tampered or attacker-written file in the cache dir must
730        // never reach it.
731        $contents = \file_get_contents($path);
732        if ($contents === false || !\str_starts_with($contents, '<?php /* lucent-container-plans v1 */')) {
733            return;
734        }
735
736        if (!\Lucent\Facades\FileSystem::isWithinRoot($path)) {
737            return;
738        }
739
740        $plans = require $path;
741
742        if (!\is_array($plans)) {
743            return;
744        }
745
746        foreach ($plans as $key => $plan) {
747            if ($this->isValidPlan($plan)) {
748                $this->reflectionPlans[$key] = $plan;
749            }
750        }
751    }
752
753    /**
754     * Determine whether a cached plan has the expected structure.
755     *
756     * Guards against corrupt or version-mismatched cache files injecting
757     * malformed plans.
758     *
759     * @param mixed $plan The plan to validate
760     * @return bool True if the plan is a well-formed parameter plan
761     */
762    private function isValidPlan(mixed $plan): bool
763    {
764        if (!\is_array($plan)) {
765            return false;
766        }
767
768        foreach ($plan as $param) {
769            if (!\is_array($param)
770                || !isset($param['name'], $param['type'], $param['default_available'], $param['default'], $param['variadic'], $param['nullable'])) {
771                return false;
772            }
773        }
774
775        return true;
776    }
777
778    /**
779     * Resolve an entry through the full resolution pipeline.
780     *
781     * @param string $abstract Identifier to resolve
782     * @param array $parameters Explicit constructor values keyed by name
783     * @return mixed The resolved entry
784     * @throws NotFoundExceptionInterface If no entry can be resolved
785     * @throws ContainerExceptionInterface If the entry cannot be built
786     */
787    private function resolve(string $abstract, array $parameters = []): mixed
788    {
789        $abstract = $this->resolveAlias($abstract);
790
791        if (isset($this->instances[$abstract]) && !isset($this->aliases[$abstract])) {
792            return $this->instances[$abstract];
793        }
794
795        $contextual = $this->getContextualConcrete($abstract);
796        if ($contextual !== null) {
797            return $this->resolveContextual($contextual, $parameters);
798        }
799
800        if (isset($this->factories[$abstract])) {
801            return $this->resolveFactory($abstract, $parameters);
802        }
803
804        if (isset($this->bindings[$abstract])) {
805            return $this->resolveBinding($abstract, $parameters);
806        }
807
808        return $this->build($abstract, $parameters);
809    }
810
811    /**
812     * Resolve a singleton factory, caching the result as an instance.
813     *
814     * @param string $abstract The identifier
815     * @param array $parameters Explicit values keyed by name
816     * @return object The resolved instance
817     * @throws ContainerExceptionInterface On resolution failure
818     */
819    private function resolveFactory(string $abstract, array $parameters = []): object
820    {
821        $factory = $this->factories[$abstract];
822        unset($this->factories[$abstract]);
823
824        $service = $this->resolveFactoryCallable($factory, $abstract, $parameters);
825
826        $this->instances[$abstract] = $service;
827        $this->resolved[$abstract] = true;
828
829        return $service;
830    }
831
832    /**
833     * Resolve a non-shared binding, returning a fresh instance.
834     *
835     * @param string $abstract The identifier
836     * @param array $parameters Explicit values keyed by name
837     * @return object The resolved instance
838     * @throws ContainerExceptionInterface On resolution failure
839     */
840    private function resolveBinding(string $abstract, array $parameters = []): object
841    {
842        $factory = $this->bindings[$abstract];
843
844        $service = $this->resolveFactoryCallable($factory, $abstract, $parameters);
845
846        $this->resolved[$abstract] = true;
847
848        return $service;
849    }
850
851    /**
852     * Invoke a factory closure, wrapping any failure in a
853     * {@see ContainerExceptionInterface}.
854     *
855     * @param Closure $factory The factory to invoke
856     * @param string $abstract The identifier the factory is registered under
857     * @param array $parameters Explicit values keyed by name
858     * @return object The resolved instance
859     * @throws ContainerExceptionInterface On resolution failure
860     */
861    private function resolveFactoryCallable(Closure $factory, string $abstract, array $parameters = []): object
862    {
863        try {
864            $service = $this->call($factory, $parameters);
865        } catch (Throwable $e) {
866            throw new ContainerException(
867                \sprintf('Failed to resolve container factory for "%s": %s', $abstract, $e->getMessage()),
868                0,
869                $e
870            );
871        }
872
873        if (!\is_object($service)) {
874            throw new ContainerException(
875                \sprintf('Container factory for "%s" must return an object, "%s" given.', $abstract, \gettype($service))
876            );
877        }
878
879        return $service;
880    }
881
882    /**
883     * Resolve a contextual binding implementation.
884     *
885     * @param string|Closure $implementation The concrete or factory to give
886     * @param array $parameters Explicit values keyed by name
887     * @return mixed The resolved value
888     * @throws ContainerExceptionInterface On resolution failure
889     */
890    private function resolveContextual(string|Closure $implementation, array $parameters = []): mixed
891    {
892        if (\is_string($implementation)) {
893            return $this->resolve($implementation, $parameters);
894        }
895
896        return $this->call($implementation, $parameters);
897    }
898
899    /**
900     * Find the contextual implementation for an abstract in the current build
901     * context.
902     *
903     * @param string $abstract The abstract being resolved
904     * @return string|Closure|null The contextual implementation, or null
905     */
906    private function getContextualConcrete(string $abstract): string|Closure|null
907    {
908        $concrete = \end($this->buildStack);
909
910        if ($concrete === false) {
911            return null;
912        }
913
914        return $this->contextual[$concrete][$abstract] ?? null;
915    }
916
917    /**
918     * Autowire a concrete class by reflecting its constructor.
919     *
920     * @param string $concrete The class to instantiate
921     * @param array $parameters Explicit constructor values keyed by name
922     * @return object The instantiated instance
923     * @throws ContainerExceptionInterface If the class cannot be instantiated
924     */
925    private function build(string $concrete, array $parameters = []): object
926    {
927        if (!\class_exists($concrete)) {
928            throw new NotFoundException($concrete);
929        }
930
931        $reflection = new ReflectionClass($concrete);
932
933        if (!$reflection->isInstantiable()) {
934            throw new ContainerException(\sprintf('Target [%s] is not instantiable.', $concrete));
935        }
936
937        if (\in_array($concrete, $this->buildStack, true)) {
938            throw new ContainerException(
939                \sprintf('Circular dependency detected: %s -> %s', \implode(' -> ', $this->buildStack), $concrete)
940            );
941        }
942
943        $this->buildStack[] = $concrete;
944
945        try {
946            $constructor = $reflection->getConstructor();
947
948            if ($constructor === null) {
949                $instance = $reflection->newInstance();
950            } else {
951                $dependencies = $this->resolveDependencies($constructor->getParameters(), $parameters);
952                $instance = $reflection->newInstanceArgs($dependencies);
953            }
954        } finally {
955            \array_pop($this->buildStack);
956        }
957
958        $instance = $this->applyExtenders($concrete, $instance);
959        $this->fireResolvingCallbacks($concrete, $instance);
960
961        return $instance;
962    }
963
964    /**
965     * Resolve a list of constructor dependencies.
966     *
967     * @param array<int, ReflectionParameter> $parameters The constructor parameters
968     * @param array $primitives Explicit values keyed by parameter name
969     * @return array<int, mixed> The resolved dependency values
970     * @throws ContainerExceptionInterface If a dependency cannot be resolved
971     */
972    private function resolveDependencies(array $parameters, array $primitives = []): array
973    {
974        $dependencies = [];
975
976        foreach ($parameters as $parameter) {
977            $name = $parameter->getName();
978
979            if (\array_key_exists($name, $primitives)) {
980                $dependencies[] = $primitives[$name];
981                continue;
982            }
983
984            $type = $parameter->getType();
985
986            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
987                $dependencies[] = $this->resolve($type->getName());
988            } elseif ($parameter->isDefaultValueAvailable()) {
989                $dependencies[] = $parameter->getDefaultValue();
990            } elseif ($type !== null && $type->allowsNull()) {
991                $dependencies[] = null;
992            } else {
993                throw new ContainerException(
994                    \sprintf('Unresolvable dependency [%s] in class [%s].', $name, $parameter->getDeclaringClass()?->getName())
995                );
996            }
997        }
998
999        return $dependencies;
1000    }
1001
1002    /**
1003     * Apply registered extenders to a resolved instance.
1004     *
1005     * @param string $abstract The identifier
1006     * @param object $instance The resolved instance
1007     * @return object The (possibly decorated) instance
1008     */
1009    private function applyExtenders(string $abstract, object $instance): object
1010    {
1011        foreach ($this->extenders[$abstract] ?? [] as $extender) {
1012            $instance = $extender($instance, $this);
1013        }
1014
1015        return $instance;
1016    }
1017
1018    /**
1019     * Fire resolving and after-resolving callbacks for an instance.
1020     *
1021     * @param string $abstract The identifier
1022     * @param object $instance The resolved instance
1023     * @return void
1024     */
1025    private function fireResolvingCallbacks(string $abstract, object $instance): void
1026    {
1027        $this->fireCallbackArray($instance, $this->resolvingCallbacks[$abstract] ?? []);
1028        $this->fireCallbackArray($instance, $this->resolvingCallbacks['*'] ?? []);
1029
1030        $this->resolved[$abstract] = true;
1031
1032        $this->fireCallbackArray($instance, $this->afterResolvingCallbacks[$abstract] ?? []);
1033        $this->fireCallbackArray($instance, $this->afterResolvingCallbacks['*'] ?? []);
1034    }
1035
1036    /**
1037     * Invoke a list of callbacks with an instance.
1038     *
1039     * @param object $instance The instance to pass
1040     * @param array<int, Closure> $callbacks The callbacks to invoke
1041     * @return void
1042     */
1043    private function fireCallbackArray(object $instance, array $callbacks): void
1044    {
1045        foreach ($callbacks as $callback) {
1046            $callback($instance, $this);
1047        }
1048    }
1049
1050    /**
1051     * Fire rebound callbacks for an identifier.
1052     *
1053     * @param string $abstract The identifier
1054     * @return void
1055     */
1056    private function rebound(string $abstract): void
1057    {
1058        $instance = $this->instances[$abstract] ?? null;
1059
1060        foreach ($this->reboundCallbacks[$abstract] ?? [] as $callback) {
1061            $callback($instance, $this);
1062        }
1063    }
1064
1065    /**
1066     * Normalize a handler into [class, method] form.
1067     *
1068     * @param callable|string|array $callback The handler
1069     * @param string|null $defaultMethod Method to use for invokable class strings
1070     * @return array{0: string|object|null, 1: string|Closure} The normalized handler
1071     */
1072    private function normalizeHandler(callable|string|array $callback, ?string $defaultMethod = null): array
1073    {
1074        if (\is_string($callback) && \str_contains($callback, '@')) {
1075            [$class, $method] = \explode('@', $callback, 2);
1076            return [$class, $method];
1077        }
1078
1079        if (\is_array($callback)) {
1080            return [$callback[0], $callback[1]];
1081        }
1082
1083        if (\is_string($callback)) {
1084            return [$callback, $defaultMethod ?? '__invoke'];
1085        }
1086
1087        if (\is_object($callback) && !$callback instanceof Closure) {
1088            return [$callback, '__invoke'];
1089        }
1090
1091        return [null, $callback];
1092    }
1093
1094    /**
1095     * Get (and cache) the parameter plan for a callable.
1096     *
1097     * @param string|object|null $class The class, or null for a closure
1098     * @param string|Closure $method The method name or closure
1099     * @return array<int, array<string, mixed>> The parameter plan
1100     */
1101    private function getParameterPlan(string|object|null $class, string|Closure $method): array
1102    {
1103        $key = $this->planKey($class, $method);
1104
1105        if (isset($this->reflectionPlans[$key])) {
1106            return $this->reflectionPlans[$key];
1107        }
1108
1109        $reflection = $this->getReflection($class, $method);
1110        $plan = [];
1111
1112        foreach ($reflection->getParameters() as $parameter) {
1113            $plan[] = [
1114                'name' => $parameter->getName(),
1115                'type' => $this->getTypeName($parameter),
1116                'default_available' => $parameter->isDefaultValueAvailable(),
1117                'default' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null,
1118                'variadic' => $parameter->isVariadic(),
1119                'nullable' => $parameter->allowsNull(),
1120            ];
1121        }
1122
1123        return $this->reflectionPlans[$key] = $plan;
1124    }
1125
1126    /**
1127     * Build a cache key for a parameter plan.
1128     *
1129     * @param string|object|null $class The class, or null for a closure
1130     * @param string|Closure $method The method name or closure
1131     * @return string The cache key
1132     */
1133    private function planKey(string|object|null $class, string|Closure $method): string
1134    {
1135        if ($method instanceof Closure) {
1136            return 'closure:' . \spl_object_id($method);
1137        }
1138
1139        if ($class !== null) {
1140            return (\is_object($class) ? \get_class($class) : $class) . '::' . $method;
1141        }
1142
1143        return $method;
1144    }
1145
1146    /**
1147     * Get the reflection for a callable.
1148     *
1149     * @param string|object|null $class The class, or null for a closure
1150     * @param string|Closure $method The method name or closure
1151     * @return ReflectionMethod|ReflectionFunction The reflection
1152     */
1153    private function getReflection(string|object|null $class, string|Closure $method): ReflectionMethod|ReflectionFunction
1154    {
1155        if ($method instanceof Closure) {
1156            return new ReflectionFunction($method);
1157        }
1158
1159        if ($class !== null) {
1160            return new ReflectionMethod($class, $method);
1161        }
1162
1163        return new ReflectionFunction($method);
1164    }
1165
1166    /**
1167     * Resolve a single parameter from the plan.
1168     *
1169     * @param array<string, mixed> $param The parameter plan entry
1170     * @param array $parameters Explicit values keyed by name
1171     * @return mixed The resolved value
1172     * @throws ContainerExceptionInterface If the parameter cannot be resolved
1173     */
1174    private function resolveParameter(array $param, array $parameters): mixed
1175    {
1176        $name = $param['name'];
1177        $type = $param['type'];
1178
1179        if (\array_key_exists($name, $parameters)) {
1180            return $this->castParameter($parameters[$name], $type);
1181        }
1182
1183        if ($type !== null && $this->has($type)) {
1184            return $this->get($type);
1185        }
1186
1187        if ($param['default_available']) {
1188            return $param['default'];
1189        }
1190
1191        if ($param['nullable']) {
1192            return null;
1193        }
1194
1195        throw new ContainerException(
1196            \sprintf('Unable to resolve parameter [%s] of type [%s].', $name, $type ?? 'mixed')
1197        );
1198    }
1199
1200    /**
1201     * Cast a value to a parameter's declared type where possible.
1202     *
1203     * @param mixed $value The value to cast
1204     * @param string|null $type The declared type name, or null
1205     * @return mixed The cast value
1206     */
1207    private function castParameter(mixed $value, ?string $type): mixed
1208    {
1209        if ($type === null || \is_object($value) || \is_array($value)) {
1210            return $value;
1211        }
1212
1213        return match ($type) {
1214            'int' => (int) $value,
1215            'float' => (float) $value,
1216            'bool' => \filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value,
1217            'string' => (string) $value,
1218            default => $value,
1219        };
1220    }
1221
1222    /**
1223     * Get the named type of a parameter, or null.
1224     *
1225     * @param ReflectionParameter $parameter The parameter
1226     * @return string|null The type name, or null
1227     */
1228    private function getTypeName(ReflectionParameter $parameter): ?string
1229    {
1230        $type = $parameter->getType();
1231
1232        if ($type instanceof ReflectionNamedType) {
1233            return $type->getName();
1234        }
1235
1236        return null;
1237    }
1238
1239    /**
1240     * Determine whether a class exists and is instantiable.
1241     *
1242     * @param string $class The class name
1243     * @return bool True if the class is instantiable
1244     */
1245    private function isInstantiable(string $class): bool
1246    {
1247        if (!\class_exists($class)) {
1248            return false;
1249        }
1250
1251        return (new ReflectionClass($class))->isInstantiable();
1252    }
1253
1254    /**
1255     * Determine whether a parameter plan can be serialized to a file.
1256     *
1257     * @param array<int, array<string, mixed>> $plan The plan
1258     * @return bool True if all defaults are serializable
1259     */
1260    private function isPlanSerializable(array $plan): bool
1261    {
1262        foreach ($plan as $param) {
1263            if (\is_object($param['default'])) {
1264                return false;
1265            }
1266        }
1267
1268        return true;
1269    }
1270
1271    /**
1272     * Remove any existing registration for an identifier.
1273     *
1274     * Ensures a given id lives in at most one of {@see $instances},
1275     * {@see $factories}, or {@see $bindings} at a time, so the most recent
1276     * registration always wins. Also clears any alias registered *under* the
1277     * id, but deliberately preserves aliases that point *to* it so the
1278     * "alias before abstract" pattern survives re-registration.
1279     *
1280     * @param string $id The identifier to clear
1281     * @return void
1282     */
1283    private function forget(string $id): void
1284    {
1285        unset($this->instances[$id], $this->factories[$id], $this->bindings[$id], $this->aliases[$id]);
1286    }
1287
1288    /**
1289     * Follow the alias chain for an identifier.
1290     *
1291     * @param string $id The identifier to resolve
1292     * @return string The terminal (non-alias) identifier
1293     */
1294    private function resolveAlias(string $id): string
1295    {
1296        while (isset($this->aliases[$id])) {
1297            $id = $this->aliases[$id];
1298        }
1299
1300        return $id;
1301    }
1302
1303    /**
1304     * Determine whether an alias (transitively) resolves to a target id.
1305     *
1306     * @param string $alias The alias to follow
1307     * @param string $target The id to look for
1308     * @return bool True if following the chain from $alias reaches $target
1309     */
1310    private function resolvesTo(string $alias, string $target): bool
1311    {
1312        $seen = [];
1313        while (isset($this->aliases[$alias]) && !isset($seen[$alias])) {
1314            $seen[$alias] = true;
1315            $alias = $this->aliases[$alias];
1316            if ($alias === $target) {
1317                return true;
1318            }
1319        }
1320
1321        return false;
1322    }
1323
1324    /**
1325     * Derive an abstract identifier from a factory callable's return type.
1326     *
1327     * @param callable $callable The factory callable
1328     * @return string The class name the callable returns
1329     * @throws ContainerExceptionInterface If the callable has no class return type
1330     */
1331    private function abstractFromCallable(callable $callable): string
1332    {
1333        $ref = new ReflectionFunction(Closure::fromCallable($callable));
1334        $type = $ref->getReturnType();
1335
1336        if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
1337            return $type->getName();
1338        }
1339
1340        throw new ContainerException(
1341            'A callable passed as the abstract must declare a class return type, e.g. ' .
1342            'singleton(fn (): Mailer => new Mailer(...)).'
1343        );
1344    }
1345}