Skip to content

Repository files navigation

Data Transfer Object

Want to deserialize an object with data on the fly? Go for it by using the From trait.


How is this package any different from spaties popular data-transfer-object, you may ask? Well, it's not meant to be a replacement by any means. But while using it I've often come across some things I've missed since I knew them from serde, like renaming and ignoring properties, something that spatie's data-transfer-objectmight not get in the near future. So there it is, my own little DTO package :) I hope it helps someone, as it helps me in my daily work. Feel free to open issues or pull requests - any help is greatly appreciated!

Requirements

This package is designed for PHP ≥ 8.0 only since it's using PHP 8.0 Attributes.

Attributes

Name

You get a parameter which is not named as the parameter in your class? #[Name(...)] to the rescue - just specify the name from the Request:

useDgame\DataTransferObject\Annotation\Name;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
publicint$offset;
#[Name('size')]
publicint$limit;
}

Now the key size will be mapped to the property $limit - but keep in mind: the name limit is no longer known since you overwrote it with size. If that is not your intention, take a look at the Alias Attribute.

Alias

You get a parameter which is not always named as the parameter in your class? #[Alias(...)] can help you - just specify the alias from the Request:

useDgame\DataTransferObject\Annotation\Alias;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
publicint$offset;
#[Alias('size')]
publicint$limit;
}

Now the keys sizeandlimit will be mapped to the property $limit. You can mix #[Name(...)] and #[Alias(...)] as you want:

useDgame\DataTransferObject\Annotation\Alias;
useDgame\DataTransferObject\Annotation\Name;
useDgame\DataTransferObject\DataTransfer;
finalclass Foo
{
use DataTransfer;
#[Name('a')]
#[Alias('z')]
publicint$id;
}

The keys a and z are mapped to the property id - but not the key id since you overwrote it with a. But the following

useDgame\DataTransferObject\Annotation\Alias;
useDgame\DataTransferObject\DataTransfer;
finalclass Foo
{
use DataTransfer;
#[Alias('a')]
#[Alias('z')]
publicint$id;
}

will accept the keys a, z and id.

Transformations

If you want to transform a value before it is assigned to the property, you can use Transformations. You just need to implement the Transformation interface.

Cast

Cast is currently the only built-in Transformation and let you apply a Type-Cast before the value is assigned to the property:

If not told otherwise, a simple type-cast is performed. In the example below it would just call something like $this->id = (int) $id:

useDgame\DataTransferObject\Annotation\Cast;
finalclass Foo
{
use DataTransfer;
#[Cast]
publicint$id;
}

But that would be tried for any input. If you want to limit this to certain types, you can use types:

useDgame\DataTransferObject\Annotation\Cast;
finalclass Foo
{
use DataTransfer;
#[Cast(types: ['string', 'float', 'bool'])]
publicint$id;
}

Here the cast would only be performed if the incoming value is either an int, string, float or bool.

If you want more control, you can use a static method inside of the class:

useDgame\DataTransferObject\Annotation\Cast;
finalclass Foo
{
use DataTransfer;
#[Cast(method: 'toInt', class: self::class)]
publicint$id;
publicstaticfunctiontoInt(string|int|float|bool$value): int
{
return (int) $value;
}
}

or a function:

useDgame\DataTransferObject\Annotation\Cast;
functiontoInt(string|int|float|bool$value): int
{
return (int) $value;
}
finalclass Foo
{
use DataTransfer;
#[Cast(method: 'toInt')]
publicint$id;
}

If a class is given but not a method, by default __invoke will be used:

useDgame\DataTransferObject\Annotation\Cast;
finalclass Foo
{
use DataTransfer;
#[Cast(class: self::class)]
publicint$id;
publicfunction__invoke(string|int|float|bool$value): int
{
return (int) $value;
}
}

Validation

You want to validate the value before it is assigned? We can do that. There are a few pre-defined validations prepared, but you can easily write your own by implementing the Validation-interface.

Min

useDgame\DataTransferObject\Annotation\Min;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
#[Min(0)]
publicint$offset;
#[Min(0)]
publicint$limit;
}

