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)