Реализация 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));
Бэкенд просто авторизует канал и возвращает данные пользователя:
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 дня