Both $offset and $limit must be at least have the value 0 (so they must be positive-integers). If not, an exception is thrown. You can configure the message of the exception by specifying the message parameter:

useDgame\DataTransferObject\Annotation\Min;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
#[Min(0, message: 'Offset must be positive!')]
publicint$offset;
#[Min(0, message: 'Limit must be positive!')]
publicint$limit;
}

Max

useDgame\DataTransferObject\Annotation\Max;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
#[Max(1000)]
publicint$offset;
#[Max(1000)]
publicint$limit;
}

Both $offset and $limit may not exceed 1000. If they do, an exception is thrown. You can configure the message of the exception by specifying the message parameter:

useDgame\DataTransferObject\Annotation\Max;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
#[Max(1000, message: 'Offset may not be larger than 1000')]
publicint$offset;
#[Max(1000, message: 'Limit may not be larger than 1000')]
publicint$limit;
}

Instance

Do you want to make sure that a property is an instance of a certain class or that each item in an array is an instance of that said class?

useDgame\DataTransferObject\Annotation\Instance;
finalclass Collection
{
#[Instance(class: Entity::class, message: 'We need an array of Entities!')]
privatearray$entities;
}

Type

If you are trying to cover objects or other class instances, you should probably take a look at Instance.

As long as you specify a type for your properties, the Type validation is automatically added to ensure that the specified values can be assigned to the specified types. If not, a validation exception will be thrown. Without this validation, a TypeError would be thrown, which may not be desirable.

So this code

finalclass Foo
{
private ?int$id;
}

is actually seen as this:

useDgame\DataTransferObject\Annotation\Type;
finalclass Foo
{
#[Type(name: '?int')]
private ?int$id;
}

The following snippets are equivalent to the snippet above:

useDgame\DataTransferObject\Annotation\Type;
finalclass Foo
{
#[Type(name: 'int|null')]
private ?int$id;
}
useDgame\DataTransferObject\Annotation\Type;
finalclass Foo
{
#[Type(name: 'int', allowsNull: true)]
private ?int$id;
}

If you want to change the exception message, you can do so using the message parameter:

useDgame\DataTransferObject\Annotation\Type;
finalclass Foo
{
#[Type(name: '?int', message: 'id is expected to be int or null')]
private ?int$id;
}

Custom

Do you want your own Validation? Just implement the Validation-interface:

useDgame\DataTransferObject\Annotation\Validation;
useDgame\DataTransferObject\DataTransfer;
#[Attribute(Attribute::TARGET_PROPERTY)]
finalclass NumberBetween implements Validation
{
publicfunction__construct(privateint|float$min, privateint|float$max)
{
}
publicfunctionvalidate(mixed$value): void
{
if (!is_numeric($value)) {
thrownewInvalidArgumentException(var_export($value, true) . ' must be a numeric value');
}
if ($value < $this->min) {
thrownewInvalidArgumentException(var_export($value, true) . ' must be >= ' . $this->min);
}
if ($value > $this->max) {
thrownewInvalidArgumentException(var_export($value, true) . ' must be <= ' . $this->max);
}
}
}
finalclass ValidationStub
{
use DataTransfer;
#[NumberBetween(18, 125)]
privateint$age;
publicfunctiongetAge(): int
{
return$this->age;
}
}

Ignore

You don't want a specific key-value to override your property? Just ignore it:

useDgame\DataTransferObject\Annotation\Ignore;
useDgame\DataTransferObject\DataTransfer;
finalclass Foo
{
use DataTransfer;
#[Ignore]
publicstring$uuid = 'abc';
publicint$id = 0;
}
$foo = Foo::from(['uuid' => 'xyz', 'id' => 42]);
echo$foo->id; // 42echo$foo->uuid; // abc

Reject

You want to go one step further than simply ignoring a value? Then Reject it:

useDgame\DataTransferObject\Annotation\Reject;
useDgame\DataTransferObject\DataTransfer;
finalclass Foo
{
use DataTransfer;
#[Reject(reason: 'The attribute "uuid" is not supposed to be set')]
publicstring$uuid = 'abc';
}
$foo = Foo::from(['id' => 42]); // Works fineecho$foo->id; // 42echo$foo->uuid; // abc$foo = Foo::from(['uuid' => 'xyz', 'id' => 42]); // throws 'The attribute "uuid" is not supposed to be set'

Required

Normally, a nullable-property or a property with a provided default value is treated with said default-value or null if the property cannot be assigned from the provided data. If no default-value is provided and the property is not nullable, an error is thrown in case the property was not found. But in some cases you might want to specify the reason, why the property is required or even want to require an otherwise default-able property. You can do that by using Required:

useDgame\DataTransferObject\Annotation\Required;
useDgame\DataTransferObject\DataTransfer;
finalclass Foo
{
use DataTransfer;
#[Required(reason: 'We need an "id"')]
public ?int$id;
#[Required(reason: 'We need a "name"')]
publicstring$name;
}
Foo::from(['id' => 42, 'name' => 'abc']); // Works
Foo::from(['name' => 'abc']); // Fails but would work without the `Required`-Attribute since $id is nullable
Foo::from(['id' => 42]); // Fails and would fail regardless of the `Required`-Attribute since $name is not nullable and has no default-value - but the reason why it is required is now more clear.

Optional

The counterpart of Required. If you don't want to or can't provide a default/nullable value, Optional will assign the default value of the property-type in case of a missing value:

finalclass Foo
{
use DataTransfer;
#[Optional]
publicint$id;
}
$foo = Foo::from([]);
assert($foo->id === 0);

Of course you can specify which value should be used if no data is provided:

finalclass Foo
{
use DataTransfer;
#[Optional(value: 42)]
publicint$id;
}
$foo = Foo::from([]);
assert($foo->id === 42);

In case you're using Optional together with a provided default-value, the default-value has always priority:

finalclass Foo
{
use DataTransfer;
#[Optional(value: 42)]
publicint$id = 23;
}
$foo = Foo::from([]);
assert($foo->id === 23);

Numeric

You have int or float properties but aren't sure if those aren't delivered as e.g. string? Numeric to the rescue! It will translate the value to a numeric representation (to int or float):

useDgame\DataTransferObject\Annotation\Numeric;
finalclass Foo
{
use DataTransfer;
#[Numeric(message: 'id must be numeric')]
publicint$id;
}
$foo = Foo::from(['id' => '23']);
assert($foo->id === 23);

Boolean

You have bool properties but aren't sure if those aren't delivered as string or int? Boolean can help you with that!

useDgame\DataTransferObject\Annotation\Boolean;
finalclass Foo
{
use DataTransfer;
#[Boolean(message: 'checked must be a bool')]
publicbool$checked;
#[Boolean(message: 'verified must be a bool')]
publicbool$verified;
}
$foo = Foo::from(['checked' => 'yes', 'verified' => 0]);
assert($foo->checked === true);
assert($foo->verified === false);

Date

You want a DateTime but got a string? No problem:

useDgame\DataTransferObject\Annotation\Date;
use \DateTime;
finalclass Foo
{
use DataTransfer;
#[Date(format: 'd.m.Y', message: 'Your birthday must be a date')]
publicDateTime$birthday;
}
$foo = Foo::from(['birthday' => '19.09.1979']);
assert($foo->birthday === DateTime::createFromFormat('d.m.Y', '19.09.1979'));

In

Your value must be one of a specific range or enumeration? You can ensure that with In:

useDgame\DataTransferObject\Annotation\In;
finalclass Foo
{
use DataTransfer;
#[In(values: ['beginner', 'advanced', 'difficult'], message: 'Must be either "beginner", "advanced" or "difficult"')]
publicstring$difficulty;
}
Foo::from(['difficulty' => 'foo']); // will throw a error, since difficulty is not in the provided values$foo = Foo::from(['difficulty' => 'advanced']);
assert($foo->difficulty === 'advanced');

NotIn

Your value must not be one of a specific range or enumeration? You can ensure that with NotIn:

