middag/blueprint

Strongly typed Data Transfer Objects

1.11.0 2020-01-03 14:23 UTC

README

Build Status

Installation

You can install the package via Composer:

composer require middag/blueprint

Why use this library

There are lots of situations where your application relies on external data: APIs, CSV files, you name it. If you like typed properties (and IDE autocompletion) then Data Transfer Objects are the way to go, but external systems have the tendency to, well, change. Quite often, this means that your DTO no longer contains the data you expect it to, causing other parts of your application to break.

This is where Blueprint comes in. With Blueprint, you can define DTOs just like any other PHP class but with additional checks built in. Blueprint checks the type definitions in your DTO and optionally performs additional validation using the Symfony Validator component. Under the hood, Blueprint uses Symfony's PropertyInfo and PropertyAccess components.

How to use

All you have to do is extend your DTO from DataTransferObject. Consider the following DTO:

use MIDDAG\Blueprint\DataTransferObject;

final class Invoice extends DataTransferObject
{
    /**
     * @var int
     */
    public $id;

    /**
     * @var float
     */
    public $amount;

    /**
     * @var string|null
     */
    public $notes;

    /**
     * @var InvoiceLine[]
     */
    public $lines = [];
}

The definition above ensures that the $id is always an integer, the $amount a float and the $lines are always instances of InvoiceLine. The $notes should be a string or null.

You can instantiate the object by passing the values to the maker:

$invoice = Invoice::make([
    'id' => 100,
    'amount' => 25.00,
    'notes' => 'Optional notes',
]);

Nested DTOs

If you have a DTO that consists of other DTOs, you can pass the data in as a nested array:

$invoice = Invoice::make([
    'id' => 100,
    'amount' => 25.00,
    'notes' => 'Optional notes',
    
    'lines' => [
        ['id' => 101, 'description' => 'Invoice line 1'], // Is turned into an InvoiceLine object
        ['id' => 102, 'description' => 'Invoice line 2'], // Is turned into an InvoiceLine object
    ]
]);

Using the Symfony Validator

First, install the Symfony component and its dependencies for handling annotations:

composer require symfony/validator doctrine/annotations doctrine/cache

After configuring the AnnotationLoader, you can use annotations on your DTO's properties to perform additional checks:

/**
 * @Assert\GreaterThan(value=0)
 *
 * @var float
 */
public $amount;

Changing properties

You can change properties just like with any other object (e.g. $object->foo = 'bar'), but this won't perform all of our fancy checks. You have two options:

Option 1: write and then validate the object

$dto = SomeDtoObject::make(['foo' => 'bar']);
$dto->otherProperty = 'value';

// Perform validation
$dto->validate();

Option 2: use the Writer

use MIDDAG\Blueprint\Writer;

$writer = Writer::create();

$dto = SomeDtoObject::make(['foo' => 'bar']);

// Write to the object
$writer->write($dto, 'otherProperty', 'value');

Setters

Because Blueprint uses the Symfony PropertyAccess component for writing to DTO properties, you can also add custom setters to your DTO. These allow you to make last-minute changes just before validation is performed:

final class Invoice extends DataTransferObject
{
    /**
     * @var float
     */
    public $amount;
    
    /**
     * @param mixed $input
     */
    public function setAmount($input): void
    {
        // Turn empty values into 0.00
        $this->amount = (float) $input ?: 0.00;
    }
}

Note that custom setters only work when constructing the DTO or when using the writer, not when writing to the property directly ($object->property = 'foo'').

Dirty objects

Even though it sounds contradictory, there are situations where you intentionally need a DTO that does not yet contain all its data. This is why Blueprint supports so-called "dirty" DTOs:

$invoice = Invoice::dirty();

Dirty DTOs cannot be normalized and you cannot call ->toArray() on them without raising an exception. This is to make sure that you don't accidentally rely on a DTO that is in fact not in a valid state. You can check if a DTO is dirty or not by calling ->isDirty().

You can mutate DTOs like always. The DTO turns into a regular DTO once you call ->validate() and validation is performed.

Lazy Loading Ghost Objects

Let's make things even cooler by turning Blueprint DTOs into lazy loading ghost objects.

🤯

Blueprint can be used along with ProxyManager so that a DTO turns into a ghost object or proxy. This allows you to fetch certain properties on the fly but only if they are accessed (and only once).

Validation of the DTO is performed exactly the same, but now you can provide a set of base properties and then fetch the additional properties once they are accessed. This is done through a callable: the initializer. This initializer can fetch the data from anywhere: an API or anything you want.

Let's take our previous Invoice DTO as an example. First, make sure it extends from GhostDataTransferObject. Then, instead of the regular new Invoice(/* ... */), use Invoice::proxy() to create the DTO:

$factory = new LazyLoadingGhostFactory();

class Invoice extends GhostDataTransferObject
{
    // ...
}

$invoice = Invoice::proxy([
    'id' => 100,
    'amount' => 25.00,
], $factory, function (Invoice $proxy) {

    // TODO: fetch the invoice lines from a remote source to make things more exciting
    
    $proxy->lines[] = [
        InvoiceLine::make([/* ... */]);
    ];
    
    // You can write to any property in $proxy here
});

// Prints "100" and does not call our initializer
print $invoice->id;

// Silently calls our initializer and prints the invoice lines
print_r($invoice->lines);

// Won't call the initializer again and prints the invoice lines
print_r($invoice->lines);

Using Symfony?

If you load the BlueprintBundle, Blueprint will use the cache.system cache pool to speed up reflection. Additionally, the Writer and Validator will become available as services.

Credits

This library was inspired by the awesome work done by the Spatie team 🙌 Be sure to check out their spatie/data-transfer-object project.