Gacela
Documentation version: 2.3.0
Colour theme

Docs Configuration

On this page

DTO schema Since 2.3

A DTO that crosses a module boundary is written once and edited by hand forever. Every property added later means a constructor, a getter, a copy method and a pair of array converters, all of them mechanical, all of them a place to make a typo.

declareDtoSchema() declares the shape instead. dto:generate writes the class.

The declaration is the source of truth, so a package and the project consuming it can declare the same shape and the generated class carries both sets of properties. Nobody forks a DTO to add a field to it.

Declare the shape

gacela.phpphp
use Gacela\Framework\Bootstrap\GacelaConfig;
use Gacela\Framework\Dto\Schema\DtoType;

return static function (GacelaConfig $config): void {
    $config->declareDtoSchema(App\Checkout\Order::class, [
        'reference' => DtoType::string()->required(),
        'total' => DtoType::int()->required()->describe('in cents'),
        'currency' => DtoType::string()->default('EUR'),
    ]);
};

DtoType offers string(), int(), float(), bool() and array(), each refined by:

Refinement Effect
required() The getter returns a non-nullable type and throws when nothing set the value
default(mixed) fromArray() falls back to this value when the key is absent
describe(string) Becomes a docblock on that property's getter

required() and default() are mutually exclusive. A property that has an answer of its own is not one the caller must supply, and declaring both throws MalformedDtoSchemaException. A default whose type does not match the declared type is refused the same way, except that an int is accepted where a float is declared.

Nothing in a schema runs during a request. It is read by dto:generate and by nothing else, so a malformed declaration fails the command rather than the application.

Generate the class

vendor/bin/gacela dto:generate
  • --dry-run: report what would change, write nothing
  • --check: like --dry-run, and exit non-zero when a class would be written

The file lands wherever the project's own composer.json psr-4 map puts that namespace, longest matching prefix first. A shape under a namespace no prefix covers is reported as No composer autoload prefix covers … and the command exits non-zero: nothing is quietly written somewhere else.

For the declaration above:

src/Checkout/Order.phpphp
<?php

declare(strict_types=1);

namespace App\Checkout;

use Gacela\Framework\Dto\MissingDtoPropertyException;

/**
 * Generated by `vendor/bin/gacela dto:generate`. Do not edit -- regenerate it.
 *
 * Declared with `declareDtoSchema()`; every declarer of this shape contributes
 * to it, so the properties below may come from more than one package.
 */
final class Order
{
    private function __construct(
        private readonly ?string $currency = null,
        private readonly ?string $reference = null,
        private readonly ?int $total = null,
    ) {
    }

    /**
     * @param array<string, mixed> $data
     */
    public static function fromArray(array $data): self
    {
        /** @psalm-suppress MixedArgument */
        return new self(
            $data['currency'] ?? 'EUR',
            $data['reference'] ?? null,
            $data['total'] ?? null,
        );
    }

    public function getCurrency(): ?string
    {
        return $this->currency;
    }

    public function withCurrency(?string $value): self
    {
        return new self(
            $value,
            $this->reference,
            $this->total,
        );
    }

    public function getReference(): string
    {
        if ($this->reference === null) {
            throw MissingDtoPropertyException::forProperty(self::class, 'reference');
        }

        return $this->reference;
    }

    public function withReference(?string $value): self
    {
        return new self(
            $this->currency,
            $value,
            $this->total,
        );
    }

    /**
     * in cents
     */
    public function getTotal(): int
    {
        if ($this->total === null) {
            throw MissingDtoPropertyException::forProperty(self::class, 'total');
        }

        return $this->total;
    }

    public function withTotal(?int $value): self
    {
        return new self(
            $this->currency,
            $this->reference,
            $value,
        );
    }

    /**
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        $data = [];

        if ($this->currency !== null) {
            $data['currency'] = $this->currency;
        }

        if ($this->reference !== null) {
            $data['reference'] = $this->reference;
        }

        if ($this->total !== null) {
            $data['total'] = $this->total;
        }

        return $data;
    }
}

Properties are emitted in alphabetical order, not declaration order, so the same schema always produces the same bytes. Regenerating an unchanged declaration leaves version control quiet.

Using it

$order = App\Checkout\Order::fromArray([
    'reference' => 'ORD-1042',
    'total' => 4999,
]);

$order->getReference();          // 'ORD-1042', non-nullable
$order->getCurrency();           // 'EUR', from the declared default
$order->withTotal(5299)->toArray();

Every constructor parameter is nullable, including the required ones, because fromArray() accepts a partial payload and the getter is what refuses to answer. Reading a required property nothing set throws MissingDtoPropertyException, naming the property rather than failing later on a null somewhere else.

toArray() omits a property that was never set instead of writing null, so fromArray($order->toArray()) returns the same instance. A value present because of a declared default is included, since fromArray() already materialized it.

One shape, several declarers

Two declareDtoSchema() calls for the same class union their properties. That is how a project adds a field to a packaged shape, whether the second declaration comes from its own gacela.php or from extendGacelaConfig():

gacela.phpphp
$config->declareDtoSchema(Vendor\Billing\Invoice::class, [
    'internalRef' => DtoType::string(),
]);

Redeclaring a property that already exists is refused unless it says exactly the same thing. MalformedDtoSchemaException names the class and the property: a shape may be extended by another declarer, never redefined. Rewording a describe() is not a redefinition, because a description is prose about the property rather than part of its shape.

In CI

vendor/bin/gacela dto:generate --check

Exits non-zero when a declaration and its generated class have drifted apart, and writes nothing while doing it. Run it beside the tests so a declaration change that nobody regenerated fails the build rather than the next deploy.

Limitations

  • No nested shapes and no typed collections. A nested structure is an array property.
  • The declared type is the whole of the checking. There is no value validation beyond the primitive type.

See also