Skip to content

Add JSON serialization consistency for empty arrays to objects #55537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

clementbirkle
Copy link
Contributor

This PR adds the ability to control how empty arrays are serialized to JSON in Laravel's core classes. The problem is that JSON serialization of empty arrays ([]) and associative arrays ({"key":"value"}) creates inconsistency:

  • Empty arrays serialize as JSON arrays: []
  • Associative arrays serialize as JSON objects: {"key":"value"}

This causes issues with frontend clients and type systems where an attribute might change its type depending on whether it's empty or not.

Example 1: Model attributes

$user = new User;
$user->customFields = [];
return $user; // customFields serializes as [] (array)

// Later when populated
$user->customFields = ['foo' => 'bar'];
return $user; // customFields serializes as {"foo":"bar"} (object)

With this PR:

$user = new User;
$user->customFields = [];
$user->serializeAttributesAsObjects(['customFields']);
return $user; // customFields now serializes as {} (object)

Example 2: Empty collection

// Before: empty collection serializes as [] (array)
return collect([])->toJson(); // "[]"

// After: can be serialized as {} (object)
return collect([])->serializeEmptyAsObject()->toJson(); // "{}"

Why this matters

  1. Frontend Type Consistency: Prevents type errors in TypeScript or other typed frameworks
  2. Client Expectations: APIs should return consistent data structures
  3. GraphQL/OpenAPI Compatibility: Schema definitions often expect consistent types
    This is implemented with minimal overhead using a trait that's applied to Model, Collection, and JsonResource to provide consistent behavior across the framework.

Note: No documentation has been added yet, as I'd like to confirm the approach meets the project's expectations before adding it to the docs.

@shaedrich
Copy link
Contributor

Wouldn't it make more sense to provide something like JSON Schema to convert PHP data type-safe to JSON?

@clementbirkle
Copy link
Contributor Author

clementbirkle commented Apr 24, 2025

@shaedrich thanks for the suggestion! JSON Schema is indeed valuable for validating JSON data against a defined structure, but it serves a somewhat different purpose than what this PR addresses.

This PR is focused on the actual serialization behavior within Laravel itself - specifically addressing the inconsistency where the same attribute can serialize to different JSON types (array vs object) depending on whether it's empty or not. This causes runtime issues with frontend clients expecting consistent types.

JSON Schema would help validate JSON against a schema after it's generated, but wouldn't change how Laravel itself serializes the data. The issue is that Laravel currently produces inconsistent output (sometimes [], sometimes {} for what logically represents the same type of data), which can break client-side type expectations regardless of validation.

The approach in this PR gives developers control over the serialization itself, ensuring consistent JSON types without requiring additional validation steps or transformations on the client side.

That said, having better JSON Schema support in Laravel would be a valuable addition for different use cases around API validation, but would be complementary rather than a replacement for consistent serialization.

@shaedrich
Copy link
Contributor

This is written by an AI, isn't it? Yes, JSON Schema's primary purpose is to validate data against it. But I can't see while its types can't also be used to give a structure for serializing. My problem is that your solution is quite specific and using JSON Schema would help make serialization configurable in general rather than adding a workaround for one very specific problem.

@taylorotwell
Copy link
Member

Thanks for your pull request to Laravel!

Unfortunately, I'm going to delay merging this code for now. To preserve our ability to adequately maintain the framework, we need to be very careful regarding the amount of code we include.

If applicable, please consider releasing your code as a package so that the community can still take advantage of your contributions!

@clementbirkle
Copy link
Contributor Author

@shaedrich thank you for this clarification. Actually, I don't understand exactly how you would implement JSON Schema to resolve this problem. Could you provide an example of code or a concept that illustrates your approach?

@shaedrich
Copy link
Contributor

shaedrich commented Apr 24, 2025

Something like this:

$user = new User;
$user->customFields = [];
$user->serialize(schema: <<<'schema'
    "customFields": {
        "type": "object"
    }
schema);
return $user; // customFields now serializes as {} (object)

or

$user = new User;
$user->customFields = [];
$user->serialize(schema: [
    "customFields" => [
        "type" => "object",
    ],
]);
return $user; // customFields now serializes as {} (object)

@rodrigopedra
Copy link
Contributor

@clementbirkle the Illuminate\Database\Eloquent\Casts\Json helper class, which is used by the built-in casters to work with JSON, allows you to specify a custom encoder (and a custom decoder, if needed).

From a Service Provider's boot() method, you can register your custom encoder like this:

use Illuminate\Database\Eloquent\Casts\Json;

// assuming `MyJsonEncoder` is an invokable class, 
// you can use any other callable
Json::encodeUsing(new MyJsonEncoder());

Then, in your custom encoder, you can use whatever heuristics to encode your values.

@clementbirkle
Copy link
Contributor Author

Something like this:

$user = new User;
$user->customFields = [];
$user->serialize(schema: <<<'schema'
    "customFields": {
        "type": "object"
    }
schema);
return $user; // customFields now serializes as {} (object)

or

$user = new User;
$user->customFields = [];
$user->serialize(schema: [
    "customFields" => [
        "type" => "object",
    ],
]);
return $user; // customFields now serializes as {} (object)

