Implementing a Blocking Feature in Laravel (using Morph table and Morph endpoint!)
Mohamed Idris
Posted on May 23, 2024
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.
Posted on May 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
May 23, 2024