Crie DTOs com pouquíssimo código com PHP 8.1

Não é novidade para ninguém que o PHP vem crescendo pra caramba nos últimos anos e hoje queria mostrar para vocês uma experiência incrível que tive aqui nos meus projetos que foi refatorar a implementação dos meus DTOs da versão 7.4 do PHP para versão 8.1, somente utilizando 2 de seus novos recursos: Named Arguments e Readonly properties.

Vamos falar rapidamente deles antes de mostrar a evolução que tive usando essas 2 novas features.

Named Arguments

Essa nova feature do PHP veio na versão 8.0 e o intuito dela é que a gente consiga ter mais flexibilidade na hora de fazer chamada de funções com muitos argumentos. Quem aqui nunca pegou aquela função com vários argumentos e ficou pensando: “Caraca, na terceira posição é o que mesmo?”. É bem sobre isso rsrsrs.

Abaixo vou mostrar um pequeno exemplo de uso nas versões anteriores a 8.0:

function funcaoComVariosArgs(string $arg1, int $arg2, array $arg3, float $arg4) {
    // ...
};

funcaoComVariosArgs(
    'arg1',
    10,
    [0,1,2],
    12.34
);

E agora como ficaria nas versões posteriores a 8.0:

function funcaoComVariosArgs(string $arg1, int $arg2, array $arg3, float $arg4) {
    // ...
};

funcaoComVariosArgs(
    arg4: 12.34,
    arg2: 10,
    arg3: [0,1,2],
    arg1: 'arg1'
);

BIZÚ IMPORTANTE: se quiser saber mais detalhes do Named Arguments você pode ver esse vídeo do Canal Dias de Dev onde ele explica com maiores detalhes e com mais exemplos práticos.

Readonly Properties

Essa feature veio na versão 8.1 do PHP e é de longe a grande protagonista para que os meus DTOs ficassem ainda mais enxutos e mais claros. A ideia dessa feature é adicionar a palavra chave readonly na assinatura nos atributos da classe, permitindo e garantindo que seja atribuído o valor a ela somente um vez, depois disso não será mais permitido alterar o seu valor. Resumindo, apenas adicionando essa nova palavra-chave podemos criar DTOs imutáveis que é como eu já uso hoje e gosto de fazer.

Bora de exemplo de como eu fazia anteriormente nas versões anteriores a 8.1:

class MinhaClassePaiDegua
{
    public string $attr1;
    public string $attr2;

    public function __construct(string $attr1, string $attr2)
    {
        $this->attr1 = $attr1;
        $this->attr2 = $attr2;
    }

    public function __set(string $name, mixed $value): void
    {
        throw new Exception(sprintf(
            'Macho véi, tu num pode mudar o valor da prop "%s" não.',
            $name
        ));
    }
}

Se tivermos usando a versão 8.0 ou superiores, você ainda pode utilizar o Constructor Property Promotion para deixar ainda menos código e tendo o mesmo resultado:

class MinhaClassePaiDegua
{
    public function __construct(
        public string $attr1,
        public string $attr2
    ) {}

    public __set(string $name, mixed $value): void
    {
        throw new Exception(sprintf(
            'Macho véi, tu num pode mudar o valor da prop "%s" não.',
            $name
        ));
    }
}:

E utilizando a nova feature de Readonly Properties do PHP 8.1 o meu código hoje ficaria algo assim:

class MinhaClassePaiDegua
{
    public function __construct(
        public readonly string $attr1,
        public readonly string $attr2
    ) {}
}

BIZÚ IMPORTANTE: nesse vídeo do Canal Dias de Dev você poderá ver mais detalhes sobre essa nova feature do Readonly Properties, como exemplos ainda mais práticos e os ganhos do seu uso.

Como eu criava DTOs na versão 7.4

Como a maioria de vocês que me seguem já sabem que eu sigo alguns padrões arquiteturais como Clean Archicteture, Hexagonal e os conceitos de Domain-Driven Design para construir software, e os DTOs são artefatos de suma importância para desacoplamento entre as camadas lógicas, já que eles são apenas objetos muito das vezes anêmicos que transfere dados de uma camada para outra.

BIZÚ IMPORTANTE: eu falo com mais detalhes sobre como venho usando DTOs nesse vídeo do meu canal. Confere lá =)