Thank you, @shaedrich, for those examples. I think it's definitely a strong solution! However, I don’t like it too much because:

  • It's a bit cumbersome if you have 40 attributes and only one of them is causing an issue — you still need to write the entire schema.
  • In the model class, it bypasses the hidden/visible attributes.
  • It doesn't solve the issue with empty Laravel collections, which sometimes should return an object instead of an array.

@clementbirkle
Copy link
Contributor Author

@clementbirkle the Illuminate\Database\Eloquent\Casts\Json helper class, which is used by the built-in casters to work with JSON, allows you to specify a custom encoder (and a custom decoder, if needed).

From a Service Provider's boot() method, you can register your custom encoder like this:

use Illuminate\Database\Eloquent\Casts\Json;

// assuming `MyJsonEncoder` is an invokable class, 
// you can use any other callable
Json::encodeUsing(new MyJsonEncoder());

Then, in your custom encoder, you can use whatever heuristics to encode your values.

Thank you for this interesting trick, @rodrigopedra! Unfortunately, it's specific to the JSON serialization of models and doesn't solve the issue with empty Laravel collections, which sometimes should return an object instead of an array.

@rodrigopedra
Copy link
Contributor

In those cases, you can play with the JSON_FORCE_OBJECT option:

return collect([])->toJson(\JSON_FORCE_OBJECT); // "{}"

Reference: https://www.php.net/manual/en/json.constants.php#constant.json-force-object

Just be aware all arrays will be converted to objects, like this:

$data = [
    'foo' => [1, 2, 3],
];

echo collect($data)->toJson(\JSON_FORCE_OBJECT | \JSON_PRETTY_PRINT);

Will output:

{
    "foo": {
        "0": 1,
        "1": 2,
        "2": 3
    }
}

@clementbirkle
Copy link
Contributor Author

Thank you @rodrigopedra for this solution. Of course, it will work — but what about this case:

class MyModel extends Model
{ 
    /**
    * Get the attributes that should be cast.
    *
    * @return array<string, string>
    */
    protected function casts(): array
    {
        return [
            'a_simple_array' => 'array',
            'custom_fields' => AsCollection::class,
        ];
    }
}

$model = new MyModel();
$model->toJson(); // {"a_simple_array":[],"custom_fields":[]}

// in this case, we want this output: {"a_simple_array":[],"custom_fields":{}}

@rodrigopedra
Copy link
Contributor

@clementbirkle in that case you can use the custom encoder as I said earlier

Json::encodeUsing(new ModelEncoder(['custom_fields']));

$model = new MyModel();
$model->toJson();

The implementation would be similar to the one tried on this PR. You would check the need to encode an empty array as an object on a per-field basis.

You can also override the model's jsonSerialize() method:

public function jsonSerialize(): mixed
{
    $data = parent::jsonSerialize();

    if (array_key_exists('custom_fields', $data) && blank($data['custom_fields'])) {
        $data['custom_fields'] = new \stdClass();
    }

    return $data;
}

You can also create a helper class to reduce any repetition, something like this:

class JsonSerializer implements \JsonSerializable
{
    public function __construct(
        private readonly array $data,
        private readonly array $asObject,
    ) {}

    public function jsonSerialize(): array
    {
        $data = $this->data;

        foreach ($this->asObject as $field) {
            if (array_key_exists($field, $data) && blank($data[$field])) {
                $data[$field] = new \stdClass();
            }
        }

        return $data;
    }
}

And then:

class MyModel extends Model
{
    protected $appends = [
        'a_simple_array'
        'custom_fields',
    ];

    protected function casts(): array
    {
        return [
            'a_simple_array' => 'array',
            'custom_fields' => AsCollection::class,
        ];
    }

    public function jsonSerialize(): mixed
    {
        return new JsonSerializer(parent::jsonSerialize(), ['custom_fields']);
    }
}

@clementbirkle
Copy link
Contributor Author

clementbirkle commented Apr 25, 2025

@rodrigopedra, thank you for those examples. The last one fits best for me.

But with my PR, you could do something like this:

class MyModel extends Model
{
    /**
     * The attributes that should always be serialized as JSON objects.
     *
     * @var list<string>
     */
    protected array $serializeAsObjects = [
        'custom_fields'
    ];

    protected function casts(): array
    {
        return [
            'a_simple_array' => 'array',
            'custom_fields' => AsCollection::class,
        ];
    }
}

// output: {"a_simple_array":[],"custom_fields":{}}

Or even better, when it's a custom collection (for more reusability):

class MyCustomCollection extends Collection
{
    /**
     * Determines if empty arrays should be cast to objects when serialized to JSON.
     */
    public bool $serializeEmptyAsObject = true;
}

class MyModel extends Model
{    
    protected function casts(): array
    {
        return [
            'a_simple_array' => 'array',
            'custom_fields' => AsCollection::using(MyCustomCollection::class),
        ];
    }
}

// output: {"a_simple_array":[],"custom_fields":{}}

What do you think about these examples? Maybe the description of my PR wasn't clear enough with poor examples.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants