Реалізація Presence-індикатора (хто зараз онлайн) на веб-сайті
Зелена точка рядом з аватаром — простий елемент зовні, але за ним стоїть конкретна інфраструктурна задача: сервер повинен знати, хто підключений прямо зараз, та сповіщати інших користувачів при зміні цього стану. Застосування: чат підтримки, профілі користувачів, списки учасників курсу, спільне редагування.
Як визначається «онлайн»
Три підходи, різні за точністю:
Heartbeat через WebSocket/SSE — найточніший. Поки з'єднання активне, користувач онлайн. При розриві — подія disconnect. Затримка визначення офлайн: секунди.
Heartbeat через HTTP — клієнт відправляє POST /api/presence/ping кожні 30 секунд. Якщо пінга не було більше 60 секунд — користувач вважається офлайном. Затримка визначення: до 60 секунд. Простіше у реалізації, не потребує постійного з'єднання.
Last seen — м'який варіант. Не «онлайн/офлайн», а «був 3 хвилини тому». Оновлюється при будь-якому запиту до API. Корисно для приватності (користувач сам вибирає показувати ли точний статус).
Для більшості вебсайтів достатньо heartbeat через HTTP — не потрібно утримувати WebSocket-з'єднання ради однієї точки.
Redis-сховище присутності
Присутність зберігається в Redis, не в PostgreSQL. Причина: часті записи (кожні 30 секунд на користувача), TTL-логіка, немає смислу в персистентності.
class PresenceService
{
private const TTL = 90; // секунди без пінга = офлайн
public function markOnline(int $userId, string $context = 'global'): void
{
Redis::setex("presence:{$context}:{$userId}", self::TTL, now()->timestamp);
// Сповістити канал, якщо це перша появ
$wasOnline = Redis::exists("presence_flag:{$context}:{$userId}");
if (!$wasOnline) {
Redis::setex("presence_flag:{$context}:{$userId}", self::TTL + 10, 1);
broadcast(new UserCameOnline($userId, $context));
}
}
public function markOffline(int $userId, string $context = 'global'): void
{
Redis::del("presence:{$context}:{$userId}");
Redis::del("presence_flag:{$context}:{$userId}");
broadcast(new UserWentOffline($userId, $context));
}
public function getOnlineUsers(string $context = 'global'): array
{
$keys = Redis::keys("presence:{$context}:*");
return array_map(fn($k) => (int) last(explode(':', $k)), $keys);
}
public function isOnline(int $userId, string $context = 'global'): bool
{
return (bool) Redis::exists("presence:{$context}:{$userId}");
}
}
Параметр $context дозволяє розділити присутність по розділам: chat_room:42, course:17, global.
Heartbeat-ендпоінт
Route::middleware('auth:sanctum')->post('/api/presence/ping', function (Request $request) {
app(PresenceService::class)->markOnline(
$request->user()->id,
$request->input('context', 'global')
);
return response()->json(['ok' => true]);
});
Клієнт викликає цей ендпоінт при завантаженні сторінки та далі кожні 30 секунд:
const ping = () => fetch('/api/presence/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ context: 'global' }),
});
ping();
const interval = setInterval(ping, 30_000);
// Очистка при закритті вкладки
window.addEventListener('beforeunload', () => {
clearInterval(interval);
navigator.sendBeacon('/api/presence/offline'); // fire-and-forget
});
navigator.sendBeacon — єдиний надійний спосіб відправити запит при закритті вкладки. Звичайний fetch в beforeunload браузер може перервати.
Broadcast подій
class UserCameOnline implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly int $userId,
public readonly string $context,
) {}
public function broadcastOn(): Channel
{
return new Channel("presence.{$this->context}");
}
}
Індикатор в інтерфейсі
// Початковий стан завантажується разом зі сторінкою
const onlineUsers = new Set(initialOnlineUserIds);
function updateDot(userId, isOnline) {
const dot = document.querySelector(`[data-user-id="${userId}"] .presence-dot`);
if (!dot) return;
dot.classList.toggle('bg-green-500', isOnline);
dot.classList.toggle('bg-gray-300', !isOnline);
dot.title = isOnline ? 'Онлайн' : 'Офлайн';
}
Echo.channel('presence.global')
.listen('UserCameOnline', ({ userId }) => {
onlineUsers.add(userId);
updateDot(userId, true);
})
.listen('UserWentOffline', ({ userId }) => {
onlineUsers.delete(userId);
updateDot(userId, false);
});
Присутність через Laravel Presence Channels
Якщо використовується Laravel Echo + Pusher/Reverb, можна використовувати вбудований механізм Presence Channels — він автоматично управляє списком підключених користувачів:
Echo.join('room.42')
.here((users) => { /* початковий список */ })
.joining((user) => updateDot(user.id, true))
.leaving((user) => updateDot(user.id, false));
Backend просто авторизує канал та повертає дані користувача:
Broadcast::channel('room.{roomId}', function (User $user, int $roomId) {
if ($user->canAccessRoom($roomId)) {
return ['id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar_url];
}
});
Терміни
- Heartbeat-пінг + Redis TTL + індикатор: 1–2 дні
- Broadcast при зміні статусу (UserCameOnline / Offline): 1 день
- Presence Channels через Laravel Echo: 1 день
- Last seen замість онлайн/офлайн: 0.5 дня
- Налаштування приватності (приховати статус): +0.5 дня