Vou mostrar agora como que eu criava meus DTOs pegando um exemplo real e logo depois vou mostrar como ficou usando essas novas features do PHP, bora nóis!

declare(strict_types=1);

namespace App\Shared\Adapter;

use JsonSerializable;

abstract class DtoBase implements JsonSerializable
{
    private array $values = [];
    private bool $strict;

    private function __construct(array $values, bool $strict)
    {
        $this->strict = $strict;

        foreach ($values as $key => $value) {
            if (mb_strstr($key, '_') !== false) {
                $key = lcfirst(str_replace('_', '', ucwords($key, '_')));
            }

            if (!property_exists($this, $key)) {
                if (!$this->strict) {
                    continue;
                }

                throw new InvalidArgumentException(sprintf(
                    "The property '%s' doesn't exists in '%s' DTO Class",
                    $key,
                    get_class()
                ));
            }

            $this->{$key} = $value;
            $this->values[$key] = $this->get($key);
        }
    }

    public static function create(array $values, bool $strict = false): static
    {
        return new static($values, $strict);
    }

    public function values(): array
    {
        return $this->values;
    }

    public function get(string $property): mixed
    {
        $getter = "get" . ucfirst($property);

        if (method_exists($this, $getter)) {
            return $this->{$getter}();
        }

        if (!property_exists($this, $property)) {
            throw new InvalidArgumentException(sprintf(
                "The property '%s' doesn't exists in '%s' DTO Class",
                $property,
                get_class()
            ));
        }

        return $this->{$property};
    }

    public function jsonSerialize()
    {
        return $this->values();
    }

    public function __get(string $name)
    {
        return $this->get($name);
    }

    public function __set(string $name, mixed $value)
    {
        throw new InvalidArgumentException(
            sprintf("The property '%s' is read-only", $name)
        );
    }

    public function __isset($name): bool
    {
        return property_exists($this, $name);
    }
}

Calma calma…. eu sei que tem muita informação aqui, mas com as explicações abaixo você verá que não é tão complexo quanto aparenta ser. Essa é minha Classe DtoBase que uso como classe base para meus DTOs, ou seja, todos os meus DTOs são filhas desta classe abstrata através da tão amada e odiada Herança. Vamos a destrinchar ela aqui:

public static function create(array $values, bool $strict = false): static
{
        return new static($values, $strict);
}

Todos os meus DTOs usam uma Factory Method para serem criados. Isso faz com que eu não tenha new MeuDto() em toda parte da minha aplicação e eu consigo ter mais controle da criação do objeto. O argumento $values é uma array associativo sendo a chave o nome do atributo da classe do DTO e o valor da chave o valor de fato para ser atribuído no atributo da classe.

O argumento $strict, quando true, me dá um poder de disparar uma Exception caso o atributo informado em $values não exista, porém, por padrão eu sempre desconsidero e não disparo nenhum erro.

Todas as regras que foram faladas acima são implementadas no construtor da classe, que será explicada logo abaixo:

private function __construct(array $values, bool $strict)
{
    $this->strict = $strict;

    foreach ($values as $key => $value) {
        if (mb_strstr($key, '_') !== false) {
            $key = lcfirst(str_replace('_', '', ucwords($key, '_')));
        }

        if (!property_exists($this, $key)) {
            if (!$this->strict) {
                continue;
            }

            throw new InvalidArgumentException(sprintf(
                "The property '%s' doesn't exists in '%s' DTO Class",
                $key,
                get_class()
            ));
        }

        $this->{$key} = $value;
        $this->values[$key] = $this->get($key);
    }
}

Primeiro detalhe importante aqui é que o construtor da classe é privado, ou seja, nessa versão não é possível fazer um new MeuDto(), eu devo sempre usar MeuDto::create(). Meu construtor é bem resiliente no sentido de entender quando é enviado os nomes de atributos no formato snake_case ou camelCase. Acredite no pai, isso é uma mão na roda no dia-a-dia.

Logo após eu estou fazendo a regra do $strict que foi falado mais acima. E por fim estou populando o atributo da classe e um atributo $values onde eu guardo todos os valores usados para esse DTO para uma possível conversão do objeto para Json. Perceba que a classe base implementa a interface JsonSerializable e lá estou retornando exatamente esse atributo.

