DEV Community

Mohamed Idris
Mohamed Idris

Posted on • Updated on

Implementing a Blocking Feature in Laravel (using Morph table and Morph endpoint!)

Image description

I'm building an app where users can make posts. Recently, I was tasked with adding a blocking feature, allowing users to block other users or posts.

My senior suggested using a morph table since many things could be blocked. Here's the schema I created for the blocks table:

Schema::create('blocks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->morphs('blockable');
    $table->timestamps();

    $table->unique(['user_id', 'blockable_id', 'blockable_type']);
});
Enter fullscreen mode Exit fullscreen mode

Then, I created the Block model:

class Block extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'blockable_id',
        'blockable_type',
    ];

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

    public function blockable(): MorphTo
    {
        return $this->morphTo();
    }
}
Enter fullscreen mode Exit fullscreen mode

Additionally, I learned a valuable tip from an instructor to avoid repeating inverse relationships in each model (like User and Post). Instead, I created a trait named IsBlockable, which adds blocking features to models like User and Post.

trait IsBlockable
{
    /**
     * Get all users who have blocked this model.
     */
    public function blockedBy()
    {
        return $this->morphToMany(User::class, 'blockable', 'blocks', 'blockable_id', 'user_id');
    }

    /**
     * Check if the current user has blocked the owner of this model.
     *
     * @return bool
     */
    public function isCurrentUserBlockedByOwner()
    {
        $currentUser = User::currentUser();

        return $currentUser && ($this instanceof User ?
                $this->isInstanceBlocked($currentUser) :
                $this->user?->isInstanceBlocked($currentUser)
            );
    }

    /**
     * Get the IDs of models blocked by the current user.
     *
     * @param  \Illuminate\Database\Eloquent\Model|null  $instance
     * @return \Closure
     */
    public function blockedModelIds($instance = null)
    {
        $instance = $instance ?: $this;

        return function ($query) use ($instance) {
            $query->select('blockable_id')
                ->from('blocks')
                ->where('blockable_type', $instance->getMorphClass())
                ->where('user_id', User::currentUser()?->id);
        };
    }

    /**
     * Get the IDs of model owners who blocked the current user.
     *
     * @param  \Illuminate\Database\Eloquent\Model|null  $user
     * @return \Closure
     */
    public function blockedByOwnersIds($user = null)
    {
        $user = $user ?: User::currentUser();

        return function ($query) use ($user) {
            $query->select('user_id')
                ->from('blocks')
                ->where('blockable_type', $user?->getMorphClass())
                ->where('blockable_id', $user?->id);
        };
    }

    /**
     * Scope a query to exclude models blocked by the current user or created by users blocked by that user,
     * or created by users who have blocked the current user.
     *
     * @param  \Illuminate\Database\Eloquent\Builder  $query
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeVisibleToCurrentUser($query)
    {
        $blockedModelIds = $this->blockedModelIds();
        $blockedUserIds = $this->blockedModelIds(User::currentUser());

        return $query->whereNotIn('id', $blockedModelIds)
            ->whereNotIn('user_id', $blockedUserIds)
            ->whereNotIn('user_id', $this->blockedByOwnersIds());
    }

    /**
     * Check if the model is blocked by a specific user.
     *
     * @param  int  $userId
     * @return bool
     */
    public function isBlockedBy($userId)
    {
        return $this->blockedBy()->where('user_id', $userId)->exists();
    }

    /**
     * Determine if the model is currently blocked by any user.
     *
     * @return bool
     */
    public function isBlocked()
    {
        return $this->blockedBy()->exists();
    }

    /**
     * Get the count of users who have blocked this model.
     *
     * @return int
     */
    public function blockedByCount()
    {
        return $this->blockedBy()->count();
    }

    /**
     * Get the latest user who blocked this model.
     *
     * @return \App\Models\User|null
     */
    public function latestBlockedBy()
    {
        return $this->blockedBy()->latest()->first();
    }
}
Enter fullscreen mode Exit fullscreen mode

To further simplify block management and enhance reusability, I created another trait named BlockManager, specifically used on the model responsible for blocking actions, which in my case is the User model. So, the User model now utilizes both traits.

trait BlockManager
{
    /**
     * Get all the blocks created by this user.
     */
    public function blocks()
    {
        return $this->hasMany(Block::class);
    }

    /**
     * Get all the entities of a given class that this user has blocked.
     */
    public function blockedEntities($class)
    {
        return $this->morphedByMany($class, 'blockable', 'blocks', 'user_id', 'blockable_id')
            ->withTimestamps();
    }

    /**
     * Check if the given instance is blocked by the current user.
     *
     * @param  \Illuminate\Database\Eloquent\Model|null  $instance
     * @return bool
     */
    public function isInstanceBlocked($instance)
    {
        return $instance && $this->blockedEntities(get_class($instance))
            ->where('blockable_id', $instance->id)
            ->exists();
    }

