Skip to content

Preserve omitted vs explicit null fields when hydrating input DTOs #1245

@kifril-ltd

Description

@kifril-ltd
Q A
Bug report? no
Feature request? yes
BC Break report? no
RFC? yes
Version/Branch current 1.x / master

Summary

It would be useful to have an opt-in way to preserve the difference between:

  1. an input field that was not provided at all;
  2. an input field that was explicitly provided as null;
  3. an input field that was provided with a non-null value;

when hydrating GraphQL input objects into PHP DTOs via arguments() / ArgumentsTransformer.

This is especially important for partial update / PATCH-like mutations.

Background

GraphQL input coercion already preserves this semantic difference.

According to the GraphQL specification, if a nullable input object field is not provided, no entry is added to the coerced map. If the field is explicitly provided as null, an entry is added with value null.

Reference:

https://spec.graphql.org/September2025/#sec-Input-Objects.Input-Coercion

For example:

input UpdateUserInput {
  name: String
  email: String
  phone: String
}

Desired semantics:

updateUser(input: { name: "Alice" })

Means: update name, do not touch email or phone.

updateUser(input: { phone: null })

Means: explicitly clear phone.

updateUser(input: {})

Means: do not change anything.

Current behavior

When using raw args, this information is still available.

For example, with this input:

updateUser(input: {
  name: "Alice"
  phone: null
})

args can still represent the difference:

[
    'input' => [
        'name' => 'Alice',
        'phone' => null,
        // email is not present
    ],
]

So userland code can do:

array_key_exists('email', $args['input']); // false
array_key_exists('phone', $args['input']); // true

However, when using DTO hydration through arguments():

resolve: '@=mutation("update_user", arguments({"input": "UpdateUserInput"}, args))'

and a DTO like:

use Overblog\GraphQLBundle\Annotation as GQL;

#[GQL\Input]
final class UpdateUserInput
{
    #[GQL\Field(type: 'String')]
    public ?string $name = null;

    #[GQL\Field(type: 'String')]
    public ?string $email = null;

    #[GQL\Field(type: 'String')]
    public ?string $phone = null;
}

the resolver receives an object where both omitted fields and explicitly-null fields are represented as null:

$input->email === null;
$input->phone === null;

At this point the resolver cannot reliably distinguish:

  • email was omitted and should not be changed;
  • email was explicitly provided as null and should be cleared.

Why this matters

This makes DTO-based input objects inconvenient for partial update mutations.

For create mutations, a regular nullable DTO property works well.

For update/patch mutations, nullable DTO properties lose important GraphQL semantics. The current workaround is to avoid DTO hydration and manually inspect raw arrays with array_key_exists, but that means giving up strongly typed input DTOs in resolvers.

Another workaround is to model every nullable field as a wrapper input object in the public schema, for example:

input NullableStringInput {
  value: String
}

input UpdateUserInput {
  name: NullableStringInput
  email: NullableStringInput
}

But this makes the public GraphQL API much less idiomatic:

updateUser(input: {
  name: { value: "Alice" }
})

instead of:

updateUser(input: {
  name: "Alice"
})

Proposed solution

Add an opt-in way for hydrated input DTOs to preserve omitted/null/value semantics.

One possible API could be a generic-like Omittable<T> wrapper, using PHPDoc generics for static analysis:

/**
 * @var Omittable<string|null>
 */
#[GQL\Field(type: 'String')]
public Omittable $phone;

The GraphQL schema type would still be String, while the PHP DTO property would preserve whether the field was provided.

Resolver code could then look like:

public function updateUser(UpdateUserInput $input): User
{
    if ($input->phone->isSet()) {
        // phone was provided, either as null or as a string
        $user->setPhone($input->phone->value());
    }

    // ...
}

Expected states:

// field was not provided
$input->phone->isSet() === false;

// field was explicitly provided as null
$input->phone->isSet() === true;
$input->phone->value() === null;

// field was provided with a value
$input->phone->isSet() === true;
$input->phone->value() === '+123';

This would be conceptually similar to gqlgen's Omittable[T]: a value plus a boolean flag indicating whether the field was provided.

Reference:

https://github.com/99designs/gqlgen/blob/master/graphql/omittable.go

Expected behavior

For this GraphQL input:

updateUser(input: {})

the hydrated DTO should allow resolver code to know that:

$input->phone->isSet() === false;

For this GraphQL input:

updateUser(input: { phone: null })

the hydrated DTO should allow resolver code to know that:

$input->phone->isSet() === true;
$input->phone->value() === null;

For this GraphQL input:

updateUser(input: { phone: "+123" })

the hydrated DTO should allow resolver code to know that:

$input->phone->isSet() === true;
$input->phone->value() === '+123';

Backward compatibility

This should probably be opt-in only.

Existing DTO hydration behavior should remain unchanged unless a field or input object explicitly asks for this behavior.

The opt-in mechanism could be one of:

  • detecting properties typed as Omittable;
  • a field-level option;
  • an input-level option;
  • a custom hydrator / argument transformer extension point.

Related issue

This seems related to the broader ArgumentsTransformer discussion:

#538

That issue discusses the current limitations of ArgumentsTransformer and the need to better separate transformation and hydration. This feature request is narrower: preserving GraphQL's omitted/null/value semantics when hydrating input DTOs.

Workarounds today

The current workaround is to avoid DTO hydration for PATCH-like mutations and inspect raw arrays manually:

if (array_key_exists('phone', $args['input'])) {
    $user->setPhone($args['input']['phone']);
}

This works, but it means losing typed DTOs in resolvers.

Another workaround is to expose wrapper input objects in the GraphQL schema, but that leaks a server-side implementation concern into the public API and makes the schema less idiomatic.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions