头图

Laravel 多态关联的模型本地化套用

前言

在项目中,一般情况下,我们可以使用单个字段来创建一个一对一或者一对多关联,比如一个 User 有多个 Post。

而除了这些,我们偶尔会遇到一些关联关系除了需要根据 ID 进行关联外,还可能需要根据不同的 Type 去关联不同的模型,而这,就是多态关联。

在 Laravel 中,为我们提供了开箱即用的多态关联。

准备环境

软件版本
Windows 1124H2
PHP8.2.6 ZTS Visual C++ 2019 x64
Xdebug3.3.2
SQLite--
Laravel Framework11.34.2 [注1]

表结构[注2]

classDiagram
direction BT
class comments {
   varchar commentable_type
   integer commentable_id
   text body
   datetime created_at
   datetime updated_at
   integer id
}
class posts {
   varchar title
   text content
   datetime created_at
   datetime updated_at
   integer id
}
class videos {
   varchar title
   varchar url
   datetime created_at
   datetime updated_at
   integer id
}

如上图所示,我们存在三张表,其中,comments 使用 commentable_id 分别与 posts 和 videos 关联,至于具体需要关联哪一个模型,则需要根据 commentable_type 来决定。

默认情况下,Laravel 在 commentable_type 中填充的是 Post 和 Video 模型的完整命名空间类名。也就是分别为:App\Models\PostApp\Models\Video,这个设计带来了一些小问题。

  • 我们的 type 字段和实体类名强关联了,如果将来类可能要改名字,就势必要修改数据库内的数据才行。
  • 这里面包含了 \ 这个 “麻烦”的字符,有些 SQL 工具你还需要多次转义后才能构成最终的查询,尤其是有些特别的查询。
  • 这里完整的类名太过于冗长,他们几乎都有一样的前缀,只是后面的类名不一样而已。

基于以上的问题,Laravel 官方也提供了解决方案,就是使用 Relation::morphMap 方法,它用起来就像下面这样。

public function boot(){
    Relation::morphMap([
        'post' => 'App\Models\Post',
        'video' => 'App\Models\Video',
    ]);
}

等等,不对啊,我看文档中明明是 Relation::enforceMorphMap,你怎么说是 morphMap,其实都没错,虽然你现在看到最新的 Laravel 文档中提到的是 Relation::enforceMorphMap ,但是实际上,morphMap 也是可以用的,因为这是之前遗留下的方法,那既然有 morphMap 了,为什么还需要 enforceMorphMap

问得好,在使用 morphMap 我们可以选择为部分的 morph 设置映射,比如上面的例子中,我可以只映射 post、而不映射 video,也就是说,在数据库中,post 的 type 保存的是 post、而 video 却保存的 App\Models\Video,造成了一种不一致了。尤其是这里的 “多态”,就意味着,一开始可能只有这两个类型,结果后面又新增一个类型,前面的你都映射了,而后面的同事忘了映射,就导致数据库里面的数据看起来乱糟糟的了。

所以为了解决这个问题,自 Laravel 8.x 开始,引入了 enforceMorphMap 这个 Feature,并且文档里面也改为了这个方法,现在如果你使用了这个,模型却忘了映射,就会抛出一个 ClassMorphViolationException 异常,而不是像之前那样默不作声,从而帮助你在开发阶段发现这个问题,它内部其实是调用了 requireMorphMap + morphMap 而已,当然,如果你喜欢之前那样,还是可以调用旧的 morphMap 方法 。

遇到的问题

实际上,上面都是理想完美状态,有一些特别的限制。

  • 定义的别名必须是全局唯一的。
  • 使用数字作为别名时,被意外的重置(Laravel < 5.5)

有时候我们的业务中就是使用了数字来区分不同的 type,并且不同模型的 type 可能还会重复,比如我们希望在 Comment 中,type 1/2 分别表示 Post 和 Video,而在 Attachment 中,type 1/2 又分别表示 User 和 Product。

通常我们直接这样做的话,都会得到一个错误:Class name must be a valid object or a string,它来自于 PHP,因为 PHP 在尝试 new 这个类的时候,无法解析了。

