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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,13 @@ On top of those base traits **Complex Heart** provide ready to use compositions:
- `IsEntity` composed by `IsModel`, `HasIdentity`, `HasEquality`.
- `IsAggregate` composed by `IsEntity`, `HasDomainEvents`.

For more information please check the wiki.
## Key Features

- **Type-Safe Factory Method**: The `make()` static factory validates constructor parameters at runtime with clear error messages
- **Automatic Invariant Checking**: When using `make()`, Value Objects and Entities automatically validate invariants after construction (no manual `$this->check()` needed)
- **Readonly Properties Support**: Full compatibility with PHP 8.1+ readonly properties
- **PHPStan Level 8**: Complete static analysis support

> **Note:** Automatic invariant checking only works when using the `make()` factory method. Direct constructor calls require manual `$this->check()` in the constructor.

For more information and usage examples, please check the wiki.
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"pestphp/pest-plugin-faker": "^2.0",
"phpstan/phpstan": "^1.0",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan-mockery": "^1.1"
"phpstan/phpstan-mockery": "^1.1",
"laravel/pint": "^1.25"
},
"autoload": {
"psr-4": {
Expand All @@ -41,11 +42,12 @@
"scripts": {
"test": "vendor/bin/pest --configuration=phpunit.xml --coverage-clover=coverage.xml --log-junit=test.xml",
"test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage-html=coverage",
"analyse": "vendor/bin/phpstan analyse src --no-progress --level=8",
"analyse": "vendor/bin/phpstan analyse src --no-progress --memory-limit=4G --level=8",
"check": [
"@analyse",
"@test"
]
],
"pint": "vendor/bin/pint --preset psr12"
},
"config": {
"allow-plugins": {
Expand Down
1 change: 0 additions & 1 deletion src/Errors/ImmutabilityError.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@
*/
class ImmutabilityError extends Error
{

}
1 change: 0 additions & 1 deletion src/Exceptions/InstantiationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@
*/
class InstantiationException extends RuntimeException
{

}
1 change: 0 additions & 1 deletion src/Exceptions/InvariantViolation.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@
*/
class InvariantViolation extends Exception
{

}
1 change: 0 additions & 1 deletion src/IsAggregate.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
* @see https://martinfowler.com/bliki/EvansClassification.html
*
* @author Unay Santisteban <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Traits
*/
trait IsAggregate
{
Expand Down
11 changes: 10 additions & 1 deletion src/IsEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
* @see https://martinfowler.com/bliki/EvansClassification.html
*
* @author Unay Santisteban <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Traits
*/
trait IsEntity
{
Expand All @@ -25,4 +24,14 @@ trait IsEntity
use HasEquality {
HasIdentity::hash insteadof HasEquality;
}

/**
* Entities have automatic invariant checking enabled by default.
*
* @return bool
*/
protected function shouldAutoCheckInvariants(): bool
{
return true;
}
}
201 changes: 172 additions & 29 deletions src/IsModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,192 @@

namespace ComplexHeart\Domain\Model;

use ComplexHeart\Domain\Model\Exceptions\InstantiationException;
use ComplexHeart\Domain\Model\Traits\HasAttributes;
use ComplexHeart\Domain\Model\Traits\HasInvariants;
use Doctrine\Instantiator\Exception\ExceptionInterface;
use Doctrine\Instantiator\Instantiator;
use RuntimeException;

/**
* Trait IsModel
*
* Provides type-safe object instantiation with automatic invariant checking.
*
* Key improvements in this version:
* - Type-safe make() method with validation
* - Automatic invariant checking after construction
* - Constructor as single source of truth
* - Better error messages
*
* @author Unay Santisteban <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Traits
*/
trait IsModel
{
use HasAttributes;
use HasInvariants;

/**
* Initialize the Model. Just as the constructor will do.
* Create instance with type-safe validation.
*
* This method:
* 1. Validates parameter types against constructor signature
* 2. Creates instance through constructor (type-safe)
* 3. Invariants are checked automatically after construction
*
* @param mixed ...$params Constructor parameters
* @return static
* @throws \InvalidArgumentException When required parameters are missing
* @throws \TypeError When parameter types don't match
*/
final public static function make(mixed ...$params): static
{
$reflection = new \ReflectionClass(static::class);
$constructor = $reflection->getConstructor();

if (!$constructor) {
throw new \RuntimeException(
sprintf('%s must have a constructor to use make()', static::class)
);
}

// Validate parameters against constructor signature
// array_values ensures we have a proper indexed array
self::validateConstructorParameters($constructor, array_values($params));

// Create instance through constructor (PHP handles type enforcement)
// @phpstan-ignore-next-line - new static() is safe here as we validated the constructor
$instance = new static(...$params);

// Auto-check invariants if enabled
$instance->autoCheckInvariants();

return $instance;
}

/**
* Validate parameters match constructor signature.
*
* @param \ReflectionMethod $constructor
* @param array<int, mixed> $params
* @return void
* @throws \InvalidArgumentException
* @throws \TypeError
*/
private static function validateConstructorParameters(
\ReflectionMethod $constructor,
array $params
): void {
$constructorParams = $constructor->getParameters();
$required = $constructor->getNumberOfRequiredParameters();

// Check parameter count
if (count($params) < $required) {
$missing = array_slice($constructorParams, count($params), $required - count($params));
$names = array_map(fn ($p) => $p->getName(), $missing);
throw new \InvalidArgumentException(
sprintf(
'%s::make() missing required parameters: %s',
basename(str_replace('\\', '/', static::class)),
implode(', ', $names)
)
);
}

// Validate types for each parameter
foreach ($constructorParams as $index => $param) {
if (!isset($params[$index])) {
continue; // Optional parameter not provided
}

$value = $params[$index];
$type = $param->getType();

if (!$type instanceof \ReflectionNamedType) {
continue; // No type hint or union type
}

$typeName = $type->getName();
$isValid = self::validateType($value, $typeName, $type->allowsNull());

if (!$isValid) {
throw new \TypeError(
sprintf(
'%s::make() parameter "%s" must be of type %s, %s given',
basename(str_replace('\\', '/', static::class)),
$param->getName(),
$typeName,
get_debug_type($value)
)
);
}
}
}

/**
* Validate a value matches expected type.
*
* @param mixed $value
* @param string $typeName
* @param bool $allowsNull
* @return bool
*/
private static function validateType(mixed $value, string $typeName, bool $allowsNull): bool
{
if ($value === null) {
return $allowsNull;
}

return match($typeName) {
'int' => is_int($value),
'float' => is_float($value) || is_int($value), // Allow int for float
'string' => is_string($value),
'bool' => is_bool($value),
'array' => is_array($value),
'object' => is_object($value),
'callable' => is_callable($value),
'iterable' => is_iterable($value),
'mixed' => true,
default => $value instanceof $typeName
};
}

/**
* Determine if invariants should be checked automatically after construction.
*
* Override this method in your class to disable auto-check:
*
* protected function shouldAutoCheckInvariants(): bool
* {
* return false;
* }
*
* @return bool
*/
protected function shouldAutoCheckInvariants(): bool
{
return false; // Disabled by default for backward compatibility
}

/**
* Called after construction to auto-check invariants.
*
* This method is automatically called after the constructor completes
* if shouldAutoCheckInvariants() returns true.
*
* @return void
*/
private function autoCheckInvariants(): void
{
if ($this->shouldAutoCheckInvariants()) {
$this->check();
}
}

/**
* Initialize the Model (legacy method - DEPRECATED).
*
* @deprecated Use constructor with make() factory method instead.
* This method will be removed in v1.0.0
*
* @param array<int|string, mixed> $source
* @param string|callable $onFail
*
* @return static
*/
protected function initialize(array $source, string|callable $onFail = 'invariantHandler'): static
Expand All @@ -39,17 +201,16 @@ protected function initialize(array $source, string|callable $onFail = 'invarian
}

/**
* Transform an indexed array into assoc array by combining the
* given values with the list of attributes of the object.
* Transform an indexed array into assoc array (legacy method - DEPRECATED).
*
* @deprecated This method will be removed in v1.0.0
* @param array<int|string, mixed> $source
*
* @return array<string, mixed>
*/
private function prepareAttributes(array $source): array
{
// check if the array is indexed or associative.
$isIndexed = fn($source): bool => ([] !== $source) && array_keys($source) === range(0, count($source) - 1);
$isIndexed = fn ($source): bool => ([] !== $source) && array_keys($source) === range(0, count($source) - 1);

/** @var array<string, mixed> $source */
return $isIndexed($source)
Expand All @@ -58,22 +219,4 @@ private function prepareAttributes(array $source): array
// return the already mapped array source.
: $source;
}

/**
* Restore the instance without calling __constructor of the model.
*
* @return static
*
* @throws RuntimeException
*/
final public static function make(): static
{
try {
return (new Instantiator())
->instantiate(static::class)
->initialize(func_get_args());
} catch (ExceptionInterface $e) {
throw new InstantiationException($e->getMessage(), $e->getCode(), $e);
}
}
}
14 changes: 13 additions & 1 deletion src/IsValueObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,31 @@
* > A small simple object, like money or a date range, whose equality isn't based on identity.
* > -- Martin Fowler
*
* Value Objects have automatic invariant checking enabled by default when using the make() factory method.
* For direct constructor usage, you must manually call $this->check() at the end of your constructor.
*
* @see https://martinfowler.com/eaaCatalog/valueObject.html
* @see https://martinfowler.com/bliki/ValueObject.html
* @see https://martinfowler.com/bliki/EvansClassification.html
*
* @author Unay Santisteban <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Traits
*/
trait IsValueObject
{
use IsModel;
use HasEquality;
use HasImmutability;

/**
* Value Objects have automatic invariant checking enabled by default.
*
* @return bool
*/
protected function shouldAutoCheckInvariants(): bool
{
return true;
}

/**
* Represents the object as String.
*
Expand Down
4 changes: 2 additions & 2 deletions src/Traits/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ final public static function attributes(): array
{
return array_filter(
array_keys(get_class_vars(static::class)),
fn(string $item): bool => !str_starts_with($item, '_')
fn (string $item): bool => !str_starts_with($item, '_')
);
}

Expand Down Expand Up @@ -118,7 +118,7 @@ protected function getStringKey(string $id, string $prefix = '', string $suffix
return sprintf(
'%s%s%s',
$prefix,
implode('', map(fn(string $chunk): string => ucfirst($chunk), explode('_', $id))),
implode('', map(fn (string $chunk): string => ucfirst($chunk), explode('_', $id))),
$suffix
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Traits/HasEquality.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ protected function hash(): string
*
* @return string
*/
abstract function __toString(): string;
abstract public function __toString(): string;
}
Loading