Поддержка лайков/дислайков для любых сущностей (полиморфизм).
Высокая производительность записи и чтения.
Защита от дублирования (один пользователь — одно действие на сущность).
Масштабируемость под рост нагрузки.
Простота добавления новых типов сущностей.
На примере классики в Ларке, но может быть и другая структура, так как один проект = одна структура.
app/
├── Models/
│ └── Like.php # Eloquent модель
├── Http/
│ ├── Controllers/
│ │ └── LikeController.php # API эндпоинты
│ └── Requests/
│ └── StoreLikeRequest.php # валидация
├── Services/
│ └── LikeService.php # бизнес-логика
database/
└── migrations/
└── create_likes_table.php # полиморфная таблица
routes/
└── api.php # POST /likes, GET /likes/stats
resources/js/ # frontend (если SSR не нужен)
Backend - PHP 8.2+ (FPM или Swoole для высокой нагрузки)
Фреймворк - Laravel, Symfony, или чистый PHP с PSR (минимализм)
База данных - PostgreSQL или MySQL 8+
Кэш / рейт-лимит - Redis
Frontend- Vanilla JS или React/Vue (только для UI)
Веб-сервер - Nginx + PHP-FPM
Деплой- Docker + Kubernetes / обычные VPS
-- Полиморфная таблица "target"
CREATE TABLE likes (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
target_type VARCHAR(50) NOT NULL, -- 'post', 'comment', 'product'
target_id UUID NOT NULL, -- ID сущности в её таблице
action SMALLINT NOT NULL CHECK (action IN (1, -1)), -- 1 = like, -1 = dislike
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, target_type, target_id)
);
-- Индексы
CREATE INDEX idx_likes_target ON likes (target_type, target_id);
CREATE INDEX idx_likes_user ON likes (user_id);Или через миграцию
Schema::create('likes', function (Blueprint $table) {
$table->id();
$table->uuid('user_id');
$table->morphs('likeable'); // likeable_type, likeable_id
$table->tinyInteger('action')->comment('1 = like, -1 = dislike');
$table->timestamps();
$table->unique(['user_id', 'likeable_type', 'likeable_id']);
});# голосование
POST /api/v1/likes
{
"user_id": "usr-123",
"target_type": "post",
"target_id": "post-456",
"action": 1 // или -1(dislike)
}
# Пример, без ЧПУ
GET /api/v1/likes/stats?target_type=post&target_id=post-456
→ { "likes": 1200, "dislikes": 300, "total": 900 }
// routes/api.php
Route::post('/api/v1/likes', [LikeController::class, 'store']);
Route::get('/api/v1/likes/stats', [LikeController::class, 'stats']);public function store(StoreLikeRequest $request)
{
return app(LikeService::class)->toggle(
userId: $request->user_id,
targetType: $request->target_type,
targetId: $request->target_id,
action: $request->action
);
}async function toggleLike(targetType, targetId, action) {
const res = await fetch('/api/v1/likes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: currentUser.id,
csrf: @csrf
target_type: targetType,
target_id: targetId,
action: action // 1 or -1
})
});
const stats = await res.json();
updateUI(stats); // обновить кнопки и счётчики
}AddLike(user_id, target_type, target_id, action)
Валидация: action ∈ {1, -1}, target_type разрешён.
Проверка дубля: через UNIQUE в БД → ON CONFLICT DO UPDATE.
Запись в БД:
INSERT INTO likes (user_id, target_type, target_id, action)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, target_type, target_id)
DO UPDATE SET action = EXCLUDED.action, updated_at = NOW();Инвалидация кэша в Redis: DEL like:stats:post:post-456.
(Опционально) отправить событие в Kafka: like.created.GetStats(target_type, target_id)
class LikeService
{
public function toggle(string $userId, string $targetType, string $targetId, int $action): array
{
// 1. Преобразуем тип в класс модели (например, 'post' → Post::class)
$modelClass = $this->resolveModel($targetType);
// 2. Используем upsert (Laravel 8+)
Like::updateOrCreate(
[
'user_id' => $userId,
'likeable_type' => $modelClass,
'likeable_id' => $targetId,
],
['action' => $action]
);
// 3. Инвалидируем кэш
Redis::del("like:stats:{$targetType}:{$targetId}");
// 4. (Опционально) отправляем событие
event(new LikeUpdated($targetType, $targetId));
return $this->getStats($targetType, $targetId);
}
public function getStats(string $targetType, string $targetId): array
{
$cacheKey = "like:stats:{$targetType}:{$targetId}";
return Cache::remember($cacheKey, 300, function () use ($targetType, $targetId) {
$modelClass = $this->resolveModel($targetType);
$likes = Like::where('likeable_type', $modelClass)
->where('likeable_id', $targetId)
->selectRaw('SUM(CASE WHEN action = 1 THEN 1 ELSE 0 END) as likes')
->selectRaw('SUM(CASE WHEN action = -1 THEN 1 ELSE 0 END) as dislikes')
->first();
return [
'likes' => (int) $likes->likes,
'dislikes' => (int) $likes->dislikes,
'total' => (int) $likes->likes - (int) $likes->dislikes,
];
});
}
}Rate-limit через middleware: RateLimiter::for('likes', fn($job) => Limit::perMinute(10)->by($job->user_id));
Уникальный индекс в БД + updateOrCreate
Кэширование в Redis на 5 минут
Использовать OPcache, PHP 8.2+, connection pooling (pgBouncer) PHP-FPM создаёт процесс на каждый запрос → выше overhead, чем Go. Нужно больше RAM при высокой нагрузке. Для >10 млн/день может потребоваться переход на Swoole или микросервис на другом языке.
Избегать SELECT FOR UPDATE — использовать INSERT ... ON DUPLICATE KEY UPDATE (MySQL) или ON CONFLICT (PostgreSQL)
Горизонтальное: несколько PHP-FPM воркеров + балансировщик (Nginx).
Кэш: Redis на отдельном сервере или кластере.
БД: реплика только для чтения (если нужны аналитические отчёты).
Очереди: если нужно уведомлять — используйте Laravel Queues с Redis или DB драйвером.
При 1 млн/день (~12 RPS) даже один сервер (4 ядра, 8 ГБ RAM) справится, если:
Есть Redis кэш
Есть уникальный индекс
Нет N+1 запросов
Laravel — он идеально подходит для этой задачи.
Обязательно подключите Redis.
Настройте мониторинг (Laravel Telescope, Prometheus, New Relic).
Тестируйте нагрузку через k6 или Artillery.
блаблабла...