这个需求很“常见”是吧,但是目前却做不到。

在早期的版本中,其实还存在另外一个由 morphMap 带来的问题

    public static function morphMap(array $map = null, $merge = true)
    {
        $map = static::buildMorphMapFromModels($map);

        if (is_array($map)) {
            static::$morphMap = $merge && static::$morphMap
                            ? array_merge(static::$morphMap, $map) : $map;
        }

        return static::$morphMap;
    }

如上的代码中,如果我们传入的映射键是数字的,代码进入 array_merge 的分支时,就会因为 array_merge 导致索引被重置……

在上面的这个讨论中,还提到了另外一个问题,就是能否以组的方式,为每个都定义映射,不过这个问题的讨论并没有结果,但是类似的期待还是挺多的,甚至还出现了一些偏门的。

这个 PR 中,添加了一个 Relation::morphUsingTableNames() 方法,用于自动映射表名,但是因为一些复杂的原因,这个 PR 最后还是被 Revert 了,因为它还存在一些其他不能被修复的[问题https://github.com/laravel/framework/issues/35519]()。

image.png

其实在这个 PR 中,还有另外一个声音

image.png

imliam on Aug 20, 2021

While this code is being changed, maybe it would also be useful to add an optional property/method to models (eg. morphableName) that can be used - so each model can represent its own morphable name itself instead of being done elsewhere in a map?

寻找解药

不过值得一提的是 Laravel 中确实有一个存在类似能力的方法,那就是 HasRelationships::getActualClassNameForMorph,目前,他会在你使用单个模型访问尚未加载的多态模型时被调用,会传入 type 的值作为参数,也就是说,如果数据库中保存的是 1 ,这里就会传入 1。

这个方法的默认实现如下:

    public static function getActualClassNameForMorph($class)
    {
        return Arr::get(Relation::morphMap() ?: [], $class, $class);
    }

可以看到,这里实际上就是调用了前面定义的映射而已,现在我们到 Comment 添加这个方法的复写:

    public static function getActualClassNameForMorph($class)
    {
        return match ((int) $class) {
            1 => Post::class,
            2 => Video::class,
            default => parent::getActualClassNameForMorph($class),
        };
    }

然后你就会发现一个,好像确实可以。当你执行编写下面的代码时,你确实拿到了对应的 commentable

dump(\App\Models\Comment::first()->commentable);

看起来很美好,你应更很快就会发现,这实际上带来了另外一个问题,你没法使用 with 和 load 进行预加载关系了

当你使用 with 或者 load 时,你将会得到一个跟没有使用这个方法时一样的错误:Class name must be a valid object or a string,是的,没错,他就是真的没有使用这个方法……,想不到吧。

这其实是 Laravel 给我们开的一个“小玩笑”,当我们从一个尚未预加载 commentable 的模型时,如果访问 commentable,那么 Laravel 按照以下路径来访问到我们刚刚覆盖的 getActualClassNameForMorph 方法。

graph TD
    A[HasRelationships.morphInstanceTo]
    B[HasRelationships.morphTo]
    C[Comment.commentable]

    B --> A
    C --> B

Laravel 先调用了我们的关系方法 commentable,然后关系里面调用了 morphTo,接着又调用了 morphInstanceTo,然后在 morphInstanceTo 调用了我们刚刚创建的方法。

    protected function morphInstanceTo($target, $name, $type, $id, $ownerKey)
    {
        $instance = $this->newRelatedInstance(
            static::getActualClassNameForMorph($target)
        );

        return $this->newMorphTo(
            $instance->newQuery(), $this, $id, $ownerKey ?? $instance->getKeyName(), $type, $name
        );
    }

注意,这里之所以能够访问到我们定义的 getActualClassNameForMorph 方法,正是因为这里使用了 statis 进行调用(延迟绑定

现在,再来看看 getActualClassNameForMorph 的另一条访问路径

graph TD
    E[MorphTo.createModelByType]
    F[MorphTo.getResultsByType]
    G[MorphTo.getEager]
    H[Builder.eagerLoadRelation]
    I[Builder.eagerLoadRelations]
    J[Model.load]
    K[Builder.get]
    L[HasOneOrManyThrough.get]
    M[BelongsToMany.get]

    F --> E
    G --> F
    H --> G
    I --> H
    J --> I
    K --> I
    L --> I
    M --> I

可以看到,常用的 Builder.get 和 Model.load 都最终会访问到 MorphTo.createModelByType最终在这个方法里面调用了 getActualClassNameForMorph

    public function createModelByType($type)
    {
        $class = Model::getActualClassNameForMorph($type);

        return tap(new $class, function ($instance) {
            if (! $instance->getConnectionName()) {
                $instance->setConnection($this->getConnection()->getName());
            }
        });
    }

而这里却使用了 Model 进行调用,这是为什么呢?

现在来会看一下完整的调用路径

graph TD
    N[HasRelationships.getActualClassNameForMorph]
    A[HasRelationships.morphInstanceTo]
    B[HasRelationships.morphTo]
    C[Comment.commentable]
    E[MorphTo.createModelByType]
    F[MorphTo.getResultsByType]
    G[MorphTo.getEager]
    H[Builder.eagerLoadRelation]
    I[Builder.eagerLoadRelations]
    J[Builder.get]
    K[BelongsToMany.get]
    L[Model.load]
    M[HasOneOrManyThrough.get]

    A --> N
    B --> A
    C --> B
    E --> N
    F --> E
    G --> F
    H --> G
    I --> H
    J --> I
    K --> I
    L --> I
    M --> I

可以看到,当我们直接访问 commentable 的时候,实际上是走了左边的路径,而当我们使用 with/load 时走的是右边的路径。

此时最大的区别就在于,HasRelationships 本身是一个 Trait,它在基础 Model 中被 use,那也就是说,在上面调用 HasRelationships.morphInstanceTo 的时候 static 实际上还是我们调用的 Model(Comment),而 MorphTo 呢?它是一个单独的类,所以这里是用了 Model 进行调用,那我这里就访问不到 Comment 吗?其实不然,在 MorphTo 这个类中,因为其祖先类是 Relation,其实里面有一个 parent 属性,这个属性保存的就是 Comment 的实例。

classDiagram
    Relation <|-- BelongsTo
    BelongsTo <|-- MorphTo

    Relation : +Model parent
    class Relation
    class BelongsTo 
    class MorphTo

但是,至于 Laravel 中为什么会这样做,暂时不太清楚,没有找到相关资料。

值得一提的是,在 Laravel 5.4 之前,确实有过类似的用法,此后都被取代了。

image.png

其实从上面的 PR 中不难看到,一开始 morphInstanceTo 里面也是使用的 Model 进行调用。

image.png

只是在随后的一个 PR 中,有人贡献了一次,并且把这里改成了 static 调用,并且原因也是这个贡献者希望使用数字来为每个模型定义映射,这个 PR 也是被合并了,也就达到了现在的样子,但是对于 createModelByType 里面的调用,这位贡献者也提交了 PR,但是这个 PR 因为 Travis 的测试失败了,因为他这里是直接粗暴的调用了 static,前面说过,这里的 static 实际上是指向了 MorphTo,所以自然是行不通的,且作者没有及时维护,被关闭了,至此就遗留至今 🫥

现在,让我们打开 MorphTo::createModelByType ,修改 Model 为 parent

    public function createModelByType($type)
    {
-        $class = Model::getActualClassNameForMorph($type);
+        $class = $this->parent::getActualClassNameForMorph($type);

        return tap(new $class, function ($instance) {
            if (! $instance->getConnectionName()) {
                $instance->setConnection($this->getConnection()->getName());
            }
        });
    }

ok,返回我们的代码,你就可以发现 load 和 with 都可以正常工作了。

但是,这就够了吗?

如果你的表中只有一个这样的多态关联关系,这就够了,如果你有多个,这可能会不够,因为,getActualClassNameForMorph 这里虽然是传入的 type,但是他并没有告诉你,这是来自哪一个字段,这在预加载(with/load) 的时候变得极为困难。

当然,更重要的是目前这个还是要你去改核心的代码或者通过其他方式改核心的代码,有些难以达到。

陷入僵局

其实在几年前我也处理过这个问题当然是还在用 Laravel 5.4 版本,当时根据一通调试后,确定了 Laravel 内部在访问关联字段的时候,其实是会通过访问器的 get*Attribute,所以当时的解决方案就是,创建了一个 getCommentableTypeAttribute 这样的 访问器。

    public function commentableType(): Attribute
    {
        return Attribute::get(fn($value) => match ((int) $value) {
            1 => Post::class,
            2 => Video::class,
            default => $value,
        });
    }

好像确实可以,with/load 都工作了。

但是,当我们不适用 with/load 时,直接访问未加载的关系属性(commentable)时,你就会发现,又出错了,这次得到的时 Class "1" not found 这样的错误。

What?

其实啊,这是在 HasRelationships::morphTo 这个方法的 return 那里,有一个判断,其主要目的是,使用 getAttributeFromArray 获取模型的 $attributes 属性,并且返回指定 key 的,也就是说,他这里实际上时直接访问的 attributes 的值,并不会经过任何的访问器……,自然就没法使用。

    public function morphTo($name = null, $type = null, $id = null, $ownerKey = null)
    {
        // If no name is provided, we will use the backtrace to get the function name
        // since that is most likely the name of the polymorphic interface. We can
        // use that to get both the class and foreign key that will be utilized.
        $name = $name ?: $this->guessBelongsToRelation();

        [$type, $id] = $this->getMorphs(
            Str::snake($name), $type, $id
        );

        // If the type value is null it is probably safe to assume we're eager loading
        // the relationship. In this case we'll just pass in a dummy query where we
        // need to remove any eager loads that may already be defined on a model.
        return is_null($class = $this->getAttributeFromArray($type)) || $class === ''
                    ? $this->morphEagerTo($name, $type, $id, $ownerKey)
                    : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey);
    }

不过,注意后面的判断,如果 attributes 有这个字段,就会调用 morphEagerTo,反之调用后面的 morphInstanceTo,其实这里的注释就写的很明白,如果此时从 attributes 上获取的 type 为空,那么就有可能我们其实是在使用 with(因为这时候查询并没有结束,所以返回了 morphEagerTo),而现在这里是有值的,也就是数据库里面保存的 1/2,不过,别忘了,上面提到的众多 getActualClassNameForMorph 调用者中,就有这个 morphInstanceTo,是的,没错。。。我们还可能需要重写 getActualClassNameForMorph 方法来应对没有预加载的情况。

现在加上之前的 getActualClassNameForMorph 实现,就成了下面这样

public function commentable(): MorphTo
{
    return $this->morphTo();
}    

public static function getActualClassNameForMorph($class)
{
    return match ((int) $class) {
        1 => Post::class,
        2 => Video::class,
        default => parent::getActualClassNameForMorph($class),
    };
}

public function commentableType(): Attribute
{
    return Attribute::get(fn($value) => match ((int) $value) {
        1 => Post::class,
        2 => Video::class,
        default => $value,
    });
}

你还可以使用旧版的 get*Attribute 方法。

public function commentable(): MorphTo
{
    return $this->morphTo();
}    

public static function getActualClassNameForMorph($class)
{
    return match ((int) $class) {
        1 => Post::class,
        2 => Video::class,
        default => parent::getActualClassNameForMorph($class),
    };
}

public function getCommentableTypeAttribute($value)
{
    return match ((int) $value) {
        1 => Post::class,
        2 => Video::class,
        default => $value,
    };
}

现在,刷新页面,你会发现基本已经完美了,我们没有改变任何 Laravel 内部的代码,只是改变了我们的模型。

现在可以直接访问 commentable ,也可以使用 with/load

到此就完美了吗?

不,并没有

仔细观察,你会发现上面这个方案还存在一个小小的问题,那就是我们必须要覆盖 commentable_type 的读出字段,这样我们如果需要在接口返回时,就会显示成我们转换后的类名了,而不是原始的 1/2,那么有没有办法呢,其实是有的,那就是再新建一个 commentTypeRef 的字段,在这里面返回原始值,现在你的 Comment 模型应该就会像下面这样。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    use HasFactory;

    protected $appends = ['commentable_type_ref'];

    public static function getActualClassNameForMorph($class)
    {
        return match ((int) $class) {
            1 => Post::class,
            2 => Video::class,
            default => parent::getActualClassNameForMorph($class),
        };
    }

    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    public function commentableType(): Attribute
    {
        return Attribute::get(fn($value) => match ((int) $value) {
            1 => Post::class,
            2 => Video::class,
            default => $value,
        });
    }

    public function commentableTypeRef(): Attribute
    {
        return Attribute::get(fn() => (int) $this->attributes['commentable_type']);
    }


}

