Skip to content

Conversation

@achrafAa
Copy link
Contributor

Fix: Add $forceWrap property to JsonResource for consistent API response wrapping

Problem

check #56724

Laravel JsonResource wrapping behavior is inconsistent when the resource data contains a key that matches the $wrap property (usually 'data'). This causes unpredictable API responses depending on the internal structure of the model.

Current Behavior (Bug)

// Model WITHOUT 'data' column
$task = Task::find(1); // Task model with id=1, title="Task 1"
return new TaskResource($task);
// Response: {"data": {"id": 1, "title": "Task 1"}}

// Model WITH 'data' column  
$task = Task::find(1); // Task model with id=1, title="Task 1", data="some value"
return new TaskResource($task);
// Response: {"id": 1, "title": "Task 1", "data": "some value"} // ❌ No wrapping!

Root Cause

In ResourceResponse::haveDefaultWrapperAndDataIsUnwrapped(), wrapping is skipped if the resource array already contains a top-level key equal to $wrap:

protected function haveDefaultWrapperAndDataIsUnwrapped($data) 
{
    return $this->wrapper() && ! array_key_exists($this->wrapper(), $data);
}

Solution

Added an opt-in $forceWrap static property to JsonResource that forces wrapping even when the wrapper key exists in the resource data.

Changes Made

1. Add $forceWrap property to JsonResource

// src/Illuminate/Http/Resources/Json/JsonResource.php

/**
 * Whether to force wrapping even if $wrap key exists in resource data.
 *
 * @var bool
 */
public static $forceWrap = false;

2. Update ResourceResponse to check $forceWrap

// src/Illuminate/Http/Resources/Json/ResourceResponse.php

protected function haveDefaultWrapperAndDataIsUnwrapped($data)
{
    if ($this->resource instanceof JsonResource && $this->resource::$forceWrap) {
        return $this->wrapper() !== null;
    }

    return $this->wrapper() && ! array_key_exists($this->wrapper(), $data);
}

Usage

class TaskResource extends JsonResource
{
    public static $wrap = 'data';
    public static $forceWrap = true; // ← Forces consistent wrapping

    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'data' => $this->data, // Won't break wrapping anymore
        ];
    }
}

Results

// Model WITH 'data' column + forceWrap = true
$task = Task::find(1); // Task model with id=1, title="Task 1", data="some value"
return new TaskResource($task);
// Response: {"data": {"id": 1, "title": "Task 1", "data": "some value"}} ✅ Consistent!

Benefits

Predictable API Responses - Response shape is no longer dependent on model field names
Backward Compatible - Default behavior unchanged, purely opt-in
Explicit Developer Intent - Clear declaration of wrapping behavior
Supports Modern API Patterns - Enables JSON:API, GraphQL-style APIs with reserved keys

Test Coverage

Added simple, direct tests:

  • testResourceSkipsWrappingWhenDataKeyExists - Demonstrates the original bug
  • testResourceWrapsWhenDataKeyDoesNotExist - Verifies normal behavior
  • testResourceForceWrapOverridesDataKeyCheck - Verifies the fix works

Tests directly call $resource->toResponse() and verify JSON output - no complex route setup needed.

All existing tests pass (76/76), confirming no breaking changes.

Impact

This fixes a long-standing inconsistency in Laravel resource responses that has confused developers and created brittle API contracts. The solution follows Laravel's design philosophy of being explicit over implicit while maintaining full backward compatibility.

@achrafAa achrafAa changed the title Fix: Add $forceWrap property to JsonResource for consistent API response #56724 [12.x] Fix: Add $forceWrap property to JsonResource for consistent API response #56724 Aug 25, 2025
@challapradyumna
Copy link

This fix will be backported to 11.x right ?

@achrafAa
Copy link
Contributor Author

@challapradyumna I think its possible as it would be the same change.

@macropay-solutions
Copy link

macropay-solutions commented Aug 25, 2025

@achrafAa the opt-in should be default from a logical perspective. Did you contact the owner of that line

$this->wrapper() && ! array_key_exists($this->wrapper(), $data);

to ask for hes/her reasoning on this?

@achrafAa
Copy link
Contributor Author

@macropay-solutions, logically, consistent wrapping might seem like the better default—especially to ensure predictable API responses regardless of internal data structure.

That said, I did some digging and saw that this particular behavior comes from this line authored by Taylor back in 2017:
7d9abfd

I’d be interested in understanding the original intent behind this condition. My current interpretation is that it was an optimization to avoid redundant wrapping when the resource data already includes a data key. But in practice, as shown in the example, it can lead to inconsistent API responses when models happen to have a data attribute.

@taylorotwell – would you be open to sharing your original thinking behind this behavior? Do you think the forceWrap opt-in approach is an acceptable compromise, or would it make sense to revisit the default behavior?

@macropay-solutions
Copy link

@achrafAa maybe to avoid infinite loops or...

@taylorotwell taylorotwell merged commit f24fb47 into laravel:12.x Aug 26, 2025
60 checks passed
@AhmedAlaa4611
Copy link
Contributor

Why don’t we simply remove the check?

! array_key_exists($this->wrapper(), $data)

@AhmedAlaa4611
Copy link
Contributor

Why don’t we simply remove the check?

! array_key_exists($this->wrapper(), $data)

Hello @achrafAa

@achrafAa
Copy link
Contributor Author

achrafAa commented Sep 2, 2025

@AhmedAlaa4611 we want to avoid breaking changes, that check was put for a certain X raison, thus deleting it may cause those scenarios to break app flow.

@AhmedAlaa4611
Copy link
Contributor

@AhmedAlaa4611 we want to avoid breaking changes, that check was put for a certain X raison, thus deleting it may cause those scenarios to break app flow.

What about reverting this and removing the check from master?

To be honest, it feels inconsistent that the framework sometimes applies wrapping and other times doesn’t. If consistent wrapping is needed, the $forceWrap property should be used.

It’s unfortunate to introduce this kind of inconsistency, especially since Taylor declined to document it.

@achrafAa
Copy link
Contributor Author

achrafAa commented Sep 2, 2025

@AhmedAlaa4611 Not sure about removing it from the master branch, but for me, avoiding breaking changes is the way to go — especially since this Resource is widely used by packages and businesses.

The refusal of a documentation PR doesn’t necessarily mean Taylor disagrees. It could also just mean avoiding fluff or overly opinionated API-style docs.

That said, feel free to create a PR to address your concern and add documentation from your point of view, as long as it doesn’t introduce breaking changes. I’ll be happy to support it if it makes sense to me. 🙏

At the end of the day, it’s all about stability over opinionated decisions.

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.

7 participants