# 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

```php [gacela.php]
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

```bash
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:

```php [src/Checkout/Order.php]
<?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

```php
$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()`](https://gacela-project.com/docs/extensions.md#extend-gacela-config):

```php [gacela.php]
$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.

::: warning The generated file is derived
Do not edit it. The next `dto:generate` overwrites it, and `--check` reports the edit as a class that would be
written.
:::

## In CI

```bash
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

- [`dto:generate`](https://gacela-project.com/docs/cli.md#dto-generate): the command's flags and exit codes
- [Extensions and plugins](https://gacela-project.com/docs/extensions.md#extend-gacela-config): how a package contributes a declaration
- [Bootstrap](https://gacela-project.com/docs/bootstrap.md#gacelaconfig): the rest of the `GacelaConfig` surface