现在接口会返回一个 commentable_type_ref 字段,我这里偷懒了,直接转为 int 了。

很好,但美中不足,问题虽然解决了,但是前端原来用来判断的字段变了,不过这也无伤大雅,跟前端友好沟通一下就好了。

如果你想更进一步,那不妨继续看看。

更进一步

在上面的内容中,我们基本已经解决了我们的需求,只是有一些小瑕疵。

现在来思考一个问题,在我们创建模型关联的时候,使用到的字段,是必须要数据库中存在的吗?

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    use HasFactory;

    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function userId(): Attribute
    {
        return Attribute::get(fn() => $this->id);
    }
}

在这里,我们为 Post 模型创建了一个 user 的关系,如果你查看最初的表定义代码,你会发现,posts 表里面并没有 user_id 这个字段,而我却在下面使用获取其创建了一个 user_id 字段,那这样能行吗?

答案是可行的。

Route::get('/', function () {
-    $comments = \App\Models\Comment::limit(5)->get();
-
-    $comments->load('commentable');
-    $comment = $comments->first();
-    $commentable = $comment->commentable;
-
-    return $comments;
-
+    
+    $post = \App\Models\Post::with('user')->first();
+
+    return $post;
});
{
    "id": 1,
    "title": "voluptatibus",
    "content": "fugit",
    "created_at": "2024-11-29T15:44:42.000000Z",
    "updated_at": "2024-11-29T15:44:42.000000Z",
    "user": {
        "id": 1,
        "name": "Test User",
        "email": "test@example.com",
        "email_verified_at": "2024-12-01T03:53:24.000000Z",
        "created_at": "2024-12-01T03:53:24.000000Z",
        "updated_at": "2024-12-01T03:53:24.000000Z"
    }
}

