Implementing a Blocking Feature in Laravel (using Morph table and Morph endpoint!)
Tags: laravel, backend, morphs, traits
Published: 2024-05-23 18:29:54


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ccb3omtb406bz9m4lgvw.jpg)

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:
```php
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']);
});
```


Then, I created the `Block` model:
```php
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();
    }
}
```


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`.
```php
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();
    }
}
```


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.
```php
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);
    }
}
```


Also, here's `BlockFactory` class to create blocks for testing purposes:
```php
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(),
        ];
    }
```

---

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

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

```php
Route::post('/blocks', BlocksController::class);
```

`BlocksController` provides a simple endpoint for blocking or unblocking users or posts.
```php
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,
        ]);
    }
}
```

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

```php
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.');
                    }
                }
            },
        ];
    }
}
```

  
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.