From b483030e477026f5813d928540ec845c8ab712be Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 13:14:57 +0400 Subject: [PATCH] feat: support PHP 8.1 via #[ScopeShared] attribute Lower the minimum PHP version to 8.1. Since `readonly class` and ReflectionClass::isReadOnly() are 8.2-only, add a #[ScopeShared] attribute that opts a service out of per-scope cloning explicitly, so the same instance is shared across the whole scope tree on any supported version. State::clone() now shares a service when it is an enum, a readonly class (8.2+), or carries #[ScopeShared]. Refs #4 Assisted-By: Claude Opus 4.8 (1M context) --- composer.json | 3 +- psalm.xml | 2 +- src/Attribute/ScopeShared.php | 18 ++++++ src/Internal/State.php | 7 ++- tests/Unit/ScopeSharedAttributeTest.php | 78 +++++++++++++++++++++++++ tests/Unit/Stub/ScopeSharedTag.php | 21 +++++++ 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/Attribute/ScopeShared.php create mode 100644 tests/Unit/ScopeSharedAttributeTest.php create mode 100644 tests/Unit/Stub/ScopeSharedTag.php diff --git a/composer.json b/composer.json index 24d1a04..7630986 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ } ], "require": { - "php": ">=8.2", + "php": ">=8.1", "internal/destroy": "^1.0", "psr/container": "1 - 2", "yiisoft/injector": "^1.2" @@ -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" diff --git a/psalm.xml b/psalm.xml index a350484..27598b8 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,7 +5,7 @@ errorLevel="1" findUnusedBaselineEntry="false" findUnusedCode="false" - phpVersion="8.2" + phpVersion="8.1" > diff --git a/src/Attribute/ScopeShared.php b/src/Attribute/ScopeShared.php new file mode 100644 index 0000000..b8934fb --- /dev/null +++ b/src/Attribute/ScopeShared.php @@ -0,0 +1,18 @@ +isReadOnly() || $reflection->isEnum()) { + if ( + $reflection->isEnum() + || (PHP_VERSION_ID >= 80200 && $reflection->isReadOnly()) + || $reflection->getAttributes(ScopeShared::class) !== [] + ) { $self->cache[$id] = $service; continue; } diff --git a/tests/Unit/ScopeSharedAttributeTest.php b/tests/Unit/ScopeSharedAttributeTest.php new file mode 100644 index 0000000..5c025af --- /dev/null +++ b/tests/Unit/ScopeSharedAttributeTest.php @@ -0,0 +1,78 @@ +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); + } +} diff --git a/tests/Unit/Stub/ScopeSharedTag.php b/tests/Unit/Stub/ScopeSharedTag.php new file mode 100644 index 0000000..13a176e --- /dev/null +++ b/tests/Unit/Stub/ScopeSharedTag.php @@ -0,0 +1,21 @@ +