没错,它可以正确的加载出 user 来,并且还可以使用 with/load 和直接访问。

有没有一点点的惊讶,这便是解决前面这个问题的所在。

现在, 回到 Comment 模型,把原本 commentable 里面的 morphTo 参数完善一下,,同时清理掉不再需要的 commentable_type_ref 吧,现在让我们用回之前的测试代码。

-protected $appends = ['commentable_type_ref'];

...

public function commentable(): MorphTo
{
-    return $this->morphTo();
+    return $this->morphTo('commentable', 'commentable_type_class', 'commentable_id', 'id');
}

-public function commentableType(): Attribute
+public function commentableTypeClass(): Attribute
{
-    return Attribute::get(fn($value) => match ((int) $value) {
+    return Attribute::get(fn() => match ((int) $this->commentable_type) {
        1 => Post::class,
        2 => Video::class,
-       default => $value,
+       default => $this->commentable_type,
    });
}


-public function commentableTypeRef(): Attribute
-{
-    return Attribute::get(fn() => (int) $this->attributes['commentable_type']);
-}

得到结果

[
    {
        "id": 1,
        "commentable_type": "1",
        "commentable_id": 5,
        "body": "iste",
        "created_at": "2024-11-29T15:48:28.000000Z",
        "updated_at": "2024-11-29T15:48:28.000000Z",
        "commentable": {
            "id": 5,
            "title": "sunt",
            "content": "numquam",
            "created_at": "2024-11-29T15:48:28.000000Z",
            "updated_at": "2024-11-29T15:48:28.000000Z"
        }
    }
]

现在,完美了没?我只想说, 还差一点点~

为了后面的步骤更清晰,我先来装一个 laravel-debugger

composer require barryvdh/laravel-debugbar --dev

别忘了在 .env 中添加配置项以启用

DEBUGBAR_ENABLED=true

嗷,现在你访问接口应该不会看到 laravel-debugger,这是因为他不会对 JSON 响应生效,如果想要看到,可以随便访问一个 404 页面,然后就能看到那个熟悉的 icon 了,然后点击 icon,再点击右上角的从右至左第三个图标(打开文件),选择其他页面的请求。

image.png

现在,把我们的测试代码改成下面这样

Route::get('/', function () {
    $comments = \App\Models\Comment::limit(1)->get();
    $comment = $comments->first();

    return $comment->commentable;
});

然后你就得到了结果

{
    "id": 5,
    "commentable_type": "1",
    "commentable_id": 5,
    "body": "reprehenderit",
    "created_at": "2024-11-29T15:48:28.000000Z",
    "updated_at": "2024-11-29T15:48:28.000000Z"
}

是的,你没有看错……,这里明明返回的是 commentable,也就是原本应该返回 Post 或者 Video 的,这里却貌似返回了 comments.id = 5 的数据,有些离谱吧。。。

 title=

还记得我们前面提到的 morphTo 方法吗?这里也会走到的喔,他会使用 getAttributeFromArray 来获取 $attributes 属性上的字段来判断(在这里理论上是数据库中的字段),如果获取到了,他会调用 morphInstanceTo,反之他则认为这是一个来自 with的调用,继而调用 morphEagerTo

而我们这里的 commentable_type_class 其实是虚构而来的,它并不存在于数据库中的真实字段,自然,这里获取出来的就成了空,进入了预加载的逻辑,而实际上我们其实是在直接访问未预加载的数据,就导致了这个错误。

如何修复这个问题?既然,我们需要给数据库返回的结果添加一个字段,那在 select 的时候,处理一下不就好了吗?

Route::get('/', function () {
    $comments = \App\Models\Comment::select(['*', DB::raw("CASE
        WHEN commentable_type = 1 THEN '\\App\\Models\\Post'
        WHEN commentable_type = 2 THEN '\\App\\Models\\Video'
        ELSE commentable_type
    END AS commentable_type_class")])->limit(1)->get();
    $comment = $comments->first();

    return $comment->commentable;
});

确实,我们通过这样解决了前面的 bug,实现我们的需求,只是代码看起来有亿点点糟糕 🤔

什么?还有高手!

最终方案

其实参考前面的内容不难发现,我们前面都完成了,只是,在最后一部,需要向 $attributes 添加字段这里卡住了,那么,有没有什么方法,可以让我们在 $attributes 上添加字段呢,答案是有的。

你可能会想到,如果只是往上面添加一个字段,那直接覆盖模型的 boot 方法然后给 $attributes 追加一个不就好了。

但是,那还是可能会影响到上面的 morphTo 方法,所以,我们的最终目的是,要找到一个合适的时机去添加,并且还要尽可能的考虑可能带来的其他问题。

而 Laravel 的模型中恰好有这一类不同时机调用的方法,那就是模型事件。

选择一个合适的模型事件

打开 Laravel 文档,可以看到模型有以下事件。

事件名称触发时机
retrieved当模型实例从数据库中检索出来时触发。
creating在保存模型之前触发(创建新记录)。
created在模型成功保存到数据库后触发(新记录)。
updating在更新现有模型时,数据被提交到数据库之前触发。
updated在模型成功更新后触发。
saving在保存模型之前触发(包括创建和更新)。
saved在模型成功保存后触发(包括创建和更新)。
deleting在删除模型之前触发。
deleted在模型成功删除后触发。
trashed当模型被软删除(即 delete())时触发。
forceDeleting在强制删除(即 forceDelete())之前触发(跳过软删除)。
forceDeleted在强制删除后触发(跳过软删除)。
restoring在恢复软删除的模型时触发。
restored在恢复软删除的模型成功后触发。
replicating在复制模型实例(使用 replicate())时触发。

看到以上表格,你应该一眼就看到了 retrieved 事件,是的,没错,我们就需要在模型从数据库中被检索出来的时候去处理,现在改写我们的 Comment 模型代码。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    use HasFactory;

    protected static function booted()
    {
        parent::booted();

        self::retrieved(function (Comment $comment) {
            $comment->attributes['commentable_type_class'] = match ((int) $comment->commentable_type) {
                1 => Post::class,
                2 => Video::class,
                default => $comment->commentable_type,
            };

            $comment->makeHidden(['commentable_type_class']);
        });
    }


    public function commentable(): MorphTo
    {
        return $this->morphTo('commentable', 'commentable_type_class', 'commentable_id', 'id');
    }

}

首先,先覆盖掉 booted 方法,并调用 parent::booted(),然后开始我们的逻辑。

retrieved 时,我们修改传入的 Comment 模型,向其 $attributes 上添加一个 comment_type_class

与此同时,因为我们并不希望这个字段返回到我们的响应中,所以我们使用了 makeHidden,将其隐藏。

现在还有一个小问题,就是,如果我们的 comment 设置了 $fillable$guarded 都是 [] 的时候,就会出问题。

SQLSTATE[HY000]: General error: 1 no such column: commentable_type_class (Connection: sqlite, SQL: update "comments" set "commentable_type_class" = App\Models\Post, "updated_at" = 2024-12-01 04:57:01 where "id" = 1)

这个问题也很好解决,查看上面的事件表格,你会发现,saving,会在创建和保存前执行,所以我们只需要在这里再 unset 掉刚刚添加的属性,就好了。

self::saving(function (Comment $comment) {
    unset($comment->attributes['commentable_type_class']);
});

至此,我们的目的已经达成,首先,我们没有破坏原本的 commentable_type、其次,我们也没有破坏 Laravel 内部任何东西,甚至,如果你一个模型中有多个多态,也可以如此炮制。

完整的 Comment 模型如下。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    use HasFactory;

    protected static function booted()
    {
        parent::booted();

        self::retrieved(function (Comment $comment) {
            $comment->attributes['commentable_type_class'] = match ((int) $comment->commentable_type) {
                1 => Post::class,
                2 => Video::class,
                default => $comment->commentable_type,
            };

            $comment->makeHidden(['commentable_type_class']);
        });

        self::saving(function (Comment $comment) {
            unset($comment->attributes['commentable_type_class']);
        });
    }


    public function commentable(): MorphTo
    {
        return $this->morphTo('commentable', 'commentable_type_class', 'commentable_id', 'id');
    }

}

结束

其实,这个实现还是有一些小问题,因为 Laravel 内部多态设计的原因,在以上的解决方案中,你将无法使用 hasMorph whereHasMorph 等相关方法,这说起来有一些复杂,所以,如果你需要用到这两个方法,那么很遗憾,这个方案并不适用于你,你应该考虑从新调整数据库的设计,以适配 Laravel 的多态关联。

对此,如果你的数据量不是很大,我还是很建议你尽早调整你的数据库设计,使其符合 Laravel 的规范要求,而不是像这样去处理它,毕竟这个方案,可能还存在一些我没有预料到的问题,但是我已经尽可能的去验证了。

注言

注1:这个方案应该适用于 Laravel 6.x(含)以后的版本,但我未作过多的测试

图注:你应该注意到了里面有很多的图片生成,这些都是使用的 Markdown 的 mermaid 扩展代码块来渲染的,这些调用栈都是使用的 PHPStorm 的 Navigate 下的 Call Hierarchy,然后导出文本,然后交给 ChatGPT 来生成对应的 mermaid 图形,提示词如下:

1、精简以下数据内容,比如过于重复的路径前缀
2、使用 markdown 支持的图形语法,选择合适的图形,并生成原始的 markdown 内容。

image.png

注2:、上面的数据库示例表结构,也是由 ChatGPT 创建的迁移模型来实现的,但是数据的 Seeder 是由 Laravel Idea 生成的。

Creative Commons 4.0 授权协议 (CC BY 4.0)

此作品采用 Creative Commons 署名 4.0 国际许可协议 进行许可。


唯一丶
23.1k 声望8.7k 粉丝

友情链接