Skip to content

feat(refactor-deps): replace laminas-servicemanager with lightweight PSR-11 container#12

Open
Fahl-Design wants to merge 9 commits intomainfrom
feature/replace-laminas-servicemanager-with-simple-container
Open

feat(refactor-deps): replace laminas-servicemanager with lightweight PSR-11 container#12
Fahl-Design wants to merge 9 commits intomainfrom
feature/replace-laminas-servicemanager-with-simple-container

Conversation

@Fahl-Design
Copy link
Copy Markdown
Member

@Fahl-Design Fahl-Design commented Apr 13, 2026

Summary

  • Drop laminas/laminas-servicemanager to avoid downstream dependency conflicts
  • Introduce src/Container/SimpleContainer.php — a minimal PSR-11 container supporting Mezzio's ConfigProvider format (factories, aliases, invokables, delegators)
  • Widen psr/container constraint to ^1.1 || ^2.0 (matching Symfony/Mezzio convention)
  • Remove slam/phpstan-laminas-framework dev dependency (tied to ServiceManager)

Test plan

  • PHPStan level 8 passes
  • PHP-CS-Fixer passes
  • All 110 Codeception tests pass
  • Verify CI pipeline passes

Summary by CodeRabbit

  • Chores

    • Replaced the external service manager with a new lightweight internal container; removed an unused dependency and its static-analysis bootstrap.
  • Bug Fixes

    • Detect circular container aliases and raise a clear error.
    • Use a more robust base-directory resolution and stricter OpenAPI spec defaulting.
  • Tests

    • Added unit tests for factory resolution behaviors and alias/error handling.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces Laminas ServiceManager with a new PSR-11 SimpleContainer, adds PSR-11 exception classes, updates container instantiation and tests, removes PHPStan Laminas bootstrap and adjusts analyzed paths, updates Composer dependencies, and tweaks OpenAPI spec path resolution in routing config. (38 words)

Changes

Cohort / File(s) Summary
Dependency Management
composer.json
Removed laminas/laminas-servicemanager from require and slam/phpstan-laminas-framework from require-dev.
Container Implementation
src/Container/SimpleContainer.php, src/Container/Exception/ContainerException.php, src/Container/Exception/NotFoundException.php
Added final PSR-11 SimpleContainer with support for services, factories, invokables, aliases, delegators, and caching; added PSR-11 ContainerException and NotFoundException classes.
Container Configuration
config/container.php
Switched import/instantiation from Laminas\ServiceManager\ServiceManager to WebProject\PhpOpenApiMockServer\Container\SimpleContainer.
Static Analysis / PHPStan
phpstan.neon, tests/phpstan-container.php
Added bin and config to PHPStan analyzed paths; removed bootstrapFiles and Laminas-specific loader block from phpstan.neon; deleted tests/phpstan-container.php.
Routing Config
config/routes.php
Replaced realpath(__DIR__ . '/..') ?: '/app' with dirname(__DIR__) and changed OPENAPI_SPEC defaulting so null (not false) triggers the default spec path.
Unit Tests
tests/Unit/SimpleContainerFactoryTest.php, tests/Unit/SimpleContainerTest.php
Added tests covering 0/1/2-arg factories, native function and invokable factories, invokable object factories, and circular-alias behavior (has vs get).

Sequence Diagram