useDgame\DataTransferObject\Annotation\NotIn;
finalclass Foo
{
use DataTransfer;
#[NotIn(values: ['holy', 'shit', 'wtf'], message: 'Must not be a swear word')]
publicstring$word;
}

Matches

You must be sure that your values match a specific pattern? You can do that for all scalar values by using Matches:

useDgame\DataTransferObject\Annotation\Matches;
finalclass Foo
{
use DataTransfer;
#[Matches(pattern: '/^[a-z]+\w*/', message: 'Your name must start with a-z')]
publicstring$name;
#[Matches(pattern: '/[1-9][0-9]+/', message: 'products must be at least 10')]
publicint$products;
}
Foo::from(['name' => '_', 'products' => 99]); // will throw a error, since name does not start with a-z
Foo::from(['name' => 'John', 'products' => 9]); // will throw a error, since products must be at least 10

Trim

You have to make sure, that string values are trimmed? No worries, we have Trim:

useDgame\DataTransferObject\Annotation\Trim;
finalclass Foo
{
use DataTransfer;
#[Trim]
publicstring$name;
}
$foo = Foo::from(['name' => ' John ']);
assert($foo->name === 'John');

Path

Did you ever wanted to extract a value from a provided array? Path to the rescue:

finalclass Person
{
use DataTransfer;
#[Path('person.name')]
publicstring$name;
}

It helps while with JSON's special $value attribute

finalclass Person
{
use DataTransfer;
#[Path('married.$value')]
publicbool$married;
}

and with XML's #text.

finalclass Person
{
use DataTransfer;
#[Path('first.name.#text')]
publicstring$firstname;
}

But we can do even more. You can choose which parts of the field are taken

finalclass Person
{
use DataTransfer;
#[Path('child.{born, age}')]
publicarray$firstChild = [];
}

and can even assign them directly to an object:

finalclass Person
{
use DataTransfer;
publicint$id;
publicstring$name;
public ?int$age = null;
#[Path('ancestor.{id, name}')]
public ?self$parent = null;
}

SelfValidation

In addition to the customary validations you can specify a class-wide validation after all assignments are done:

#[SelfValidation(method: 'validate')]
finalclass SelfValidationStub
{
use DataTransfer;
publicfunction__construct(publicint$id)
{
}
publicfunctionvalidate(): void
{
assert($this->id > 0);
}
}

ValidationStrategy

The default validation strategy is fail-fast which means an Exception is thrown as soon as an error is detected. But that might not desirable, so you can configure this with a ValidationStrategy:

#[ValidationStrategy(failFast: false)]
finalclass Foo
{
use DataTransfer;
#[Min(3)]
publicstring$name;
#[Min(0)]
publicint$id;
}
Foo::from(['name' => 'a', 'id' => -1]);

The example above would throw a combined exception that name is not long enough and id must be at least 0. You can configure this as well by extending the ValidationStrategy and provide a FailureHandler and/or a FailureCollection.

Property promotion

In the above examples, property promotion is not always used because it is more readable that way, but property promotion is supported. So the following example

useDgame\DataTransferObject\Annotation\Min;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
#[Min(0)]
publicint$offset;
#[Min(0)]
publicint$limit;
}

can be rewritten as shown below

useDgame\DataTransferObject\Annotation\Min;
useDgame\DataTransferObject\DataTransfer;
finalclass Limit
{
use DataTransfer;
publicfunction__construct(
#[Min(0)] publicint$offset,
#[Min(0)] publicint$limit
) { }
}

and it still works.

Nested object detection

You have nested objects and want to deserialize them all at once? That is a given:

useDgame\DataTransferObject\DataTransfer;
finalclass Bar
{
publicint$id;
}
finalclass Foo
{
use DataTransfer;
publicBar$bar;
}
$foo = Foo::from(['bar' => ['id' => 42]]);
echo$foo->bar->id; // 42

Have you noticed the missing From in Bar? From is just a little wrapper for the actual DTO. So your nested classes don't need to use it at all.

There is no limit to the depth of nesting, the responsibility is yours! :)

About

A data transfer object inspired by Rust's serde

Topics

Resources

Stars

41 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages