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