sequenceDiagram
    participant Client
    participant SimpleContainer
    participant Aliases
    participant Cache
    participant Factories
    participant Delegators

    Client->>SimpleContainer: get(id)
    SimpleContainer->>Aliases: resolve(id)
    Aliases-->>SimpleContainer: resolvedId
    SimpleContainer->>Cache: has(resolvedId)?
    alt cache hit
        Cache-->>SimpleContainer: instance
        SimpleContainer-->>Client: instance
    else cache miss
        SimpleContainer->>Factories: fetch factory(resolvedId)
        Factories-->>SimpleContainer: factory
        SimpleContainer->>SimpleContainer: invoke factory(this[, resolvedId])
        SimpleContainer->>Delegators: apply delegators(resolvedId, next)
        Delegators-->>SimpleContainer: decoratedInstance
        SimpleContainer->>Cache: store(resolvedId, decoratedInstance)
        SimpleContainer-->>Client: decoratedInstance
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I swapped a heavy wheel for a nimble spring,
Factories whisper, aliases sing,
Delegators stitch like carrot thread,
Services hop into bed,
Small paws, proud containering! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective: replacing laminas-servicemanager dependency with a lightweight PSR-11 container implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/replace-laminas-servicemanager-with-simple-container

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Fahl-Design Fahl-Design changed the title refactor(deps): replace laminas-servicemanager with lightweight PSR-11 container feat(refactor-deps): replace laminas-servicemanager with lightweight PSR-11 container Apr 13, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Container/SimpleContainer.php`:
- Around line 52-53: Replace bare RuntimeException throws in SimpleContainer
(e.g., the is_callable($factory) check in the factory resolution, the invalid
delegator error, and the circular-alias failure) with exceptions implementing
Psr\Container\ContainerExceptionInterface; follow the existing pattern used for
the NotFound anonymous class by returning/throwing an anonymous class that
implements ContainerExceptionInterface and extends RuntimeException (or wraps
the message), or create a small dedicated ContainerException class and throw
that from resolveFactory, resolveDelegator, and alias-resolution code paths
(refer to symbols is_callable($factory), resolveDelegator/resolveFactory, and
alias resolution/circular-alias handling to locate each throw site).
- Around line 34-35: Remove the PHP 8-only return type from the
SimpleContainer::get method signature: locate the public function get(string
$id): mixed declaration and drop the ": mixed" return type so the method has no
PHP 8 return type, keeping only the parameter type; update or add a PHPDoc above
get() if needed to document the possible return types (e.g. mixed) for clarity
and PSR-11 compatibility.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 94b03de6-13a6-41e9-a63d-c21eb033cab4

📥 Commits

Reviewing files that changed from the base of the PR and between 384a5d6 and bdd7b07.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • composer.json
  • config/container.php
  • phpstan.neon
  • src/Container/SimpleContainer.php
  • tests/phpstan-container.php
💤 Files with no reviewable changes (3)
  • composer.json
  • phpstan.neon
  • tests/phpstan-container.php

…1 container

Drop laminas/laminas-servicemanager to avoid downstream dependency
conflicts. Introduce a minimal SimpleContainer that implements
PSR-11 ContainerInterface and supports the Mezzio ConfigProvider
format (factories, aliases, invokables, delegators).

- Add src/Container/SimpleContainer.php
- Update config/container.php to use SimpleContainer
- Widen psr/container to ^1.1 || ^2.0 (matching Symfony/Mezzio convention)
- Remove slam/phpstan-laminas-framework (tied to ServiceManager)
- Remove tests/phpstan-container.php (no longer needed)
PSR-11 requires get() to throw ContainerExceptionInterface for general
errors. Apply the same anonymous-class pattern already used for
NotFoundExceptionInterface to factory, delegator, and circular-alias
error paths.
resolveAlias() is called from both get() and has(); throwing
ContainerExceptionInterface from has() would violate PSR-11.
…Exception types

Add dedicated ContainerException and NotFoundException in
Container\Exception namespace, replacing inline anonymous classes.
@Fahl-Design Fahl-Design force-pushed the feature/replace-laminas-servicemanager-with-simple-container branch from c850e76 to eab20a6 Compare April 13, 2026 17:48
@Fahl-Design
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Container/SimpleContainer.php`:
- Around line 79-83: has() currently calls resolveAlias() which can throw a
ContainerException on circular aliases, violating PSR-11; change
resolveAlias(string $id, bool $throwOnCircular = true) to make circular-alias
detection conditional and update has() to call resolveAlias($id, false) and
return false when resolveAlias indicates a circular alias (or returns
null/false) while leaving get() to call resolveAlias($id, true) to preserve
existing exception behavior; reference resolveAlias, has, and get in the fix.
- Line 56: The get() method currently calls factories as $factory($this,
$resolved) which breaks 0- and 1-arg factories (e.g., closures from
setInvokableClass() and config factories like
OpenApiMockMiddlewareFactory::__invoke). Update the factory invocation in
SimpleContainer::get to detect the callable's required parameter count (via
ReflectionFunction/ReflectionMethod or ReflectionParameter inspection) and
invoke the factory with the appropriate arguments (no args, only $this, or $this
and $resolved) so all registered factories (0-, 1- and 2-arg) are called safely;
ensure you reference the factory callable used in get() and keep existing
resolved resolution logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b5777a29-d309-4a92-a6dc-fc7af9a63c20

📥 Commits

