Skip to content

[Laravel] camelCase named Relations are ignored as they are denormalized as snake_case #6927

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

Open
llei4 opened this issue Jan 24, 2025 · 3 comments
Labels

Comments

@llei4
Copy link

llei4 commented Jan 24, 2025

API Platform version(s) affected: 4.0.16

Description
On Laravel we use camelCase naming when the Model's name is composed. On relations, we also use that notation.

The relations uisng camelCase are ignored and the data from the paylod is not sent to the Processor.

How to reproduce
Having 2 Models:

GrandFather

namespace App\Models;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[ApiResource()]
class GrandFather extends Model
{
    protected $table = 'grand_fathers';
    protected $primaryKey = 'id_grand_father';
    protected $fillable = ['name','grandSons'];

    #[ApiProperty(genId: false, identifier: true)]
    private ?int $id_grand_father;

    private ?string $name = null;

    private ?Collection $grandSons = null;

    /**
     * @return HasMany
     */
    public function grandSons(): HasMany
    {
        return $this->hasMany(GrandSon::class,'grand_father_id','id_grand_father');
    }
}

GrandSon

namespace App\Models;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[ApiResource()]
class GrandSon extends Model
{
    protected $table = 'grand_sons';
    protected $primaryKey = 'id_grand_son';
    protected $fillable = ['name','grandFather','grand_father_id','grandfather'];

    #[ApiProperty(genId: false, identifier: true)]
    private ?int $id_grand_son;
    
    private ?string $name = null;

    private ?GrandFather $grandFather = null;

    /**
     * @return BelongsTo
     */
    public function grandFather(): BelongsTo
    {
        return $this->belongsTo(GrandFather::class,'grand_father_id','id_grand_father');
    }
}

Created using following migrations:

GrandFather

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{

    public function up(): void
    {
        Schema::create('grand_fathers', function (Blueprint $table) {
            $table->increments('id_grand_father');
            $table->string('name');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('grand_fathers');
    }
};

GrandSon

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{

    public function up(): void
    {
        Schema::create('grand_sons', function (Blueprint $table) {
            $table->increments('id_grand_son');
            $table->string('name');
            $table->unsignedInteger('grand_father_id')->nullable();
            $table->timestamps();
            $table->foreign('grand_father_id')->references('id_grand_father')->on('grand_fathers');

        });

    }

    public function down(): void
    {
        Schema::dropIfExists('grand_sons');
    }
};

Using POST on GrandSon with the following Payload:

{
  "name": "GrandSon Name",
  "grandFather": {
    "name": "GrandFather Name"
  }
}

so the whole call is:

curl -X 'POST' \
  'http://localhost:8008/api/grand_sons' \
  -H 'accept: application/ld+json' \
  -H 'Content-Type: application/ld+json' \
  -d '{
  "name": "GrandSon Name",
  "grandFather": {
    "name": "GrandFather Name"
  }
}'

It creates the GrandSon with no realtion at all (so grand_father_id is NULL.

In order to investigate, if I Print the $body variable on ApiPlatformController just before the processor uses process function on Line 98 I get:

{"name":"GrandSon Name"}

So the Relation is not just not persisted but the data is totally ignored and it is not sent to the Processor.
If then I try to switch the relation namig to all lowercase so on Model GrandSon:

...
    private ?GrandFather $grandfather = null;

    /**
     * @return BelongsTo
     */
    public function grandfather(): BelongsTo
    {
        return $this->belongsTo(GrandFather::class,'grand_father_id','id_grand_father');
    }
...

and I use the Following Payload:

{
  "name": "GrandSon Name",
  "grandfather": {
    "name": "GrandFather Name"
  }
}

Then I receive the expected error 500 "Nested documents for attribute \"grandfather\" are not allowed. Use IRIs instead." so the Relation is processed (The error is a whole different story discussed on: #6882)

On the other hand, I trid setting

#[SerializedName('grand_father')]

on the realation which works but then you are forced to use grand_father parameter on the Payload.

This is specially annoying for all those projects which have several Models and Relations already defined on the usual way and want to start using ApiPlatform.

Possible Solution

Not using SnakeCaseToCamelCaseNameConverter to denormalize relations.

I think the problem is using SnakeCaseToCamelCaseNameConverter to denormalize Relations.

This denormalization works on actual attributes so camelCase attributes defined on Laravel Models are converted to snake_case as this is how the fileds are named on DB but fails on relations.

Example: GrandFather relation is denormalized as grand_father

@soyuka
Copy link
Member

soyuka commented Jan 28, 2025

You shouldn't mix both camelCase and snake_case, to make things integrate well with Eloquent models, we're doing automatic transformations. If you want to do something more manual, I recommend to declare your ApiResource on a App\ApiResource\GrandSon class and set a provider that fetches your model. I'm unsure how to fix this behavior as we rely heavily on everything beeing snake_case with Eloquent.

@llei4
Copy link
Author

llei4 commented Jan 28, 2025

Hello,

Thanks for the answer.

I am not trying to mix camelCase and snake_case, just following Laravel's standards so Relations are defined using camelCase.

Maybe I didn't explain myself properly but the point is that having a Relation using camelCase (GrandFather on GrandSon Model), calling POST/PATCH/PUT on GrandSon using the following Payload:

{
  "name": "GrandSon Name",
  "grandFather":  "/api/grand_fathers/1"
}

It is converted to:

{
  "name": "GrandSon Name",
}

by the default provider on ApiPlatformController so the $body resulting of $this->provider->provide($operation, $uriVariables, $context); is no longer having the data for that Relation so it is not sent to the Processor.

If I switch the Relation's name not using camelCase so for example grandfather (instead of grandFather) and using this new relation name on the Payload so:

{
  "name": "GrandSon Name",
  "grandfather":  "/api/grand_fathers/1"
}

data remains on $body so it is sent to the Processor.

I think this is because the nameConverted SnakeCaseToCamelCaseNameConverter used on the Attributes is also used on Relations so it transforms grandFather to grand_father which doesn't exist on the Model.

EDIT

In order to add more context:

The defualt provider uses AbstractObjectNormalizer.php from Symfony. In line 345 it converts the attributes name using the nameconverter. So, as it is using denormalize from SnakeCaseToCamelCaseNameConverter the Attribute grandFather is converted to grand_father so then it is ignored here because it is not on the $allowedAttributes array.

Copy link

stale bot commented Mar 30, 2025

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the stale label Mar 30, 2025
@stale stale bot closed this as completed Apr 7, 2025
@soyuka soyuka reopened this Apr 8, 2025
@stale stale bot removed the stale label Apr 8, 2025
@soyuka soyuka added the bug label Apr 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants