Skip to content

Commit cfdc22c

Browse files
authored
fix(laravel): do not exclude custom primary keys matching HasMany foreign keys (#7810)
Fixes #7190
1 parent 98b8efb commit cfdc22c

File tree

5 files changed

+145
-1
lines changed

5 files changed

+145
-1
lines changed

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Laravel\Eloquent\Metadata;
1515

1616
use Illuminate\Database\Eloquent\Model;
17+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1718
use Illuminate\Database\Eloquent\Relations\Relation;
1819
use Illuminate\Support\Str;
1920
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
@@ -77,7 +78,10 @@ public function getAttributes(Model $model): array
7778
$indexes = $schema->getIndexes($table);
7879
$relations = $this->getRelations($model);
7980

80-
$foreignKeys = array_flip(array_filter(array_column($relations, 'foreign_key')));
81+
// Only exclude BelongsTo foreign keys — those are local columns on this model's table.
82+
// HasMany/HasOne foreign keys reference the related table and should not be excluded.
83+
$belongsToRelations = array_filter($relations, static fn ($r) => is_a($r['type'], BelongsTo::class, true));
84+
$foreignKeys = array_flip(array_filter(array_column($belongsToRelations, 'foreign_key')));
8185
$attributes = [];
8286

8387
foreach ($columns as $column) {

src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Orchestra\Testbench\Concerns\WithWorkbench;
2121
use Orchestra\Testbench\TestCase;
2222
use Workbench\App\Models\Book;
23+
use Workbench\App\Models\Device;
2324

2425
/**
2526
* @author Tobias Oitzinger <tobiasoitzinger@gmail.com>
@@ -80,4 +81,22 @@ public function secret(): HasMany // @phpstan-ignore-line
8081
$metadata = new ModelMetadata();
8182
$this->assertCount(1, $metadata->getRelations($model));
8283
}
84+
85+
/**
86+
* When a model has a custom primary key (e.g. device_id) and a HasMany
87+
* relation whose foreign key on the related table has the same name,
88+
* the primary key must not be excluded from getAttributes().
89+
*
90+
* Only BelongsTo foreign keys are local columns that should be excluded.
91+
* HasMany/HasOne foreign keys reference the related model's table.
92+
*/
93+
public function testCustomPrimaryKeyNotExcludedByHasManyForeignKey(): void
94+
{
95+
$model = new Device();
96+
$metadata = new ModelMetadata();
97+
$attributes = $metadata->getAttributes($model);
98+
99+
$this->assertArrayHasKey('device_id', $attributes, 'Primary key "device_id" should not be excluded from attributes when it matches a HasMany foreign key name.');
100+
$this->assertTrue($attributes['device_id']['primary'], 'The device_id attribute should be marked as primary key.');
101+
}
83102
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Models;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\GetCollection;
19+
use Illuminate\Database\Eloquent\Model;
20+
use Illuminate\Database\Eloquent\Relations\HasMany;
21+
22+
/**
23+
* Model with a custom primary key (device_id) that matches the foreign
24+
* key name on the related Port model. Used to test that ModelMetadata
25+
* does not incorrectly exclude the primary key from the attribute list.
26+
*/
27+
#[ApiResource(
28+
operations: [
29+
new GetCollection(),
30+
new Get(),
31+
],
32+
)]
33+
class Device extends Model
34+
{
35+
protected $primaryKey = 'device_id';
36+
protected $fillable = ['hostname'];
37+
38+
public function ports(): HasMany
39+
{
40+
return $this->hasMany(Port::class, 'device_id', 'device_id');
41+
}
42+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Models;
15+
16+
use Illuminate\Database\Eloquent\Model;
17+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
18+
19+
/**
20+
* Child model with a foreign key (device_id) that matches the parent
21+
* model's primary key name. Not an API resource itself.
22+
*/
23+
class Port extends Model
24+
{
25+
protected $primaryKey = 'port_id';
26+
protected $fillable = ['device_id', 'name'];
27+
28+
public function device(): BelongsTo
29+
{
30+
return $this->belongsTo(Device::class, 'device_id', 'device_id');
31+
}
32+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
use Illuminate\Database\Migrations\Migration;
15+
use Illuminate\Database\Schema\Blueprint;
16+
use Illuminate\Support\Facades\Schema;
17+
18+
/*
19+
* Tables for testing custom primary key handling in ModelMetadata.
20+
*
21+
* Uses the common convention of <table>_id as primary key, where the
22+
* PK name on the parent table matches the FK name on the child table.
23+
*/
24+
return new class extends Migration {
25+
public function up(): void
26+
{
27+
Schema::create('devices', static function (Blueprint $table): void {
28+
$table->increments('device_id');
29+
$table->string('hostname');
30+
$table->timestamps();
31+
});
32+
33+
Schema::create('ports', static function (Blueprint $table): void {
34+
$table->increments('port_id');
35+
$table->unsignedInteger('device_id');
36+
$table->string('name');
37+
$table->foreign('device_id')->references('device_id')->on('devices');
38+
$table->timestamps();
39+
});
40+
}
41+
42+
public function down(): void
43+
{
44+
Schema::dropIfExists('ports');
45+
Schema::dropIfExists('devices');
46+
}
47+
};

0 commit comments

Comments
 (0)