Reviewing files that changed from the base of the PR and between bdd7b07 and eab20a6.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • composer.json
  • config/container.php
  • phpstan.neon
  • src/Container/Exception/ContainerException.php
  • src/Container/Exception/NotFoundException.php
  • src/Container/SimpleContainer.php
  • tests/phpstan-container.php
💤 Files with no reviewable changes (3)
  • tests/phpstan-container.php
  • composer.json
  • phpstan.neon
✅ Files skipped from review due to trivial changes (3)
  • src/Container/Exception/NotFoundException.php
  • config/container.php
  • src/Container/Exception/ContainerException.php

@Fahl-Design
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/Unit/SimpleContainerTest.php (1)

19-28: Remove stale comments in test method.

Lines 25-26 contain comments that are now misleading: they describe a "current behavior" and "desired behavior" that suggest the test was written before the fix. Since the fix is now implemented and the test passes, these comments should be removed.

Suggested fix
     public function testHasReturnsFalseOnCircularAlias(): void
     {
         $container = new SimpleContainer();
         $container->setAlias('a', 'b');
         $container->setAlias('b', 'a');
 
-        // Current behavior: this throws ContainerException
-        // Desired behavior: this returns false
         self::assertFalse($container->has('a'));
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Unit/SimpleContainerTest.php` around lines 19 - 28, Remove the outdated
explanatory comments inside the test method testHasReturnsFalseOnCircularAlias:
open the method in SimpleContainerTest (the method that constructs a
SimpleContainer, calls setAlias('a','b') and setAlias('b','a') and
assertsFalse($container->has('a'))), and delete the two lines that start with
"// Current behavior..." and "// Desired behavior..." so the test only contains
the setup and assertion without stale commentary.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/Unit/SimpleContainerTest.php`:
- Around line 19-28: Remove the outdated explanatory comments inside the test
method testHasReturnsFalseOnCircularAlias: open the method in
SimpleContainerTest (the method that constructs a SimpleContainer, calls
setAlias('a','b') and setAlias('b','a') and assertsFalse($container->has('a'))),
and delete the two lines that start with "// Current behavior..." and "//
Desired behavior..." so the test only contains the setup and assertion without
stale commentary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 01897dd1-2d6f-4f91-a0f9-856a08586656

📥 Commits

Reviewing files that changed from the base of the PR and between eab20a6 and 58eb423.

📒 Files selected for processing (3)
  • src/Container/SimpleContainer.php
  • tests/Unit/SimpleContainerFactoryTest.php
  • tests/Unit/SimpleContainerTest.php
✅ Files skipped from review due to trivial changes (1)
  • tests/Unit/SimpleContainerFactoryTest.php

@Fahl-Design
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
config/routes.php (1)

15-15: ?: '/app' fallback is likely unreachable here.

dirname(__DIR__) should always return a non-empty string in this context, so the fallback adds noise without effect.

♻️ Suggested simplification
-    $packageRoot = dirname(__DIR__) ?: '/app';
+    $packageRoot = dirname(__DIR__);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/routes.php` at line 15, The fallback "?: '/app'" is unnecessary
because dirname(__DIR__) always returns a non-empty string here; simplify by
removing the redundant null-coalescing fallback and assign $packageRoot directly
from dirname(__DIR__) (update the assignment using the $packageRoot variable and
the dirname(__DIR__) expression in config/routes.php).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Container/SimpleContainer.php`:
- Around line 160-177: The setters currently don't remove competing
registrations so older layers can shadow newer ones; update setService,
setFactory (and setInvokableClass), and setAlias to clear any conflicting
entries before writing: setService(string $name, mixed $service) should unset
$this->aliases[$name] and $this->factories[$name] before assigning
$this->services[$name]; setFactory(string $name, callable|string $factory) and
setInvokableClass(string $name, string $class) should unset
$this->aliases[$name] and $this->services[$name] before assigning
$this->factories[$name]; setAlias(string $alias, string $target) should unset
$this->services[$alias] and $this->factories[$alias] before assigning
$this->aliases[$alias]; this ensures the last registration wins given the lookup
order (aliases -> services -> factories).

---

Nitpick comments:
In `@config/routes.php`:
- Line 15: The fallback "?: '/app'" is unnecessary because dirname(__DIR__)
always returns a non-empty string here; simplify by removing the redundant
null-coalescing fallback and assign $packageRoot directly from dirname(__DIR__)
(update the assignment using the $packageRoot variable and the dirname(__DIR__)
expression in config/routes.php).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46d15eeb-82ea-4bab-9a96-8cbe13744396

📥 Commits

Reviewing files that changed from the base of the PR and between 58eb423 and 1d94b2c.

📒 Files selected for processing (4)
  • config/routes.php
  • phpstan.neon
  • src/Container/SimpleContainer.php
  • tests/Unit/SimpleContainerTest.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • phpstan.neon
  • tests/Unit/SimpleContainerTest.php

Comment on lines +49 to +106
public function get(string $id): mixed
{
$resolved = $this->resolveAlias($id, true);
Assert::string($resolved);

if (array_key_exists($resolved, $this->services)) {
return $this->services[$resolved];
}

if (!isset($this->factories[$resolved])) {
throw new NotFoundException("Service '{$id}' not found in container.");
}

$factory = $this->factories[$resolved];

if (is_string($factory) && class_exists($factory)) {
$factory = new $factory();
}

if (!is_callable($factory)) {
throw new ContainerException("Factory for '{$resolved}' is not callable.");
}

$reflection = match (true) {
$factory instanceof Closure => new ReflectionFunction($factory),
is_string($factory) && !str_contains($factory, '::') => new ReflectionFunction($factory),
is_string($factory) && str_contains($factory, '::') => new ReflectionMethod($factory),
is_array($factory) => new ReflectionMethod($factory[0], $factory[1]),
is_object($factory) => new ReflectionMethod($factory, '__invoke'),
default => throw new ContainerException("Unsupported factory type for '{$resolved}'."),
};

$params = $reflection->getNumberOfParameters();
$instance = match (true) {
0 === $params => $factory(),
1 === $params => $factory($this),
default => $factory($this, $resolved),
};

if (isset($this->delegators[$resolved])) {
foreach ($this->delegators[$resolved] as $delegator) {
$current = $instance;

if (is_string($delegator) && class_exists($delegator)) {
$delegator = new $delegator();
}

if (!is_callable($delegator)) {
throw new ContainerException("Delegator for '{$resolved}' is not callable.");
}

$instance = $delegator($this, $resolved, static fn (): mixed => $current);
}
}

$this->services[$resolved] = $instance;

return $instance;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Detect circular service resolution before invoking nested get() calls.

Alias loops are handled, but factory/delegator loops are not. If service A resolves B and B resolves A, this path recurses until PHP blows the stack or memory instead of failing with a ContainerException.

Suggested fix
 final class SimpleContainer implements ContainerInterface
 {
     /** `@var` array<string, mixed> */
     private array $services = [];
+
+    /** `@var` array<string, true> */
+    private array $resolving = [];
@@
     public function get(string $id): mixed
     {
         $resolved = $this->resolveAlias($id, true);
         Assert::string($resolved);
+
+        if (isset($this->resolving[$resolved])) {
+            throw new ContainerException("Circular service dependency detected for '{$resolved}'.");
+        }
 
         if (array_key_exists($resolved, $this->services)) {
             return $this->services[$resolved];
         }
@@
-        $factory = $this->factories[$resolved];
+        $this->resolving[$resolved] = true;
+
+        try {
+            $factory = $this->factories[$resolved];
 
-        if (is_string($factory) && class_exists($factory)) {
-            $factory = new $factory();
-        }
+            if (is_string($factory) && class_exists($factory)) {
+                $factory = new $factory();
+            }
@@
-        if (isset($this->delegators[$resolved])) {
-            foreach ($this->delegators[$resolved] as $delegator) {
-                $current = $instance;
+            if (isset($this->delegators[$resolved])) {
+                foreach ($this->delegators[$resolved] as $delegator) {
+                    $current = $instance;
@@
-                $instance = $delegator($this, $resolved, static fn (): mixed => $current);
+                    $instance = $delegator($this, $resolved, static fn (): mixed => $current);
+                }
             }
-        }
 
-        $this->services[$resolved] = $instance;
+            $this->services[$resolved] = $instance;
 
-        return $instance;
+            return $instance;
+        } finally {
+            unset($this->resolving[$resolved]);
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function get(string $id): mixed
{
$resolved = $this->resolveAlias($id, true);
Assert::string($resolved);
if (array_key_exists($resolved, $this->services)) {
return $this->services[$resolved];
}
if (!isset($this->factories[$resolved])) {
throw new NotFoundException("Service '{$id}' not found in container.");
}
$factory = $this->factories[$resolved];
if (is_string($factory) && class_exists($factory)) {
$factory = new $factory();
}
if (!is_callable($factory)) {
throw new ContainerException("Factory for '{$resolved}' is not callable.");
}
$reflection = match (true) {
$factory instanceof Closure => new ReflectionFunction($factory),
is_string($factory) && !str_contains($factory, '::') => new ReflectionFunction($factory),
is_string($factory) && str_contains($factory, '::') => new ReflectionMethod($factory),
is_array($factory) => new ReflectionMethod($factory[0], $factory[1]),
is_object($factory) => new ReflectionMethod($factory, '__invoke'),
default => throw new ContainerException("Unsupported factory type for '{$resolved}'."),
};
$params = $reflection->getNumberOfParameters();
$instance = match (true) {
0 === $params => $factory(),
1 === $params => $factory($this),
default => $factory($this, $resolved),
};
if (isset($this->delegators[$resolved])) {
foreach ($this->delegators[$resolved] as $delegator) {
$current = $instance;
if (is_string($delegator) && class_exists($delegator)) {
$delegator = new $delegator();
}
if (!is_callable($delegator)) {
throw new ContainerException("Delegator for '{$resolved}' is not callable.");
}
$instance = $delegator($this, $resolved, static fn (): mixed => $current);
}
}
$this->services[$resolved] = $instance;
return $instance;
/** `@var` array<string, mixed> */
private array $services = [];
/** `@var` array<string, true> */
private array $resolving = [];
public function get(string $id): mixed
{
$resolved = $this->resolveAlias($id, true);
Assert::string($resolved);
if (isset($this->resolving[$resolved])) {
throw new ContainerException("Circular service dependency detected for '{$resolved}'.");
}
if (array_key_exists($resolved, $this->services)) {
return $this->services[$resolved];
}
if (!isset($this->factories[$resolved])) {
throw new NotFoundException("Service '{$id}' not found in container.");
}
$this->resolving[$resolved] = true;
try {
$factory = $this->factories[$resolved];
if (is_string($factory) && class_exists($factory)) {
$factory = new $factory();
}
if (!is_callable($factory)) {
throw new ContainerException("Factory for '{$resolved}' is not callable.");
}
$reflection = match (true) {
$factory instanceof Closure => new ReflectionFunction($factory),
is_string($factory) && !str_contains($factory, '::') => new ReflectionFunction($factory),
is_string($factory) && str_contains($factory, '::') => new ReflectionMethod($factory),
is_array($factory) => new ReflectionMethod($factory[0], $factory[1]),
is_object($factory) => new ReflectionMethod($factory, '__invoke'),
default => throw new ContainerException("Unsupported factory type for '{$resolved}'."),
};
$params = $reflection->getNumberOfParameters();
$instance = match (true) {
0 === $params => $factory(),
1 === $params => $factory($this),
default => $factory($this, $resolved),
};
if (isset($this->delegators[$resolved])) {
foreach ($this->delegators[$resolved] as $delegator) {
$current = $instance;
if (is_string($delegator) && class_exists($delegator)) {
$delegator = new $delegator();
}
if (!is_callable($delegator)) {
throw new ContainerException("Delegator for '{$resolved}' is not callable.");
}
$instance = $delegator($this, $resolved, static fn (): mixed => $current);
}
}
$this->services[$resolved] = $instance;
return $instance;
} finally {
unset($this->resolving[$resolved]);
}
}

@Fahl-Design
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/Container/SimpleContainer.php (2)

49-106: ⚠️ Potential issue | 🟠 Major

Detect circular service resolution before invoking nested get() calls.

Alias loops are handled by resolveAlias(), but factory/delegator loops are not. If service A's factory resolves B and B's factory resolves A, the recursion continues until PHP exhausts the stack—no ContainerException is thrown.

Suggested fix using a resolution guard
 final class SimpleContainer implements ContainerInterface
 {
     /** `@var` array<string, mixed> */
     private array $services = [];

+    /** `@var` array<string, true> */
+    private array $resolving = [];
+
     /** `@var` array<string, callable|string> */
     private array $factories = [];

Then in get():

     public function get(string $id): mixed
     {
         $resolved = $this->resolveAlias($id, true);
         Assert::string($resolved);

+        if (isset($this->resolving[$resolved])) {
+            throw new ContainerException("Circular service dependency detected for '{$resolved}'.");
+        }
+
         if (array_key_exists($resolved, $this->services)) {
             return $this->services[$resolved];
         }

         if (!isset($this->factories[$resolved])) {
             throw new NotFoundException("Service '{$id}' not found in container.");
         }

+        $this->resolving[$resolved] = true;
+
+        try {
             $factory = $this->factories[$resolved];
             // ... factory invocation and delegator logic ...
             $this->services[$resolved] = $instance;
             return $instance;
+        } finally {
+            unset($this->resolving[$resolved]);
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Container/SimpleContainer.php` around lines 49 - 106, The get() method
needs a resolution guard to detect circular factory/delegator resolution: add a
property (e.g., array $resolving) to track ids currently being constructed,
check resolveAlias($id) against that guard at the start of get() and throw a
ContainerException if it's already present, push the resolved id onto $resolving
before invoking the factory or delegators, and remove it in a finally block so
the entry is cleared even on errors; reference the get(), resolveAlias(),
$this->factories, $this->delegators and $this->services symbols when
implementing the guard and ensure nested get() calls will hit the guard and
throw a clear ContainerException on cycles.

151-157: ⚠️ Potential issue | 🟡 Minor

Delegators accumulate across multiple configure() calls and survive service re-registration.

Unlike setService/setFactory/setAlias which clear competing registrations, delegators are only appended. This means:

  1. Calling configure() twice with the same delegator config duplicates delegator entries
  2. Re-registering a service via setFactory() still applies previously registered delegators

If this is intentional, consider documenting the behavior. Otherwise, consider clearing delegators when the target service is re-registered.

Optional: clear delegators on re-registration
     public function setFactory(string $name, callable|string $factory): void
     {
-        unset($this->aliases[$name], $this->services[$name]);
+        unset($this->aliases[$name], $this->services[$name], $this->delegators[$name]);
         $this->factories[$name] = $factory;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Container/SimpleContainer.php` around lines 151 - 157, Delegators are
being appended in configure() causing duplicates and persisting across
re-registration; modify behavior so delegators do not accumulate unexpectedly by
either resetting entries on configure() or clearing them when a service is
re-registered. Specifically, update configure() (where $this->delegators is
populated) to replace the delegators for a given service name instead of always
appending (use direct assignment rather than $this->delegators[$name][]), or add
a clearing step in setFactory(), setService(), and setAlias() to unset or empty
$this->delegators[$name] when a service is re-registered; reference the methods
configure(), setFactory(), setService(), setAlias() and the $delegators property
to locate where to apply the change. Ensure tests cover calling configure()
twice and re-registering a service to confirm no duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/Container/SimpleContainer.php`:
- Around line 49-106: The get() method needs a resolution guard to detect
circular factory/delegator resolution: add a property (e.g., array $resolving)
to track ids currently being constructed, check resolveAlias($id) against that
guard at the start of get() and throw a ContainerException if it's already
present, push the resolved id onto $resolving before invoking the factory or
delegators, and remove it in a finally block so the entry is cleared even on
errors; reference the get(), resolveAlias(), $this->factories, $this->delegators
and $this->services symbols when implementing the guard and ensure nested get()
calls will hit the guard and throw a clear ContainerException on cycles.
- Around line 151-157: Delegators are being appended in configure() causing
duplicates and persisting across re-registration; modify behavior so delegators
do not accumulate unexpectedly by either resetting entries on configure() or
clearing them when a service is re-registered. Specifically, update configure()
(where $this->delegators is populated) to replace the delegators for a given
service name instead of always appending (use direct assignment rather than
$this->delegators[$name][]), or add a clearing step in setFactory(),
setService(), and setAlias() to unset or empty $this->delegators[$name] when a
service is re-registered; reference the methods configure(), setFactory(),
setService(), setAlias() and the $delegators property to locate where to apply
the change. Ensure tests cover calling configure() twice and re-registering a
service to confirm no duplication.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e179e559-ef70-4458-88fe-59369b22d400

📥 Commits

Reviewing files that changed from the base of the PR and between 1d94b2c and db4a48d.

📒 Files selected for processing (2)
  • config/routes.php
  • src/Container/SimpleContainer.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • config/routes.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant