wwwision / types
Tools to create PHP types that adhere to JSON schema like rules
Fund package maintenance!
bwaidelich
Paypal
Installs: 1 752
Dependents: 5
Suggesters: 0
Security: 0
Stars: 5
Watchers: 2
Forks: 0
Open Issues: 2
Requires
- php: >=8.1
- ramsey/uuid: ^4.7
- webmozart/assert: ^1.11
Requires (Dev)
- phpbench/phpbench: ^1.2
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.1
- roave/security-advisories: dev-latest
- squizlabs/php_codesniffer: ^4.0.x-dev
README
Library to narrow the scope of your PHP types with JSON Schema inspired attributes allowing for validating and mapping unknown data.
Usage
This package can be installed via composer:
composer require wwwision/types
Afterward, three steps are required to profit from the type safety of this package.
Given, you have the following Contact
entity:
class Contact { public function __construct(public string $name, public int $age) {} }
This class has a couple of issues:
- The values are mutable, so every part of the system can just change them without control (
$contact->name = 'changed';
) - The values of
$name
and$age
are unbound – this makes the type very fragile. For example, you could specify a name with thousands of characters or a negative number for the age, possibly breaking at the integration level - There is no human readably type information – is the $name supposed to be a full name, just the given name or a family name, ...?
0. Create classes for your Value Objects
Note This list is 0-based because that part is slightly out of scope, it is merely a general recommendation
final class ContactName { public function __construct(public string $value) {} } final class ContactAge { public function __construct(public int $value) {} }
1. Add attributes
By adding one of the provided attributes, schema information and documentation can be added to type classes:
#[Description('The full name of a contact, e.g. "John Doe"')] #[StringBased(minLength: 1, maxLength: 200)] final class ContactName { public function __construct(public string $value) {} } #[Description('The current age of a contact, e.g. 45')] #[IntegerBased(minimum: 1, maximum: 130)] final class ContactAge { public function __construct(public int $value) {} }
Note In most cases it makes sense to specify an upper bound for your types because that allows you to re-use that at "the edges" (e.g. for frontend validation and database schemas)
2. Make constructor private and classes immutable
By making constructors private, validation can be enforced providing confidence that the objects don't violate their allowed range. See best practices for more details.
#[Description('The full name of a contact, e.g. "John Doe"')] #[StringBased(minLength: 1, maxLength: 200)] final class ContactName { private function __construct(public readonly string $value) {} } #[Description('The current age of a contact, e.g. 45')] #[IntegerBased(minimum: 1, maximum: 130)] final class ContactAge { private function __construct(public readonly int $value) {} } final class Contact { public function __construct( public readonly ContactName $name, public readonly ContactAge $age, ) {} }
3. Use instantiate()
to create instances
With private constructors in place, the instantiate()
function should be used to create new instances of the affected
classes:
// ... instantiate(Contact::class, ['name' => 'John Doe', 'age' => 45]);
Note In practice you'll realize that you hardly need to create new Entity/Value Object instances within your application logic but mostly in the infrastructure layer. E.g. a
DatabaseContactRepository
might return aContacts
object.
Example: Database integration
// ... #[ListBased(itemClassName: Contact::class)] final class Contacts implements IteratorAggregate { private function __construct(private readonly array $contacts) {} public function getIterator() : Traversable { yield from $this->contacts; } } interface ContactRepository { public function findByName(ContactName $name): Contacts; } final class DatabaseContactRepository implements ContactRepository { public function __construct(private readonly PDO $pdo) {} public function findByName(ContactName $name): Contacts { $statement = $this->pdo->prepare('SELECT name, age FROM contacts WHERE name = :name'); $statement->execute(['name' => $name->value]); return instantiate(Contacts::class, $statement->fetchAll(PDO::FETCH_ASSOC)); } }
Best practices
In order to gain the most with this package, a couple of rules should be considered:
All state fields in the constructor
This package uses reflection to parse the constructors of involved classes. Therefore the constructor should contain every variable that makes up the internal state (IMO that's a good practice anyways).
In general you should only allow state changes through the constructor and it's a good idea to mark DTO classes as readonly
Private constructors
In order to allow data to be validated everywhere, there must be no way to instantiate
an Integer-, String- or ListBased class other than with the
provided instantiate()
method.
Therefore, constructors of Value Objects should be private:
#[StringBased] final class SomeValueObject { private function __construct(public readonly string $value) {} }
Note For Shapes (i.e. composite) objects that rule doesn't apply, because all of their properties are valid if the above rule is followed:
// ... final class SomeComposite { public function __construct( public readonly SomeValueObject $alreadyValidated, public readonly bool $neverInvalid, ) {} } // this is fine: instantiate(SomeComposite::class, ['alreadyValidated' => 'some value', 'neverInvalid' => true]); // and so is this: new SomeComposite(instantiate(SomeValueObject::class, 'some value'), true);
Final classes
In my opinion, classes in PHP should be final by default. For the core domain types this is especially true because inheritance could lead to invalid schemas and failing validation. Instead, composition should be used where it applies.
Immutability
In order to guarantee the correctness of the types, there should be no way to change a value without re-applying validation. The easiest way to achieve this, is to make those types immutable – and this comes with some other benefits as well.
The readonly
keyword can be used on properties (with PHP 8.2+ even on the class itself) to ensure immutability on the
PHP type level.
If types should be updatable from the outside, ...
- a new instance should be returned
- and it should not call the private constructor but use
instantiate()
in order to apply validation
#[StringBased(format: StringTypeFormat::date, pattern: '^1980')] final class Date { private function __construct(public readonly string $value) {} public function add(\DateInterval $interval): self { return instantiate(self::class, \DateTimeImmutable::createFromFormat('Y-m-d', $this->value)->add($interval)->format('Y-m-d')); } } $date = instantiate(Date::class, '1980-12-30'); $date = $date->add(new \DateInterval('P1D')); // this is fine assert($date->value === '1980-12-31'); // this is not because of the "pattern" $date = $date->add(new \DateInterval('P1D')); // Exception: Failed to cast string of "1981-01-01" to Date: invalid_string (Value does not match regular expression)
Attributes
Description
The Description
attribute allows you to add some domain specific documentation to classes and parameters.
Example: Class with description
#[Description('This is a description for this class')] final class SomeClass { public function __construct( #[Description('This is some overridden description for this parameter')] public readonly bool $someProperty, ) {} } assert(Parser::getSchema(SomeClass::class)->overriddenPropertyDescription('someProperty') === 'This is some overridden description for this parameter');
IntegerBased
With the IntegerBased
attribute you can create Value Objects that represent an integer.
It has the optional arguments
minimum
– to specify the allowed minimum valuemaximum
– to specify the allowed maximum value
Example
#[IntegerBased(minimum: 0, maximum: 123)] final class SomeIntBased { private function __construct(public readonly int $value) {} } instantiate(SomeIntBased::class, '-5'); // Exception: Failed to cast string of "-5" to SomeIntBased: too_small (Number must be greater than or equal to 0)
FloatBased
Starting with version 1.2
With the FloatBased
attribute you can create Value Objects that represent a floating point number (aka double).
It has the optional arguments
minimum
– to specify the allowed minimum value (as integer or float)maximum
– to specify the allowed maximum value (as integer or float)
Example
#[FloatBased(minimum: 12.34, maximum: 30)] final class SomeFloatBased { private function __construct(public readonly float $value) {} } instantiate(SomeFloatBased::class, 12); // Exception: Failed to cast integer value of 12 to SomeFloatBased: too_small (Number must be greater than or equal to 12.340)
StringBased
With the StringBased
attribute you can create Value Objects that represent a string.
It has the optional arguments
minLength
– to specify the allowed minimum length of the stringmaxLength
– to specify the allowed maximum length of the stringpattern
– to specify a regular expression that the string has to matchformat
– one of the predefined formats the string has to satisfy (this is a subset of the JSON Schema string format)
Example: String Value Object with min and max length constraints
#[StringBased(minLength: 1, maxLength: 200)] final class GivenName { private function __construct(public readonly string $value) {} } instantiate(GivenName::class, ''); // Exception: Failed to cast string of "" to GivenName: too_small (String must contain at least 1 character(s))
Example: String Value Object with format and pattern constraints
Just like with JSON Schema, format
and pattern
can be combined to further narrow the type:
#[StringBased(format: StringTypeFormat::email, pattern: '@your.org$')] final class EmployeeEmailAddress { private function __construct(public readonly string $value) {} } instantiate(EmployeeEmailAddress::class, 'not@your.org.localhost'); // Exception: Failed to cast string of "not@your.org.localhost" to EmployeeEmailAddress: invalid_string (Value does not match regular expression)
ListBased
With the ListBased
attribute you can create generic lists (i.e. collections, arrays, sets, ...) of the
specified itemClassName
.
It has the optional arguments
minCount
– to specify how many items the list has to contain at leastmaxCount
– to specify how many items the list has to contain at most
Example: Simple generic array
#[StringBased] final class Hobby { private function __construct(public readonly string $value) {} } #[ListBased(itemClassName: Hobby::class)] final class Hobbies implements IteratorAggregate { private function __construct(private readonly array $hobbies) {} public function getIterator() : Traversable { yield from $this->hobbies; } } instantiate(Hobbies::class, ['Soccer', 'Ping Pong', 'Guitar']);
Example: More verbose generic array with type hints and min and max count constraints
The following example shows a more realistic implementation of a List, with:
- An
@implements
annotation that allows IDEs and static type analyzers to improve the DX - A Description attribute
minCount
andmaxCount
validationCountable
andJsonSerializable
implementation (just as an example, this is not required for the validation to work)
// ... /** * @implements IteratorAggregate<Hobby> */ #[Description('A list of hobbies')] #[ListBased(itemClassName: Hobby::class, minCount: 1, maxCount: 3)] final class HobbiesAdvanced implements IteratorAggregate, Countable, JsonSerializable { /** @param array<Hobby> $hobbies */ private function __construct(private readonly array $hobbies) {} public function getIterator() : Traversable { yield from $this->hobbies; } public function count(): int { return count($this->hobbies); } public function jsonSerialize() : array { return array_values($this->hobbies); } } instantiate(HobbiesAdvanced::class, ['Soccer', 'Ping Pong', 'Guitar', 'Gaming']); // Exception: Failed to cast value of type array to HobbiesAdvanced: too_big (Array must contain at most 3 element(s))
Composite types
The examples above demonstrate how to create very specific Value Objects with strict validation and introspection.
Example: Complex composite object
#[StringBased] final class GivenName { private function __construct(public readonly string $value) {} } #[StringBased] final class FamilyName { private function __construct(public readonly string $value) {} } final class FullName { public function __construct( public readonly GivenName $givenName, public readonly FamilyName $familyName, ) {} } #[Description('honorific title of a person')] enum HonorificTitle { #[Description('for men, regardless of marital status, who do not have another professional or academic title')] case MR; #[Description('for married women who do not have another professional or academic title')] case MRS; #[Description('for girls, unmarried women and married women who continue to use their maiden name')] case MISS; #[Description('for women, regardless of marital status or when marital status is unknown')] case MS; #[Description('for any other title that does not match the above')] case OTHER; } #[Description('A contact in the system')] final class Contact { public function __construct( public readonly HonorificTitle $title, public readonly FullName $name, #[Description('Whether the contact is registered or not')] public bool $isRegistered = false, ) {} } // Create a Contact instance from an array $person = instantiate(Contact::class, ['title' => 'MRS', 'name' => ['givenName' => 'Jane', 'familyName' => 'Doe']]); assert($person->name->familyName->value === 'Doe'); assert($person->isRegistered === false); // Retrieve the schema for the Contact class $schema = Parser::getSchema(Contact::class); assert($schema->getDescription() === 'A contact in the system'); assert($schema->propertySchemas['isRegistered']->getDescription() === 'Whether the contact is registered or not');
Generics
Generics won't make it into PHP most likely (see this video from Brent that explains why that is the case).
The ListBased attribute allows for relatively easily creation of type-safe collections of a specific item type.
Currently you still have to create a custom class for that, but I don't think that this is a big problem because mostly a common collection class won't fit all the specific requirements.
For example: PostResults
could provide different functions and implementations than a Posts
set (the former might be unbound, the latter might have a minCount
constraint etc).
Further thoughts
I'm thinking about adding a more generic (no pun intended) way to allow for common classes without having to specify the itemClassName
in the attribute but at instantiation time, maybe something along the lines of
#[Generic('TKey', 'TValue')] final class Collection { // ... } // won't work as of now: $posts = generic(Collection::class, $dbRows, TKey: Types::int(), TValue: Types::classOf(Post::class));
But it adds some more oddities and I currently don't really need it becaused of the reasons mentioned above.
Interfaces
Starting with version 1.1, this package allows to refer to interface types.
In order to instantiate an object via its interface, the instance class name has to be specified via the __type
key.
All remaining array items will be used as usual. For simple objects, that only expect a single scalar value, the __value
key can be specified additionally:
interface SimpleOrComplexObject { public function render(): string; } #[StringBased] final class SimpleObject implements SimpleOrComplexObject { private function __construct(private readonly string $value) {} public function render(): string { return $this->value; } } final class ComplexObject implements SimpleOrComplexObject { private function __construct(private readonly string $prefix, private readonly string $suffix) {} public function render(): string { return $this->prefix . $this->suffix; } } $simpleObject = instantiate(SimpleOrComplexObject::class, ['__type' => SimpleObject::class, '__value' => 'Some value']); assert($simpleObject instanceof SimpleObject); $complexObject = instantiate(SimpleOrComplexObject::class, ['__type' => ComplexObject::class, 'prefix' => 'Prefix', 'suffix' => 'Suffix']); assert($complexObject instanceof ComplexObject);
Especially when working with generic lists, it can be useful to allow for polymorphism, i.e. allow the list to contain any instance of an interface:
Example: Generic list of interfaces
// ... #[ListBased(itemClassName: SimpleOrComplexObject::class)] final class SimpleOrComplexObjects implements IteratorAggregate { public function __construct(private readonly array $objects) {} public function getIterator() : Traversable{ yield from $this->objects; } public function map(Closure $closure): array { return array_map($closure, $this->objects); } } $objects = instantiate(SimpleOrComplexObjects::class, [ ['__type' => SimpleObject::class, '__value' => 'Simple'], ['__type' => ComplexObject::class, 'prefix' => 'Com', 'suffix' => 'plex'], ]); assert($objects->map(fn (SimpleOrComplexObject $o) => $o->render()) === ['Simple', 'Complex']);
Error handling
Errors that occur during the instantiation of objects lead to an InvalidArgumentException
to be thrown.
That exception contains a human-readable error message that can be helpful to debug any errors, for example:
Failed to instantiate FullNames: At key "0": At property "givenName": Value "a" does not have the required minimum length of 3 characters
Starting with version 1.2, the more specific CoerceException
is thrown with an improved exception message that collects all failures:
Failed to cast value of type array to FullNames: At "0.givenName": too_small (String must contain at least 3 character(s)). At "1.familyName": invalid_type (Required)
In addition, the exception contains a property issues
that allows for programmatic parsing and/or rewriting of the error messages.
The exception itself is JSON-serializable and the above example would be equivalent to:
[ { "code": "too_small", "message": "String must contain at least 3 character(s)", "path": [0, "givenName"], "type": "string", "minimum": 3, "inclusive": true, "exact": false }, { "code": "invalid_type", "message": "Required", "path": [1, "familyName"], "expected": "string", "received": "undefined" } ]
Note If the syntax is familiar to you, that's no surpise. It is inspired (and in fact almost completely compatible) with the issue format of the fantastic Zod library
Integrations
The declarative approach of this library allows for some interesting integrations. So far, the following two exist – Feel free to create another one and I will gladly add it to this list:
- types/graphql – to create GraphQL schemas from PHP types
- types/glossary – to create Markdown glossaries for all relevant PHP types
Dependencies
This package currently relies on the following 3rd party libraries:
- webmozart/assert – to simplify type and value assertions
- ramsey/uuid – for the
StringTypeFormat::uuid
check
...and has the following DEV-requirements:
- roave/security-advisories – to detect vulnerabilities in dependant packages
- phpstan/phpstan – for static code analysis
- squizlabs/php_codesniffer – for code style analysis
- phpunit/phpunit – for unit and integration tests
- phpbench/phpbench – for performance benchmarks
Performance
This package uses Reflection in order to introspect types. So it comes with a performance hit. Fortunately the performance of Reflection in PHP is not as bad as its reputation and while you can certainly measure a difference, I doubt that it will have a notable effect in practice – unless you are dealing with extremely time critical applications like realtime trading in which case you should not be using PHP in the first place... And you should probably reconsider your life choices in general :)
Nevertheless, this package contains a runtime cache for all reflected classes. So if you return a huge list of the same type, the performance impact should be minimal. I am measuring performance of the API via PHPBench to avoid regressions, and I might add further caches if performance turns out to become an issue.
Contribution
Contributions in the form of issues, pull requests or discussions are highly appreciated
License
See LICENSE