Skip to content
11 changes: 11 additions & 0 deletions src/Phaseolies/DI/Attributes/Immutable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Phaseolies\DI\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final class Immutable
{
public function __construct() {}
}
128 changes: 128 additions & 0 deletions src/Phaseolies/DI/Concerns/EnforcesImmutability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

namespace Phaseolies\DI\Concerns;

use Phaseolies\DI\Exceptions\ImmutableViolationException;

trait EnforcesImmutability
{
/**
* Holds all public property values after they are unset on freeze()
*
* @var array
*/
private array $__immutableProperties = [];

/**
* Whether this instance has been frozen by the container
*
* @var bool
*/
private bool $__frozen = false;

/**
* Called by Container::freezeIfImmutable() after build() completes
*
* Snapshots all public instance properties into the internal map,
* then unsets them — forcing future access through __get/__set.
*
* @return void
*/
public function freeze(): void
{
$reflector = new \ReflectionClass(static::class);

foreach ($reflector->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
if ($property->isStatic()) {
continue;
}

$name = $property->getName();

$this->__immutableProperties[$name] = $property->isInitialized($this)
? $property->getValue($this)
: null;

unset($this->$name);
}

$this->__frozen = true;
}

/**
* Check if the instance is frozen
*
* @return bool
*/
public function isFrozen(): bool
{
return $this->__frozen;
}

/**
* Serve reads from the frozen snapshot
*
* @throws ImmutableViolationException on write attempts after freeze()
* @throws \RuntimeException on access to undefined properties
* @return mixed
*/
public function __get(string $name): mixed
{
if (array_key_exists($name, $this->__immutableProperties)) {
return $this->__immutableProperties[$name];
}

throw new \RuntimeException(
"Property \${$name} does not exist on " . static::class
);
}

/**
* Block all writes once frozen
*
* @throws ImmutableViolationException
* @return void
*/
public function __set(string $name, mixed $value): void
{
if ($this->__frozen) {
throw new ImmutableViolationException(static::class, $name);
}

$this->$name = $value;
}

/**
* Forward isset() checks to the frozen map
*
* @return bool
*/
public function __isset(string $name): bool
{
return isset($this->__immutableProperties[$name]);
}

/**
* Block unset() — it is a mutation
*
* @throws ImmutableViolationException
* @return void
*/
public function __unset(string $name): void
{
throw new ImmutableViolationException(static::class, $name);
}

/**
* Prevent cloning of frozen services
*
* @throws ImmutableViolationException
* @return void
*/
public function __clone(): void
{
if ($this->__frozen) {
throw new ImmutableViolationException(static::class, '__clone');
}
}
}
41 changes: 31 additions & 10 deletions src/Phaseolies/DI/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Phaseolies\DI;

use ArrayAccess;
use Phaseolies\DI\Attributes\Immutable;

class Container implements ArrayAccess
{
Expand Down Expand Up @@ -154,7 +155,7 @@ public function instance(string $abstract, mixed $instance): void
* @param class-string<T> $abstract
* @param array $parameters
* @return T
* @throws RuntimeException
* @throws \RuntimeException
*/
public function get(string $abstract, array $parameters = []): mixed
{
Expand Down Expand Up @@ -240,7 +241,7 @@ public function make(string $abstract, array $parameters = []): mixed
* @param class-string<T> $concrete
* @param array $parameters
* @return T
* @throws RuntimeException
* @throws \RuntimeException
*/
public function build(string $concrete, array $parameters = []): object
{
Expand All @@ -257,16 +258,36 @@ public function build(string $concrete, array $parameters = []): object
$constructor = $reflector->getConstructor();

if (is_null($constructor)) {
return new $concrete(...$parameters);
$instance = new $concrete(...$parameters);
} else {
$dependencies = $this->resolveDependencies(
$constructor->getParameters(),
$parameters,
$concrete
);
$instance = $reflector->newInstanceArgs($dependencies);
}

$dependencies = $this->resolveDependencies(
$constructor->getParameters(),
$parameters,
$concrete
);
$this->freezeIfImmutable($reflector, $instance);

return $reflector->newInstanceArgs($dependencies);
return $instance;
}

/**
* Freeze the instance if the class has the #[Immutable] attribute and uses the EnforcesImmutability trait.
*
* @param \ReflectionClass $reflector
* @param object $instance
* @return void
*/
private function freezeIfImmutable(\ReflectionClass $reflector, object $instance): void
{
$hasAttribute = !empty($reflector->getAttributes(Immutable::class));
$usesTrait = method_exists($instance, 'freeze') && method_exists($instance, 'isFrozen');

if ($hasAttribute && $usesTrait) {
$instance->freeze();
}
}

/**
Expand All @@ -292,7 +313,7 @@ protected function resolveDependencies(array $parameters, array $primitives = []
/**
* Resolve a single dependency
*
* @param ReflectionParameter $parameter
* @param \ReflectionParameter $parameter
* @param array $primitives
* @param string $className
* @return mixed
Expand Down
16 changes: 16 additions & 0 deletions src/Phaseolies/DI/Exceptions/ImmutableViolationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Phaseolies\DI\Exceptions;

use RuntimeException;

class ImmutableViolationException extends RuntimeException
{
public function __construct(string $class, string $property)
{
parent::__construct(
"Cannot mutate property \${$property} on immutable service [{$class}]. " .
"Services marked #[Immutable] are frozen after instantiation."
);
}
}
Loading
Loading