1. 前言

像多态一对多的这种关系,在开发过程中很容易遇到。假设我们会发表文章和视频两种类型的作品,用户可以对它们进行评论,在 Laravel 中可以这样实现:

表结构

posts
    id - integer
    title - string
    body - text

videos
    id - integer
    title - string
    url - string

comments
    id - integer
    body - text
    commentable_id - integer
    commentable_type - string

模型结构

<?php

namespace App\Models;

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

class Comment extends Model
{
    /**
     * Get the parent commentable model (post or video).
     */
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    /**
     * Get all of the post's comments.
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Video extends Model
{
    /**
     * Get all of the video's comments.
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

如果我们采用下面这样文章和视频分开的方式,优点是很容易获取 commentable 对象,缺点也显而易见,代码繁琐,每个模型都要实现一次,如果后面增加其他类型的可评论作品,工作量会更大:

Route::post('posts/{post}/comments', ...);
Route::delete('posts/{post}/comments/{comment}', ...);

Route::post('videos/{video}/comments', ...);
Route::delete('videos/{video}/comments/{comment}', ...);

另一种实现方式如下:

Route::post('comments', ...);
Route::delete('comments/{comment}', ''');

commentable 的传参不放在路由中,转而使用表单参数 commentable_typecommentable_id 实现,这样的好处是只需要在一个地方统一处理评论。但是出现了一个问题:如何在表单验证中对提交的 commentable 信息进行有效验证。

2. 解决方案

为了可复用性,我们使用自定义验证规则 PolyExists 来实现:

PolyExists.php

<?php

namespace App\Rules;

use App\Models\Post;
use App\Models\Video;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

class PolyExists implements DataAwareRule, ValidationRule
{
    protected array $data = [];
    protected array $allowedClasses = [];

    public function __construct(array $allowedClasses)
    {
        $this->allowedClasses = $allowedClasses;
    }

    /**
     * Run the validation rule.
     *
     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $polyType = Str::replace('_id', '_type', $attribute);
        $objectType = Arr::get($this->data, $polyType, false);

        // 如果模型类不是允许的类,或者数据库中找不到对应的对象实例,那么验证不通过
        if (! in_array($objectType, $this->allowedClasses) || ! resolve($objectType)->where('id', $value)->exists()) {
            $fail('validation.unprocessable')->translate();
        }
    }

    public function setData(array $data)
    {
        $this->data = $data;

        return $this;
    }
}

使用方式如下:

<?php

namespace App\Http\Requests\Comment;

use App\Rules\PolyExists;
use Illuminate\Foundation\Http\FormRequest;

class CommentRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'commentable_id' => ['required', 'integer', new PolyExists([Post::class, Video::class])],
        ];
    }

    protected function passedValidation()
    {
        // 验证通过后,在请求中注入 commentable 对象实例,方便在控制器或其他地方使用
        $commentable = resolve($this->commentable_type)::where('id', $this->commentable_id)->first();
        $this->merge(['commentable' => $commentable]);
    }
}