Serialization
Gustav converts typed controller return values into a predictable JSON representation before building the PSR-7 response.
Typed JSON responses
Return a declared PHP value directly and Gustav serializes it as JSON. Constructor-promoted, readonly output DTOs are the canonical style:
use GustavPHP\Gustav\Attribute\Get;
enum DogState: string
{
case Available = 'available';
case Adopted = 'adopted';
}
final readonly class OwnerOutput
{
public function __construct(public string $name)
{
}
}
final readonly class DogOutput
{
/** @param list<OwnerOutput> $watchers */
public function __construct(
public int $id,
public string $name,
public DogState $state,
public ?string $nickname,
public OwnerOutput $owner,
public array $watchers,
) {
}
}
#[Get('/dogs/{id}')]
public function show(): DogOutput
{
return new DogOutput(
id: 42,
name: 'Rex',
state: DogState::Available,
nickname: null,
owner: new OwnerOutput('Ada'),
watchers: [new OwnerOutput('Grace')],
);
}
The response has status 200, content type application/json, and this body:
{
"id": 42,
"name": "Rex",
"state": "available",
"nickname": null,
"owner": { "name": "Ada" },
"watchers": [{ "name": "Grace" }]
}
Direct JSON responses use status 200. When a route needs a different status or custom headers, return a Gustav response through json():
use GustavPHP\Gustav\Attribute\Post;
#[Post('/dogs')]
public function create(): Controller\Response
{
return $this->json(
new DogOutput(/* ... */),
status: 201,
headers: ['X-Resource-Type' => 'dog'],
);
}
Handlers must declare exactly one named return type. A non-null Gustav response or PSR-7 ResponseInterface passes through unchanged; every other supported type is inferred as JSON. Nullable types such as ?DogOutput are accepted and serialize null as JSON null. Ambiguous unions, mixed, object, and void are rejected when the application starts.
Supported values
The normalizer handles these values recursively:
| PHP value | JSON representation |
|---|---|
null, string, integer, boolean | Corresponding JSON primitive |
| finite float | JSON number; 1.0 remains 1.0 |
| array | Every key and value is normalized recursively |
| backed enum | String or integer backing value |
| DTO | Initialized, non-static public properties |
JsonSerializable | Recursively normalized result of jsonSerialize() |
stdClass | Public properties, including properties added at run time |
Invalid UTF-8 in string values is replaced with the Unicode replacement character, so it cannot silently create an empty or invalid response.
Unbacked enums, closures, resources, non-finite floats, uninitialized public properties, unsupported internal objects, excessive nesting, and circular references cannot be represented. They are programming errors and become a production-safe 500; they are not client validation errors.
Excluding fields
All initialized public DTO properties are included by default. Add #[Exclude] to a declared property that must never be emitted:
use GustavPHP\Gustav\Attribute\Serializer\Exclude;
final readonly class AccountOutput
{
public function __construct(
public string $name,
#[Exclude]
public string $internalToken,
) {
}
}
Non-public and static properties are not serialized. Classes that intentionally
expose run-time dynamic properties must opt in with #[AdditionalProperties]:
use AllowDynamicProperties;
use GustavPHP\Gustav\Attribute\Serializer\AdditionalProperties;
#[AllowDynamicProperties]
#[AdditionalProperties]
class ExtensibleOutput
{
public string $name = 'Rex';
}
Use additional properties sparingly; declared readonly properties provide a clearer response contract.
JSON helper
Use json() when a handler needs to choose its status or body at run time while still returning Controller\Response:
use GustavPHP\Gustav\Attribute\Get;
#[Get('/dogs/{id}')]
public function show(): Controller\Response
{
return $this->json(new DogOutput(/* ... */), status: 200);
}
The helper uses the same recursive normalizer, enum conversion, exclusions, float handling, and error safety as a directly returned JSON value.