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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
}
],
"require": {
"php": ">=8.2",
"php": ">=8.1",
"internal/destroy": "^1.0",
"psr/container": "1 - 2",
"yiisoft/injector": "^1.2"
Expand All @@ -33,6 +33,7 @@
"bamarni/composer-bin-plugin": "^1.8",
"llm/skills": "^1.12",
"revolt/event-loop": "^1.0",
"roxblnfk/unpoly": "^1.8",
"testo/codecov": "^0.2.1",
"testo/fiber": "^0.1.3",
"testo/testo": "^0.10.46"
Expand Down
2 changes: 1 addition & 1 deletion psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
errorLevel="1"
findUnusedBaselineEntry="false"
findUnusedCode="false"
phpVersion="8.2"
phpVersion="8.1"
>
<issueHandlers>
<RedundantConditionGivenDocblockType errorLevel="suppress"/>
Expand Down
18 changes: 18 additions & 0 deletions src/Attribute/ScopeShared.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Internal\Container\Attribute;

/**
* Marks a service to be shared across scopes instead of cloned per-scope.
*
* By default a scope clones each cached service when a child scope is derived, so every scope owns
* its own mutable instance. A class marked with this attribute keeps a single instance shared across
* the whole scope tree — use it for services that are safe to share (e.g. stateless or immutable ones)
* on any supported PHP version, including where `readonly class` is unavailable.
*
* The attribute is not inherited: a subclass must be marked explicitly to be treated as shared.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class ScopeShared {}
7 changes: 6 additions & 1 deletion src/Internal/State.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Internal\Container\Internal;

use Internal\Container\Attribute\ScopeShared;
use Internal\Container\Container;
use Internal\Container\Factoriable;
use Internal\Container\Inflector;
Expand Down Expand Up @@ -172,7 +173,11 @@ public function clone(ObjectContainer $container): self
}

$reflection = new \ReflectionClass($service);
if ($reflection->isReadOnly() || $reflection->isEnum()) {
if (
$reflection->isEnum()
|| (PHP_VERSION_ID >= 80200 && $reflection->isReadOnly())
|| $reflection->getAttributes(ScopeShared::class) !== []
) {
$self->cache[$id] = $service;
continue;
}
Expand Down
78 changes: 78 additions & 0 deletions tests/Unit/ScopeSharedAttributeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Internal\Container\Tests\Unit;

use Internal\Container\Attribute\ScopeShared;
use Internal\Container\ObjectContainer;
use Internal\Container\Tests\Unit\Stub\ContainerScopeService;
use Internal\Container\Tests\Unit\Stub\ScopeSharedTag;
use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Test;

/**
* {@see ScopeShared} — a service carrying the attribute is shared across scopes instead of cloned.
*
* A scope clones each cached service so children get their own mutable copy; the attribute opts a class
* out of that, keeping one instance across the whole scope tree even when it is a plain mutable class
* (neither `readonly` nor an enum).
*/
#[Test]
#[Covers(ObjectContainer::class)]
final class ScopeSharedAttributeTest
{
public function scopeSharesMarkedServiceWithTheParent(): void
{
$container = new ObjectContainer();
$parent = $container->get(ScopeSharedTag::class);

$inScope = $container->scope(
static fn(ObjectContainer $scoped): ScopeSharedTag => $scoped->get(ScopeSharedTag::class),
);

Assert::same($inScope, $parent);
}

public function mutatingTheSharedServiceInsideAScopeIsVisibleToTheParent(): void
{
$container = new ObjectContainer();
$parent = $container->get(ScopeSharedTag::class);

$container->scope(static function (ObjectContainer $scoped): void {
$scoped->get(ScopeSharedTag::class)->tag = 42;
});

Assert::same($parent->tag, 42);
}

public function nestedScopesShareTheSameMarkedInstance(): void
{
$container = new ObjectContainer();
$parent = $container->get(ScopeSharedTag::class);

$deepest = $container->scope(
static fn(ObjectContainer $l1): ScopeSharedTag => $l1->scope(
static fn(ObjectContainer $l2): ScopeSharedTag => $l2->get(ScopeSharedTag::class),
),
);

Assert::same($deepest, $parent);
}

/**
* The attribute is the differentiator: an otherwise identical unmarked mutable service is still cloned.
*/
public function unmarkedServiceIsStillClonedPerScope(): void
{
$container = new ObjectContainer();
$parent = $container->get(ContainerScopeService::class);

$inScope = $container->scope(
static fn(ObjectContainer $scoped): ContainerScopeService => $scoped->get(ContainerScopeService::class),
);

Assert::notSame($inScope, $parent);
}
}
21 changes: 21 additions & 0 deletions tests/Unit/Stub/ScopeSharedTag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Internal\Container\Tests\Unit\Stub;

use Internal\Container\Attribute\ScopeShared;

/**
* Mutable service marked {@see ScopeShared} — a scope shares it with its parent instead of cloning it.
*
* Deliberately not `readonly` and not an enum, so a test that observes sharing proves the attribute is
* the cause rather than immutability.
*
* @internal
*/
#[ScopeShared]
final class ScopeSharedTag
{
public int $tag = 7;
}
Loading