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
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:
<?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():
$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 --checkExits 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
arrayproperty. - The declared type is the whole of the checking. There is no value validation beyond the primitive type.
See also
dto:generate: the command's flags and exit codes- Extensions and plugins: how a package contributes a declaration
- Bootstrap: the rest of the
GacelaConfigsurface