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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/Illuminate/Collections/Traits/EnumeratesValues.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
*/
trait EnumeratesValues
{
use Conditionable;
use Conditionable, EnforcesJsonObjectSerialization;

/**
* Indicates that the object's string representation should be escaped when __toString is invoked.
Expand Down Expand Up @@ -959,11 +959,11 @@ public function toArray()
/**
* Convert the object into something JSON serializable.
*
* @return array<TKey, mixed>
* @return array<TKey, mixed>|object
*/
public function jsonSerialize(): array
public function jsonSerialize(): array|object
{
return array_map(function ($value) {
$items = array_map(function ($value) {
if ($value instanceof JsonSerializable) {
return $value->jsonSerialize();
} elseif ($value instanceof Jsonable) {
Expand All @@ -974,6 +974,8 @@ public function jsonSerialize(): array

return $value;
}, $this->all());

return $this->enforceJsonObjectSerialization($items);
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/Illuminate/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable as SupportStringable;
use Illuminate\Support\Traits\EnforcesJsonObjectSerialization;
use Illuminate\Support\Traits\ForwardsCalls;
use JsonException;
use JsonSerializable;
Expand All @@ -41,7 +42,8 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
Concerns\GuardsAttributes,
Concerns\PreventsCircularRecursion,
Concerns\TransformsToResource,
ForwardsCalls;
ForwardsCalls,
EnforcesJsonObjectSerialization;
/** @use HasCollection<\Illuminate\Database\Eloquent\Collection<array-key, static & self>> */
use HasCollection;

Expand Down Expand Up @@ -1760,7 +1762,9 @@ public function toJson($options = 0)
*/
public function jsonSerialize(): mixed
{
return $this->toArray();
$data = $this->toArray();

return $this->enforceJsonObjectSerialization($data);
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/Illuminate/Http/Resources/Json/JsonResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
use Illuminate\Http\Request;
use Illuminate\Http\Resources\ConditionallyLoadsAttributes;
use Illuminate\Http\Resources\DelegatesToResource;
use Illuminate\Support\Traits\EnforcesJsonObjectSerialization;
use JsonException;
use JsonSerializable;

class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRoutable
{
use ConditionallyLoadsAttributes, DelegatesToResource;
use ConditionallyLoadsAttributes, DelegatesToResource, EnforcesJsonObjectSerialization;

/**
* The resource instance.
Expand Down Expand Up @@ -247,10 +248,12 @@ public function toResponse($request)
/**
* Prepare the resource for JSON serialization.
*
* @return array
* @return array|object
*/
public function jsonSerialize(): array
public function jsonSerialize(): array|object
{
return $this->resolve(Container::getInstance()->make('request'));
$data = $this->resolve(Container::getInstance()->make('request'));

return $this->enforceJsonObjectSerialization($data);
}
}
72 changes: 72 additions & 0 deletions src/Illuminate/Support/Traits/EnforcesJsonObjectSerialization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Illuminate\Support\Traits;

trait EnforcesJsonObjectSerialization
{
/**
* The attributes that should always be serialized as JSON objects.
*
* @var list<string>
*/
protected array $serializeAsObjects = [];

/**
* Determines if empty arrays should be cast to objects when serialized to JSON.
*/
protected bool $serializeEmptyAsObject = false;

/**
* Convert empty arrays to objects during JSON serialization based on configuration.
*
* This method handles two use cases:
* 1. Converting an entire empty array/collection to an object (useful for collections)
* 2. Converting specific attributes to objects regardless of their content (useful for models)
*/
protected function enforceJsonObjectSerialization(array $data): array|object
{
// Fast path for collection case - entire dataset as object when empty
if (empty($data) && $this->serializeEmptyAsObject) {
return (object) [];
}

// Fast path for attribute case - if no attributes configured or data is empty
if (empty($data) || empty($this->serializeAsObjects)) {
return $data;
}

// Handle specific attributes
foreach ($this->serializeAsObjects as $attribute) {
// Only transform arrays, preserve null values
if (isset($data[$attribute]) && is_array($data[$attribute]) && empty($data[$attribute])) {
$data[$attribute] = (object) [];
}
}

return $data;
}

/**
* Configure collection to be serialized as an object when empty.
*/
public function serializeEmptyAsObject(bool $value = true): static
{
$this->serializeEmptyAsObject = $value;

return $this;
}

/**
* Configure specific attributes to be serialized as objects.
*
* @param list<string>|string $attributes
*/
public function serializeAttributesAsObjects(array|string $attributes): static
{
$this->serializeAsObjects = is_array($attributes)
? $attributes
: func_get_args();

return $this;
}
}
34 changes: 34 additions & 0 deletions tests/Database/DatabaseEloquentModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3285,6 +3285,40 @@ public function testModelToJsonSucceedsWithPriorErrors(): void
$this->assertSame('{"name":"Mateus"}', $user->toJson(JSON_THROW_ON_ERROR));
}

public function testJsonSerialization()
{
// Create a custom model stub
$model = new class extends Model {};

// Set test data - use a property without a mutator
$model->name = 'foo';
$model->empty_array = [];

// First check normal serialization
$json = $model->toJson();
$decoded = json_decode($json);
$this->assertIsArray($decoded->empty_array);

// Apply our trait functionality
$model->serializeAttributesAsObjects(['empty_array']);

// Re-serialize and verify transformation
$json = $model->toJson();
$decoded = json_decode($json);
$this->assertIsObject($decoded->empty_array);

// Test empty model serialization
$emptyModel = new class extends Model {};

// Default behavior should be empty array
$emptyJson = $emptyModel->toJson();
$this->assertSame('[]', $emptyJson);

// Configure for empty object
$emptyModel->serializeEmptyAsObject();
$this->assertSame('{}', $emptyModel->toJson());
}

public function testFillableWithMutators()
{
$model = new EloquentModelWithMutators;
Expand Down
65 changes: 65 additions & 0 deletions tests/Http/JsonResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Illuminate\Tests\Http;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Mockery as m;
Expand Down Expand Up @@ -46,4 +47,68 @@ public function testJsonResourceToJsonSucceedsWithPriorErrors(): void

$this->assertSame('{"foo":"bar"}', $resource->toJson(JSON_THROW_ON_ERROR));
}

public function testJsonResourceEmptyAttributesSerializeAsObjects()
{
// Create a request mock
$request = new Request();
app()->instance('request', $request);

// Create a test model with empty array attributes
$model = new class extends Model
{
protected $attributes = [
'settings' => [],
'options' => [],
'meta' => [],
];
};

// Create a resource and specify which attribute should be serialized as an object
$resource = new class($model) extends JsonResource
{
public function toArray($request)
{
return [
'settings' => $this->settings,
'options' => $this->options,
'meta' => $this->meta,
];
}
};

// Configure only 'settings' to be serialized as an object
$resource->serializeAttributesAsObjects(['settings']);

// Convert to JSON and decode for assertions
$json = $resource->toJson();
$decoded = json_decode($json);

// Assert that 'settings' is an object but other empty arrays remain as arrays
$this->assertIsObject($decoded->settings);
$this->assertIsArray($decoded->options);
$this->assertIsArray($decoded->meta);
}

public function testJsonResourceEmptyResultSerializesAsObject()
{
// Create a request mock
$request = new Request();
app()->instance('request', $request);

// Create an empty resource
$resource = new class([]) extends JsonResource
{
public function toArray($request)
{
return [];
}
};

// Configure the resource to serialize empty results as objects
$resource->serializeEmptyAsObject();

// The result should be {} instead of []
$this->assertSame('{}', $resource->toJson());
}
}
Loading