Likes and Ratings System Implementation
Likes and ratings are user reactions to content. Like — binary reaction, rating — scale (1–5 stars). Technical tasks: atomicity with concurrent requests, prevent duplication, cache counter.
Database Structure
-- Universal likes table (polymorphic)
CREATE TABLE likes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
likeable_id INTEGER NOT NULL,
likeable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, likeable_id, likeable_type)
);
CREATE INDEX ON likes(likeable_type, likeable_id);
-- Ratings
CREATE TABLE ratings (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ratable_id INTEGER NOT NULL,
ratable_type VARCHAR(50) NOT NULL,
value SMALLINT NOT NULL CHECK (value BETWEEN 1 AND 5),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, ratable_id, ratable_type)
);
-- Counters in main tables (denormalization)
ALTER TABLE articles ADD COLUMN likes_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN ratings_count INTEGER NOT NULL DEFAULT 0;
Laravel: Likes
trait Likeable
{
public function likes(): MorphMany
{
return $this->morphMany(Like::class, 'likeable');
}
public function isLikedBy(?User $user): bool
{
if (!$user) return false;
return Cache::remember(
"liked:{$this->getMorphClass()}:{$this->id}:{$user->id}",
300,
fn() => $this->likes()->where('user_id', $user->id)->exists()
);
}
}
class LikeController extends Controller
{
public function toggle(Request $request, string $type, int $id): JsonResponse
{
$model = $this->resolveModel($type, $id);
$user = $request->user();
$existing = Like::where([
'user_id' => $user->id,
'likeable_type' => $type,
'likeable_id' => $id,
])->first();
if ($existing) {
$existing->delete();
$model->decrement('likes_count');
$liked = false;
} else {
Like::create([
'user_id' => $user->id,
'likeable_type' => $type,
'likeable_id' => $id,
]);
$model->increment('likes_count');
$liked = true;
}
Cache::forget("liked:{$type}:{$id}:{$user->id}");
return response()->json([
'liked' => $liked,
'count' => $model->fresh()->likes_count,
]);
}
}
Ratings (Stars)
class RatingController extends Controller
{
public function store(Request $request, string $type, int $id): JsonResponse
{
$request->validate(['value' => 'required|integer|between:1,5']);
$model = $this->resolveModel($type, $id);
Rating::updateOrCreate(
[
'user_id' => $request->user()->id,
'ratable_type' => $type,
'ratable_id' => $id,
],
['value' => $request->value]
);
$stats = Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
->selectRaw('AVG(value) as avg, COUNT(*) as cnt')
->first();
$model->update([
'rating_avg' => round($stats->avg, 2),
'ratings_count' => $stats->cnt,
]);
return response()->json([
'user_rating' => $request->value,
'avg' => round($stats->avg, 1),
'count' => $stats->cnt,
'distribution' => Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
->groupBy('value')
->selectRaw('value, COUNT(*) as count')
->pluck('count', 'value'),
]);
}
}
React: UI Components
function LikeButton({ type, id, initialCount, initialLiked }: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [loading, setLoading] = useState(false);
const toggle = async () => {
if (loading) return;
setLoading(true);
setLiked(!liked);
setCount(c => liked ? c - 1 : c + 1);
try {
const { data } = await api.post(`/api/likes/${type}/${id}/toggle`);
setLiked(data.liked);
setCount(data.count);
} catch {
setLiked(liked);
setCount(count);
} finally {
setLoading(false);
}
};
return (
<button
onClick={toggle}
className={`like-btn ${liked ? 'like-btn--active' : ''}`}
aria-label={liked ? 'Unlike' : 'Like'}
aria-pressed={liked}
>
<HeartIcon filled={liked} />
<span>{count.toLocaleString('en-US')}</span>
</button>
);
}
function StarRating({ type, id, userRating, avgRating, ratingsCount }: StarRatingProps) {
const [hover, setHover] = useState(0);
const [selected, setSelected] = useState(userRating || 0);
const handleRate = async (value: number) => {
setSelected(value);
await api.post(`/api/ratings/${type}/${id}`, { value });
};
return (
<div className="star-rating">
<div className="stars" role="radiogroup" aria-label="Rating">
{[1, 2, 3, 4, 5].map(star => (
<button
key={star}
role="radio"
aria-checked={selected === star}
aria-label={`${star} stars`}
className={`star ${star <= (hover || selected) ? 'star--filled' : ''}`}
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
onClick={() => handleRate(star)}
>
★
</button>
))}
</div>
<span className="rating-summary">
{avgRating.toFixed(1)} ({ratingsCount.toLocaleString('en-US')} ratings)
</span>
</div>
);
}
Implementation Timeline
Likes system (polymorphic) with React UI and optimistic updates: 2–3 days. 1–5 star ratings with aggregates: +1–2 days.