    /**
     * Get all the users that this user has blocked.
     *
     * @return \Illuminate\Database\Eloquent\Relations\MorphToMany
     */
    public function blockedUsers()
    {
        return $this->blockedEntities(\App\Models\User::class);
    }

    /**
     * Get all the posts that this user has blocked.
     *
     * @return \Illuminate\Database\Eloquent\Relations\MorphToMany
     */
    public function blockedPosts()
    {
        return $this->blockedEntities(\App\Models\Post::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Also, here's BlockFactory class to create blocks for testing purposes:

class BlockFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Block::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $blockableType = $this->faker->randomElement([User::class, Post::class]);

        do {
            $blockable = $blockableType::inRandomOrder()->first();
            $user = User::inRandomOrder()->first();

            if ($blockableType === User::class && $blockable) {
                $user = User::inRandomOrder()->whereKeyNot($blockable->id)->first();
            }

            $exists = Block::where('user_id', $user?->getKey())
                ->where('blockable_id', $blockable?->getKey())
                ->where('blockable_type', $blockable?->getMorphClass())
                ->exists();
        } while ($exists || ! $user || ! $blockable);

        return [
            'user_id' => $user->getKey(),
            'blockable_id' => $blockable->getKey(),
            'blockable_type' => $blockable->getMorphClass(),
        ];
    }
Enter fullscreen mode Exit fullscreen mode

Handling Block Requests (Taking the morph idea to the next level)

To handle blocking requests, I set up a a singleton controller:

Route::post('/blocks', BlocksController::class);
Enter fullscreen mode Exit fullscreen mode

BlocksController provides a simple endpoint for blocking or unblocking users or posts.

class BlocksController extends ApiController
{
    /**
     * Block or unblock a resource.
     *
     * This endpoint blocks or unblocks a specified resource, such as a user or a post.
     *
     * Query Parameters:
     * - action: The action to perform. Accepted values: "block", "unblock"
     * - model_type: What type of resource are you blocking or unblocking? Accepted values: "user", "post"
     * - model_id: The ID of the resource to block or unblock
     */
    public function __invoke(BlockRequest $request)
    {
        $validated = $request->validated();
        $action = strtolower($validated['action']);
        $isBlockAction = $action === 'block';
        $modelType = ucfirst(strtolower($validated['model_type']));
        $modelId = $validated['model_id'];
        $modelClass = 'App\\Models\\'.$modelType;

        if ($modelClass === User::class && $modelId == $this->user?->id) {
            return response()->json(['message' => 'You cannot block yourself.'], 400);
        }

        $isAlreadyBlocked = $this->user->blockedEntities($modelClass)->where('blockable_id', $modelId)->exists();

        if ($isBlockAction && ! $isAlreadyBlocked) {
            $this->user->blockedEntities($modelClass)->syncWithoutDetaching([$modelId]);
        } elseif (! $isBlockAction && $isAlreadyBlocked) {
            $detached = $this->user->blockedEntities($modelClass)->detach($modelId);

            if (! $detached) {
                return response()->json(
                    ['error' => "Failed to unblock the {$modelType}."],
                    Response::HTTP_INTERNAL_SERVER_ERROR
                );
            }
        }

        return response()->json([
            'message' => "Model {$action}ed successfully.",
            'blocked' => $isBlockAction,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

I also created a validation class called BlockRequest to ensure the integrity of incoming block requests.

class BlockRequest 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 [
            'action' => ['required', 'string'],
            'model_type' => ['required', 'string'],
            'model_id' => ['required', 'integer'],
        ];
    }

    /**
     * Get the "after" validation callables for the request.
     */
    public function after(): array
    {
        return [
            function (Validator $validator) {
                $action = strtolower($this->input('action') ?? '');
                $modelType = ucfirst(strtolower($this->input('model_type') ?? ''));
                $modelId = $this->input('model_id');

                if ($action && ! in_array($action, ['block', 'unblock'])) {
                    return $validator->errors()->add('action', 'Invalid action.');
                }

                if ($modelType && $modelId) {
                    $modelClass = 'App\\Models\\'.$modelType;

                    if (! class_exists($modelClass)) {
                        return $validator->errors()->add('model_type', 'Invalid model type.');
                    }

                    // Check if the model is blockable by checking if it uses IsBlockable trait
                    if (! in_array(IsBlockable::class, class_uses($modelClass), true)) {
                        return $validator->errors()->add('model_type', 'The specified model type is not blockable.');
                    }

                    // Check if the specified model instance exists
                    if (! $modelClass::find($modelId)) {
                        return $validator->errors()->add('model_id', 'The specified model ID does not exist.');
                    }
                }
            },
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

In summary, I implemented a blocking feature that allows users to block other entities effortlessly. Using a morph table, traits like IsBlockable and BlockManager, and morph request validation, the code's modularity and reusability are optimized for future development

Special thanks to my senior, Stack Overflow, and ChatGPT.

Alhamdullah.

Top comments (0)