Formats and value objects

Built-in date, date-time and byte handling, opt-in UUID codecs, and your own value objects bound per format or per property.

OpenAPI’s format is an open string. poagen treats it the same way: a few values have built-in bindings, a few are opt-in, and everything else keeps its base type while the run names it on stderr.

Built in

FormatPHP typeWhere it comes from
date-timeDateTimeImmutableBuilt in
dateDateTimeImmutableBuilt in
bytestring holding decoded bytesBuilt in, via Base64Codec
uuidthe codec’s target typeOpt-in: RamseyUuidCodec or SymfonyUuidCodec

byte decodes with base64_decode() in strict mode and encodes with base64_encode(). A payload that is not valid base64 fails naming the property. uuid applies only when the profile names a codec, because PHP has no core UUID type and a binding is a dependency decision.

Your own value object

Implement Poagen\Runtime\ScalarBinding and name the class in the profile:

final readonly class Ulid implements ScalarBinding
{
public function __construct(public string $value) {}

public static function fromScalar(mixed $raw): self { /* validate */ }

public function toScalar(): string { return $this->value; }
}
return new Profile(
// ...
formats: ['ulid' => Ulid::class],
);

A foreign type, one you cannot make implement ScalarBinding, binds through Poagen\Runtime\Codec instead. The property is declared as the codec’s own target type, never a wrapper:

final readonly class MoneyCodec implements Codec
{
public static function targetType(): string { return Money::class; }

public static function decode(mixed $wire): Money { /* parse */ }

public static function encode(mixed $value): mixed { /* format */ }
}

Three routes, most specific first

RouteReach
Profile::$propertyFormatsone property of one model, keyed '<ModelClass>.<wireName>'
Profile::$formatsevery property carrying that format, document-wide
#[BindsFormat] + Profile::$discoverInsame reach, discovered by scanning your namespaces

All three beat a built-in, so swapping DateTimeImmutable for Carbon is one entry. A per-property entry exists because a format string can cover unrelated meanings: double in one document is a price, a quantity and a percentage.

Dates without an offset

RFC 3339 requires an offset, and real documents omit it. PHP then resolves the value against the process timezone, so the same payload means different instants on your laptop and in a UTC container. Say which zone it means:

$transport = new HttpTransport(
ApiClient::SERVER_1,
ApiException::class,
context: new BindingContext(naiveZone: new DateTimeZone('Europe/Moscow')),
);

The zone applies only where the vendor sent no offset. A value carrying +05:00 keeps it.

RouteUse when
BindingContext at constructionthe zone belongs to the installation, not the document
Profile::$dateTimeZonethe vendor fixed one zone for the document; emitted as ApiClient::NAIVE_TIMEZONE

The profile constant is a value you pass to the context. The transport is yours to construct, so poagen does not apply it for you.