Para finalizar a explicação dessa classe base vamos falar do método get() que trabalha em conjunto com o método mágico __get() do PHP:

public function get(string $property): mixed
{
    $getter = "get" . ucfirst($property);

    if (method_exists($this, $getter)) {
        return $this->{$getter}();
    }

    if (!property_exists($this, $property)) {
        throw new InvalidArgumentException(sprintf(
            "The property '%s' doesn't exists in '%s' DTO Class",
            $property,
            get_class()
        ));
    }

    return $this->{$property};
}

public function __get(string $name)
{
    return $this->get($name);
}


// Com isso, eu posso invocar
// as propriedades das seguintes formas
$meuDto = MeuDto::create(['prop' => 'value']);
$meuDto->get('prop')
$meuDto->prop;

Aqui basicamente estou tentando acessar uma propriedade do meu DTO, só que aqui o meu DTO ainda poderia ter um método getter caso eu queira mudar alguma coisa no valor do atributo. Caso seja invocado algum atributo inválido eu disparo uma InvalidArgumentException… simples assim.

E aqui um exemplo de uma classe de DTO:

declare(strict_types=1);

namespace App\Customer\Domain\UseCase\CreateCustomer;

use App\Shared\Adapter\DtoBase;

/**
 * @property-read string $id
 * @property-read string $name
 * @property-read string $email
 * @property-read string $username
 * @property-read string $password
 * @property-read string $financialPassword
 * @property-read string $phone
 * @property-read string $cpf
 * @property-read string $indicatedBy
 */
final class InputData extends DtoBase
{
    protected string $name;
    protected string $email;
    protected string $username;
    protected string $password;
    protected string $financialPassword;
    protected string $phone;
    protected string $cpf;
    protected string $indicatedBy;
}

// Criação do DTO
$meuDto = InputData::create([
    'name' => '...',
    'email' => '...',
    'username' => '...',
    // ... restante dos atributos aqui ...
]);

Refatorando para PHP 8.1

Agora que você entendeu como gosto de fazer DTOs, vou mostrar agora como ficou a classe Base:

declare(strict_types=1);

namespace App\Shared\Adapter;

use JsonSerializable;

abstract class DtoBase implements JsonSerializable
{
    public function values(): array
    {
        return get_object_vars($this);
    }

    public function get(string $property): mixed
    {
        $getter = "get" . ucfirst($property);

        if (method_exists($this, $getter)) {
            return $this->{$getter}();
        }

        if (!property_exists($this, $property)) {
            throw new InvalidArgumentException(sprintf(
                "The property '%s' doesn't exists in '%s' DTO Class",
                $property,
                get_class()
            ));
        }

        return $this->{$property};
    }

    public function jsonSerialize(): mixed
    {
        return $this->values();
    }

    public function __get(string $name)
    {
        return $this->get($name);
    }

    public function __set(string $name, mixed $value)
    {
        throw new InvalidArgumentException(
            sprintf("The property '%s' is read-only", $name)
        );
    }

    public function __isset($name): bool
    {
        return property_exists($this, $name);
    }
}

É muito notória a diferença entre as 2 versões mostradas aqui, a classe base ficou bem mais simples e enxuta. Agora eu vou pegar o mesmo DTO usado anteriormente e mostrar a versão dele refatorado:

declare(strict_types=1);

namespace App\Customer\Domain\UseCase\CreateCustomer;

use App\Shared\Adapter\DtoBase;

final class InputData extends DtoBase
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $username,
        public readonly string $password,
        public readonly string $financialPassword,
        public readonly string $phone,
        public readonly string $cpf,
        public readonly string $indicatedBy
    ) {}
}

// Criação do DTO
$meuDto = new InputData(
    name: '...',
    email: '...',
    username: '...',
    // ... restante dos atributos aqui ...
);

E é dessa forma que eu consegui ser um pouco mais produtivo e ter bem menos código quando crio minhas classes de DTO.

Espero que esse artigo tenha te ajudado de alguma forma e se por acaso você tenha alguma sugestão de melhoria, por favor, deixe nos comentários que será um prazer dá um up e atualizar aqui esse post.

Forte abraço!