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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ On top of those base traits **Complex Heart** provide ready to use compositions:

- **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)
- **Named Parameter Support**: Full support for PHP 8.0+ named parameters for improved readability and flexibility
- **Union Type Support**: Complete support for PHP 8.0+ union types (e.g., `int|float`, `string|null`)
- **Readonly Properties Support**: Full compatibility with PHP 8.1+ readonly properties
- **PHPStan Level 8**: Complete static analysis support

Expand Down
73 changes: 73 additions & 0 deletions src/IsModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ final public static function make(mixed ...$params): static
);
}

// Handle named parameters if provided
if (self::hasNamedParameters($params)) {
$params = self::mapNamedToPositional($constructor, $params);
}

// Validate parameters against constructor signature
// array_values ensures we have a proper indexed array
self::validateConstructorParameters($constructor, array_values($params));
Expand Down Expand Up @@ -211,6 +216,74 @@ private static function validateUnionType(mixed $value, ReflectionUnionType $uni
return false;
}

/**
* Check if parameters include named parameters.
*
* @param array<int|string, mixed> $params
* @return bool
*/
private static function hasNamedParameters(array $params): bool
{
if (empty($params)) {
return false;
}

// Named parameters have string keys
// Positional parameters have sequential integer keys [0, 1, 2, ...]
return array_keys($params) !== range(0, count($params) - 1);
}

/**
* Map named parameters to positional parameters based on constructor signature.
*
* Supports three scenarios:
* 1. Pure named parameters: make(value: 'test')
* 2. Pure positional parameters: make('test')
* 3. Mixed parameters: make(1, name: 'test', description: 'desc')
*
* @param ReflectionMethod $constructor
* @param array<int|string, mixed> $params
* @return array<int, mixed>
* @throws InvalidArgumentException When required named parameter is missing
*/
private static function mapNamedToPositional(
ReflectionMethod $constructor,
array $params
): array {
$positional = [];
$constructorParams = $constructor->getParameters();

foreach ($constructorParams as $index => $param) {
$name = $param->getName();

// Check if parameter was provided positionally (by index)
if (array_key_exists($index, $params)) {
$positional[$index] = $params[$index];
}
// Check if parameter was provided by name
elseif (array_key_exists($name, $params)) {
$positional[$index] = $params[$name];
}
// Check if parameter has a default value
elseif ($param->isDefaultValueAvailable()) {
$positional[$index] = $param->getDefaultValue();
}
// Check if parameter is required
elseif (!$param->isOptional()) {
throw new InvalidArgumentException(
sprintf(
'%s::make() missing required parameter: %s',
basename(str_replace('\\', '/', static::class)),
$name
)
);
}
// else: optional parameter without default (e.g., nullable), will be handled by PHP
}

return $positional;
}

