Let's talk about storing additional "duplicate" foreign keys, with an example from the open-source project Krayin CRM.
In the Krayin CRM project, there is a table product_inventories
for which migration looks like this:
Schema::create('product_inventories', function (Blueprint $table) { $table->increments('id'); $table->integer('in_stock')->default(0); $table->integer('allocated')->default(0); $table->integer('product_id')->unsigned(); $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); $table->integer('warehouse_id')->unsigned()->nullable(); $table->foreign('warehouse_id')->references('id')->on('warehouses')->onDelete('cascade'); $table->integer('warehouse_location_id')->unsigned()->nullable(); $table->foreign('warehouse_location_id')->references('id')->on('warehouse_locations')->onDelete('SET NULL'); $table->timestamps();});
The warehouse_location_id
field is from a grandparent table. A simplified schema looks like this:
So, we have the location_id
field in both the "child" and "grandchild" tables, duplicating data.
Technically, this violates the third normalization form. Also, there could be problems like:
However, if your application frequently queries the "grandparent" table, you might gain a performance advantage.
In this example, you could use the direct column instead of joining the full warehouses
table:
ProductInventory::where('warehouse_location_id', $id)->get();
An alternative approach would be to use the hasManyThrough()
relationship from the Laravel core. But we need the opposite, belongsToThrough(),
which doesn't exist in Laravel.
But a package staudenmeir/belongs-to-through can help. This package allows you to query the grandparent table through the child table.
The only difference when using this package is that instead of adding warehouse_id
to the warehouse_locations
table, we would add warehouse_location_id
to the warehouses
table.
Then, in the ProductInventory
Model, we would define the BelongsToThrough
relationship from the package where related would be the WarehouseLocation
Model and through the Warehouse
Model.
use Znck\Eloquent\Relations\BelongsToThrough; class ProductInventory extends Model{ use \Znck\Eloquent\Traits\BelongsToThrough; // ... public function warehouseLocation(): BelongsToThrough { return $this->belongsToThrough(WarehouseLocation::class, Warehouse::class); }}
Now, in the Controller or whenever you would use this warehouseLocation
relationship as any other Eloquent relationship.
The same CRM project has another similar example, with DB tables leads
and lead_pipelines
.
First, every lead belongs to a specific pipeline:
Schema::create('leads', function (Blueprint $table) { $table->increments('id'); $table->string('title'); // ... more fields $table->integer('lead_pipeline_id') ->unsigned() ->nullable(); $table->foreign('lead_pipeline_id') ->references('id') ->on('lead_pipelines') ->onDelete('cascade');});
Then, every pipeline consists of stages:
Schema::create('lead_pipeline_stages', function (Blueprint $table) { $table->increments('id'); // ... more fields $table->integer('lead_pipeline_id')->unsigned(); $table->foreign('lead_pipeline_id')->references('id')->on('lead_pipelines')->onDelete('cascade');});
Finally, we add the lead_pipeline_stage_id
to the leads
table:
Schema::table('leads', function (Blueprint $table) { $table->integer('lead_pipeline_stage_id') ->after('lead_pipeline_id') ->unsigned() ->nullable(); $table->foreign('lead_pipeline_stage_id') ->references('id') ->on('lead_pipeline_stages') ->onDelete('cascade');});
However, we still leave the "grandparent" lead_pipeline_id
for convenient querying. So, devs can write queries on both columns, depending on the scenario.
Conclusion: while adding a foreign key from the grandparent table would technically violate the third normalization form, it should ultimately depend on your application. In some cases, it brings more benefit than harm.