Polymorphic relations in SQL allow one table to relate to any number of other tables through a field that stores object identifiers from different tables and a field that indicates the table.
Polymorphic relations are ideal for database isolation within a module.
A few personal tips when working in Laravel:
NEVER use ClassName as a table pointer. Always use an alias:
able suffix is recommended but inconvenient. I advise standardizing and using the same name for all polymorphic examples, such as entity_id, entity_type.
I also recommend standardizing the relations table. My option is has_relations for basic cases.
If you use Postgres, I recommend formatting aliases in an Enum, even if there is only one relation now. This will provide optimization and, most importantly, understanding in the code and DB with which entity it can interact.
Within a module - use any relations. Between modules - polymorphic.
Migration
DB::unprepared("CREATE TYPE some_holder AS ENUM ('some_use_table1', 'some_use_table2')");
Schema::create('has_device', function (Blueprint $table) {
$table->foreignId('some_id')->constrained();
$table->foreignId('entity_id');
$table->typeColumn('some_holder', 'entity_type');
$table->index(['entity_id', 'entity_type']);
$table->unique(['device_id', 'entity_id', 'entity_type']);
});
Don't forget to add an index for entity_id, entity_type. I also advise making the combination of device_id, entity_id, entity_type unique - all repetitions go in the payload.
Remember, one of many relations in the case of eager loading (with) pull all records and attach one to the request.
Create extra morph relations if necessary. This can simplify logic and code understanding and, of course, optimize selections.
The database does not guarantee the integrity of morph keys! Keep this in mind.
The DB cannot implement cascading deletion of polymorphic relations. The responsibility for deletion lies with the developers. Do not confuse detach and deletion for MorphManyToMany. Detach can be done immediately (but should not be), and delete only when there are no other references to the object.
Laravel does not support OneOfMorphToMany, but it is easy to implement. Be careful. This approach pulls ALL records and returns only one. I advise you to use only with a unique key $table->unique(['entity_id', 'entity_type'])
<?php
namespace Tests\Unit\Traits\Eloquent\HasOneOfMorphToManyRelation\Resource;
use App\Database\Eloquent\Relations\OneOfMorphToMany;
use App\Traits\Eloquent\HasOneOfMorphToManyRelation;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ModelForHasOneOfMorphMany extends Model
{
use HasOneOfMorphToManyRelation;
public $guarded = ['id'];
public $timestamps = false;
protected $table = 'model_for_test_one_of_morph_to_many';
public function relation(): OneOfMorphToMany
{
return $this->oneOfMorphToMany(
ModelForHasOneOfMorphManyRelated::class,
'entity',
'has_relations'
);
}
public static function booted(): void
{
Relation::enforceMorphMap([
'one_of_one_main' => self::class,
]);
Schema::dropIfExists('model_for_test_one_of_morph_to_many');
Schema::create('model_for_test_one_of_morph_to_many', function (Blueprint $table) {
$table->id();
});
}
}
<?php
namespace Tests\Unit\Traits\Eloquent\HasOneOfMorphToManyRelation\Resource;
use App\Database\Eloquent\Relations\OneOfMorphToMany;
use App\Traits\Eloquent\HasOneOfMorphToManyRelation;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ModelForHasOneOfMorphManyControl extends Model
{
use HasOneOfMorphToManyRelation;
public $guarded = ['id'];
public $timestamps = false;
protected $table = 'model_for_test_one_of_morph_to_many_control';
public function relations(): MorphToMany
{
return $this->morphToMany(
ModelForHasOneOfMorphManyRelated::class,
'entity',
'has_relations'
);
}
public function relation(): OneOfMorphToMany
{
return $this->oneOfMorphToMany(
ModelForHasOneOfMorphManyRelated::class,
'entity',
'has_relations'
)->orderByDesc('id');
}
public static function booted(): void
{
Relation::enforceMorphMap([
'one_of_one_control' => self::class,
]);
Schema::dropIfExists('model_for_test_one_of_morph_to_many_control');
Schema::create('model_for_test_one_of_morph_to_many_control', function (Blueprint $table) {
$table->id();
});
}
}
<?php
namespace Tests\Unit\Traits\Eloquent\HasOneOfMorphToManyRelation\Resource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ModelForHasOneOfMorphManyRelated extends Model
{
public $guarded = ['id'];
public $timestamps = false;
public $table = 'model_for_test_one_of_morph_to_many_relateds';
public static function booted(): void
{
Schema::dropIfExists('model_for_test_one_of_morph_to_many_relateds');
Schema::create('model_for_test_one_of_morph_to_many_relateds', function (Blueprint $table) {
$table->id();
});
}
}
The approach involves isolating all relation code in a trait. This works only in ActiveRecord. Doctrine will not handle this as the model file defines the DB, not the other way around.
Formally, the approach has no name. Although it is used in many ready-made solutions. But if you slightly lower the formalities - this is literally a Decorator.
I recommend calling the approach a decorator, so the working principle is clearer, and you personally look smarter than when you call it "well, that trait."
For implementation, it is very important to understand the principle of the with method of QueryBuilder. The principle is very simple, but for some reason, many do not understand it:
with takes all keys from the main query. Therefore, id in the select query is mandatory.
Makes a query to the related table, pulling out only the records that are related to the entities from the main query - where entity_id in (ids) where entity_type = 'some_type''.
Then on the code side, each model binds the related models to each object. Therefore, pulling id for select relation is mandatory.
Thus, the with query is always +1 simple select query. There will be no n+1 problems; this is Eager loading.
Not only in with, but everywhere, write SELECT and specify the list of fields you want to retrieve. This is optimization, code readability, and code security.
`Select *` is an antipattern even when retrieving one record. Use `->first(['id'])` if you need an object. Or `->value('id')` - when you need a specific field.
A personal recommendation is to write the list of fields in select immediately with the table specification. This will greatly simplify navigation through the code and allow free use of join.
It is important to understand the principle of eloquent scoped methods. Here everything is simple - these are methods on QueryBuilder. A method without the scope prefix can be used as part of the selection query.
For the trait approach, you need to isolate:
Function describing the relation. Ideally a morph relation. Mandatory.
Scope functions for retrieving related data. This will standardize data for all who use the relation. And will allow reusing the selection and testing it in isolation.
Scope functions for filtering. where has etc.
Scoped functions for aggregation. Count, sum, etc. Again, it isolates and allows describing exceptions from logic.
Functions attach, detach, set, etc. This will allow isolating any specific save logic inside the trait. And, of course, test it.
Enum Holder test helper
app/Services/Tests/DatabaseTestHelper.php
<?php
namespace App\Services\Tests;
use Illuminate\Support\Facades\DB;
class DatabaseTestHelper
{
public static function enumColumnToString(string $table, string $column = 'entity_type'): void
{
DB::unprepared("ALTER TABLE {$table} ALTER COLUMN {$column} TYPE varchar(255)");
}
}
Has device decorator
Modules/Device/Traits/HasDevice.php
<?php
namespace Modules\Device\Traits;
use App\Database\Eloquent\Relations\OneOfMorphToMany;
use App\Traits\Eloquent\HasOneOfMorphToManyRelation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Modules\Device\Models\Device;
/**
* @mixin Model
*
* @method Builder|static withDevice()
*
* @see HasDevice::scopeWithDevice
*/
trait HasDevice
{
use HasOneOfMorphToManyRelation;
public function setDevice(Device $device): void
{
$this->device()->attach($device->id);
}
public function device(): OneOfMorphToMany
{
return $this->oneOfMorphToMany(Device::class, 'entity', 'has_device');
}
public function scopeWithDevice(Builder $query): void
{
$query->with('device', function ($q) {
$q
->select([
'devices.id',
'devices.ip',
'devices.platform_name',
'devices.browser_family',
'devices.browser_name',
'devices.device_family',
'devices.device_model',
'devices.type',
'devices.country',
'devices.region',
'devices.city',
]);
});
}
}
<?php
namespace Modules\Device\Tests\Unit\Traits\HasDevice\Resource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Modules\Device\Traits\HasDevice;
class ModelForTestDevice extends Model
{
use HasDevice;
public $timestamps = false;
protected $guarded = ['id'];
public static function booted(): void
{
Relation::enforceMorphMap([
'map_for_test_device' => self::class,
]);
Schema::dropIfExists('model_for_test_devices');
Schema::create('model_for_test_devices', function (Blueprint $table) {
$table->id();
});
}
}
Has deviceS decorator
Device/Traits/HasDevices.php
<?php
namespace Modules\Device\Traits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Modules\Device\Models\Device;
/**
* @mixin Model
*
*/
trait HasDevices
{
public function attachDevice(Device $device): void
{
$this->devices()->attach($device->id);
}
public function devices(): MorphToMany
{
return $this->morphToMany(Device::class, 'entity', 'has_devices');
}
}
<?php
namespace Modules\Device\Tests\Unit\Traits\HasDevices;
use App\Services\Tests\DatabaseTestHelper;
use Modules\Device\Models\Device;
use Modules\Device\Tests\Unit\Traits\HasDevices\Resource\ModelForTestDevices;
use Tests\TestCase;
class HasDevicesTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
DatabaseTestHelper::enumColumnToString('has_devices');
}
/**
* @test
*/
public function canAttachDevice()
{
[$device1, $device2] = Device::factory()->count(2)->create();
$model = ModelForTestDevices::create();
$model->attachDevice($device1);
$model->attachDevice($device2);
$this->assertDatabaseHas('has_devices', [
'has_devices.entity_type' => $model->getMorphClass(),
'has_devices.entity_id' => $model->id,
'has_devices.device_id' => $device1->id,
]);
$this->assertDatabaseHas('has_devices', [
'has_devices.entity_type' => $model->getMorphClass(),
'has_devices.entity_id' => $model->id,
'has_devices.device_id' => $device2->id,
]);
}
}
Device resource
Modules/Device/Transformers/DeviceResource.php
<?php
namespace Modules\Device\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Modules\Device\Enums\DeviceType;
use Modules\Device\Models\Device;
use OpenAPI\Properties\PropertyEnum;
use OpenAPI\Properties\PropertyString;
use OpenAPI\Schemas\BaseScheme;
#[BaseScheme(
resource: DeviceResource::class,
properties: [
new PropertyString(
property: 'ip',
example: '162.168.1.1'
),
new PropertyString(
property: 'platform_name',
nullable: true
),
new PropertyString(
property: 'browser_family',
nullable: true
),
new PropertyString(
property: 'browser_name',
nullable: true
),
new PropertyString(
property: 'device_family',
nullable: true
),
new PropertyString(
property: 'device_model',
nullable: true
),
new PropertyEnum(
property: 'type',
enum: DeviceType::class
),
new PropertyString(
property: 'country',
nullable: true
),
new PropertyString(
property: 'region',
nullable: true
),
new PropertyString(
property: 'city',
nullable: true
),
]
)]
/**
* @mixin Device
*/
class DeviceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'ip' => $this->ip,
'platform_name' => $this->platform_name,
'browser_family' => $this->browser_family,
'browser_name' => $this->browser_name,
'device_family' => $this->device_family,
'device_model' => $this->device_model,
'type' => $this->type,
'country' => $this->country,
'region' => $this->region,
'city' => $this->city,
];
}
}
Attach
Modules/Auth/Actions/LoginUser.php
<?php
namespace Modules\Auth\Actions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsController;
use Modules\Auth\Models\User;
use Modules\Auth\Requests\LoginRequest;
use Modules\Auth\Transformers\LoginUserResource;
use Modules\Device\Actions\MakeActualDevice;
use OpenAPI\Operation\ApiPost;
use OpenAPI\Request\RequestJson;
use OpenAPI\Responses\ResponseJsonSuccess;
use OpenAPI\Responses\ResponseUnauthorized;
use Throwable;
#[ApiPost(
path: '/api/v1/login',
tags: ['Auth'],
description: 'Login user'
)]
#[ResponseUnauthorized]
#[ResponseJsonSuccess(LoginUserResource::class)]
#[RequestJson(LoginRequest::class)]
class LoginUser
{
use AsController;
/**
* @throws Throwable
*/
public function handle(LoginRequest $request): LoginUserResource
{
$login = Auth::attempt($request->only(['email', 'password']));
throw_unless($login, new AuthenticationException());
/** @var User $user */
$user = $request->user();
DB::beginTransaction();
try {
$device = MakeActualDevice::run();
$token = $user->createToken();
$token->accessToken->setDevice($device);
if ($request->get('trust_device')) {
$user->attachDevice($device);
}
DB::commit();
} catch (Throwable $e) {
DB::rollBack();
throw $e;
}
return new LoginUserResource([
'token' => $token->plainTextToken,
]);
}
}
Eager loading
Modules/Auth/Actions/GetSessions.php
<?php
namespace Modules\Auth\Actions;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
use Lorisleiva\Actions\Concerns\AsController;
use Modules\Auth\Models\PersonalAccessToken;
use Modules\Auth\Requests\GetSessionsRequest;
use Modules\Auth\Transformers\SessionResource;
use OpenAPI\Operation\ApiGet;
use OpenAPI\Responses\ResponsePaginate;
#[ApiGet(
path: '/api/v1/sessions',
tags: ['Auth'],
description: 'Get current user sessions',
auth: true,
paginate: true
)]
#[ResponsePaginate(SessionResource::class)]
class GetSessions
{
use AsController;
public function handle(GetSessionsRequest $request): AnonymousResourceCollection
{
$user = Auth::user();
$sessions = PersonalAccessToken::select([
'personal_access_tokens.id',
'personal_access_tokens.slug',
'personal_access_tokens.created_at',
'personal_access_tokens.last_used_at',
])
->withDevice()
->where([
'personal_access_tokens.tokenable_id' => $user->id,
'personal_access_tokens.tokenable_type' => $user->getMorphClass(),
])
->orderByDesc('personal_access_tokens.last_used_at')
->paginate($request->get('per_page', 10));
return SessionResource::collection($sessions);
}
}
For trait names, I also recommend abandoning the able prefix. HasSomeRelation will be ok.
Combine decorator and JsonResource. This very conveniently allows isolating and describing response formats.
For simple entities, usually, one resource is enough. For complex ones - 2: short for lists and more detailed. If you have a lot of resources - most likely you have problems with business logic. And the frontend developer will curse you.
The Trait approach works perfectly with simple relations where the main entity and its dependencies are obvious. Problems start when entities are equal. For example, a music band and concerts - a direct dependency, the band has concerts. But for a pair of music band and festival, it is hard to say who depends on whom. This is the chicken and egg problem, and no approach solves it beautifully.
For multi-layered relations, I advise denormalizing. Yes, it sounds scary, but in practice attaching an "order" to both the customer and the store at once - this is literally 2 lines of code and plus one simple record in the DB. But it will provide incredible convenience in work and optimization.
A big minus of such relations is the complexity of testing. Methods that pull and filter many scope methods from different traits will often be encountered. Here, either really create a full structure with entities or simply call a conditional ModelEvent on scope methods and check their call.
Of course, instead of scope methods, you can use Action with the same logic as with, but implemented manually. And this will have to be done when moving the module to a separate service. But for now, I think it is more convenient to limit everything to a trait.
Complex logic, large Query requests, etc. should still be moved to Action. Testing them is much easier.
An advantage of Action is the ability to call them in a queue, for example, saving relations. But this is very bad for user experience. I do not recommend it.
Polymorphic relations will still be the only adequate DB structure, regardless of whether you use decorators or actions.
Transactions are mandatory in the context of denormalization.
And without it, a transaction gives 2 very positive points:
You save everything or nothing. You will forever get rid of data integrity problems in case of errors. Even when using microservices, a throw from action will say that everything is not ok.
The transaction creates one connection to the DB. This is somewhat irrelevant for Octane but gives a significant effect in normal development.
Always test transactions. It is very easy to forget to call commit, and debugging this is very difficult.