/**
* Determine if invariants should be checked automatically after construction.
*
Expand Down
62 changes: 62 additions & 0 deletions tests/TypeValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,65 @@
->and($e->getMessage())->toContain('array given');
}
});

test('make() should accept named parameters', function () {
$email = Email::make(value: 'test@example.com');

expect($email)->toBeInstanceOf(Email::class)
->and((string) $email)->toBe('test@example.com');
});

test('make() should accept named parameters in any order', function () {
$money = Money::make(currency: 'USD', amount: 100);

expect($money)->toBeInstanceOf(Money::class)
->and((string) $money)->toBe('100 USD');
});

test('make() should mix named and positional parameters', function () {
// First positional, rest named
$model = ComplexModel::make(1, name: 'Test', description: 'Desc', tags: []);

expect($model)->toBeInstanceOf(ComplexModel::class);
});

test('make() should skip optional parameters with named params', function () {
// Skip optional 'label' parameter
$value = FlexibleValue::make(value: 42);

expect($value)->toBeInstanceOf(FlexibleValue::class)
->and((string) $value)->toBe('42');
});

test('make() should use default values for omitted named params', function () {
// FlexibleValue has label with default null
$value = FlexibleValue::make(value: 'test');

expect($value)->toBeInstanceOf(FlexibleValue::class);
});

test('make() should throw error for missing required named parameter', function () {
Money::make(amount: 100);
})->throws(InvalidArgumentException::class, 'missing required parameter: currency');

test('make() should validate types with named parameters', function () {
Email::make(value: 123);
})->throws(TypeError::class, 'parameter "value" must be of type string, int given');

test('make() should handle nullable types with named parameters', function () {
$model = ComplexModel::make(id: 1, name: 'Test', description: null, tags: []);

expect($model)->toBeInstanceOf(ComplexModel::class);
});

test('make() should handle union types with named parameters', function () {
$money1 = Money::make(amount: 100, currency: 'USD');
$money2 = Money::make(amount: 99.99, currency: 'EUR');

expect($money1)->toBeInstanceOf(Money::class)
->and($money2)->toBeInstanceOf(Money::class);
});

test('make() should validate union types with named parameters', function () {
Money::make(amount: 'invalid', currency: 'USD');
})->throws(TypeError::class, 'parameter "amount" must be of type int|float');
6 changes: 5 additions & 1 deletion wiki/Domain-Modeling-Aggregates.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ final class Order implements Aggregate

**Benefits of using `make()` in factory methods:**
- Automatic invariant checking when using `make()`
- Type validation at runtime
- Type validation at runtime with clear error messages
- Named parameter support for improved readability (as shown above)
- Union type support (e.g., `int|float`, `string|null`)
- Cleaner factory method code
- Consistent with Value Objects and Entities

**Why named parameters?** As shown in the example above, using named parameters (`reference:`, `customer:`, etc.) makes the code self-documenting and prevents parameter mix-ups, especially important in Aggregates with many constructor parameters.

**Important:** Auto-check ONLY works when using `make()`. In the alternative approach using direct constructor calls, you must manually call `$this->check()` inside the constructor.

#### Alternative: Direct Constructor with Manual Check
Expand Down
10 changes: 9 additions & 1 deletion wiki/Domain-Modeling-Entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,19 @@ final class Customer implements Entity

// Type-safe instantiation with automatic invariant validation
$customer = Customer::make(UUIDValue::random(), 'Vincent Vega');

// Named parameters for improved readability (PHP 8.0+)
$customer = Customer::make(
id: UUIDValue::random(),
name: 'Vincent Vega'
);
```

**Benefits:**
- Automatic invariant checking when using `make()`
- Type validation at runtime
- Type validation at runtime with clear error messages
- Named parameter support for improved readability
- Union type support (e.g., `int|float`, `string|null`)
- Cleaner constructor code

**Important:** Auto-check ONLY works when using `make()`. If you call the constructor directly (`new Customer(...)`), you must manually call `$this->check()` inside the constructor.
Expand Down
37 changes: 37 additions & 0 deletions wiki/Domain-Modeling-Value-Objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,53 @@ class Email implements ValueObject
$email = Email::make('user@example.com'); // ✅ Valid
$email = Email::make(123); // ❌ TypeError: parameter "value" must be of type string, int given
$email = Email::make('invalid'); // ❌ InvariantViolation: Valid format

// Named parameters for improved readability (PHP 8.0+)
$email = Email::make(value: 'user@example.com'); // ✅ Self-documenting code
```

**Benefits of `make()`:**
- Runtime type validation with clear error messages
- Automatic invariant checking after construction
- Named parameter support for improved readability
- Union type support (e.g., `int|float`, `string|null`)
- Works seamlessly with readonly properties
- PHPStan level 8 compliant

**Important:** Auto-check ONLY works when using `make()`. Direct constructor calls do NOT trigger automatic invariant checking, so you must manually call `$this->check()` in the constructor.

#### Named Parameters Example

Named parameters (PHP 8.0+) make code more readable and allow parameters in any order:

```php
final class Money implements ValueObject
{
use IsValueObject;

public function __construct(
private readonly int|float $amount,
private readonly string $currency
) {}

protected function invariantPositiveAmount(): bool
{
return $this->amount > 0;
}

public function __toString(): string
{
return sprintf('%s %s', $this->amount, $this->currency);
}
}

// All equivalent, choose the most readable for your context:
$money = Money::make(100, 'USD'); // Positional
$money = Money::make(amount: 100, currency: 'USD'); // Named
$money = Money::make(currency: 'USD', amount: 100); // Named, different order
$money = Money::make(100, currency: 'USD'); // Mixed
```

#### Alternative: Constructor Property Promotion with Manual Check

If you prefer direct constructor calls, you **must** manually call `$this->check()`:
Expand Down