Skip to content
← К сниппетам
PHP

PHP на бэкенде: HTTP, безопасность, форматирование

Тринадцать PHP-сниппетов для серверной стороны: настоящий IP клиента за прокси, хеширование bcrypt, лимитер на Redis, повторы с экспоненциальной задержкой и джиттером, разбор BCP-47, очистка UTF-8 и маскирование токенов.

PHP по-прежнему обрабатывает заметную долю веб-запросов, и здесь собрано то, что находится ближе всего к самому запросу: кто его прислал, принимать ли его и как ответить, ничего не утекая.

IP клиента — единственное, чему нельзя верить

$_SERVER['REMOTE_ADDR'] содержит адрес того, кто установил соединение, — а за балансировщиком или CDN это и есть балансировщик. Обычное решение — читать X-Forwarded-For, и обычная ошибка — читать его безусловно: заголовок присылает клиент, поэтому атакующий подставит туда любой адрес и обойдёт все ваши лимиты и баны по IP. Заголовок имеет смысл, только когда соединение пришло от прокси, который контролируете вы, и нужное значение — последнее, дописанное вашей инфраструктурой, а не первое в списке.

Пароли, токены и вопрос времени

password_hash() с алгоритмом по умолчанию — правильный ответ для паролей, а password_verify() сам сравнивает за постоянное время. Ловушка в другом месте: сравнение API-ключей, подписей вебхуков и токенов сессий через == выдаёт их содержимое через тайминг, потому что сравнение строк выходит на первом же различающемся байте. Именно для этого существует hash_equals().

Маскирование токена для показа — вторая половина той же задачи: секреты чаще всего утекают через логи и экраны поддержки, а первых и последних символов достаточно, чтобы опознать ключ, не раскрывая его.

Лимиты и повторы — две стороны одного

Лимитер на Redis отвечает на вопрос «принимать ли этот запрос», а повтор с задержкой — на вопрос «что делать, когда чужой лимитер отказал мне». Пропускают обычно джиттер: без него все клиенты, упавшие в один момент, повторяют попытку тоже в один момент, и восстановление превращается во вторую аварию. Добавление случайности к задержке — и есть всё лечение.

Кодировки — место обитания невидимых багов

Невалидный UTF-8 о себе не сообщает. Он проходит через приложение незамеченным и всплывает ошибкой базы, сломанным JSON-ответом или белой страницей — как правило, далеко от места, где попал внутрь. Санитизация на границе дешевле последующего расследования.

Корректный разбор языкового тега BCP-47 важен по той же причине: en-GB, en_gb и en означают слегка разное, а наивное сравнение строк с захардкоженным списком тихо ломается для всех, кто в него не попал.

PHP

GET-запрос через cURL

Классический рецепт PHP для получения JSON от внешнего API без каких-либо дополнительных зависимостей.

Как это работает: curl_init открывает соединение с URL, CURLOPT_RETURNTRANSFER заставляет curl_exec вернуть тело ответа строкой вместо вывода на экран, а curl_getinfo после выполнения читает HTTP-статус код.

<?php

function httpGet(string $url, array $headers = []): array
{
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    $response = curl_exec($ch);
    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return ['status' => $statusCode, 'body' => json_decode($response, true)];
}

// Example usage
$result = httpGet('https://api.example.com/users/1', ['Accept: application/json']);
if ($result['status'] === 200) {
    echo $result['body']['name'];
}
PHP

Форматирование байтов в читаемый размер

Превращает количество байтов в "1.5 MB", "820 KB" и т.д. — стандартный хелпер для отображения размера файла.

Как это работает: log($bytes, 1024) определяет, в какую степень 1024 попадает количество байтов (0 = байты, 1 = КБ, 2 = МБ...), после чего значение делится на эту степень для получения отображаемого числа.

<?php

function formatBytes(int $bytes, int $precision = 2): string
{
    if ($bytes <= 0) {
        return '0 B';
    }

    $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
    $power = min((int) floor(log($bytes, 1024)), count($units) - 1);

    return round($bytes / (1024 ** $power), $precision) . ' ' . $units[$power];
}

// Example usage
formatBytes(1536);       // "1.5 KB"
formatBytes(1073741824); // "1 GB"
PHP

Получение реального IP клиента за прокси/CDN

Читает настоящий IP посетителя из заголовков CDN/прокси (Cloudflare, X-Forwarded-For и т.д.), с фолбэком на $_SERVER['REMOTE_ADDR'], и отбрасывает приватные/поддельные значения.

Как это работает: заголовки прокси/CDN проверяются в порядке доверия (сначала собственный заголовок Cloudflare, поскольку его нельзя подделать, если Cloudflare стоит перед приложением), и каждый кандидат проверяется как настоящий публичный IP, прежде чем ему довериться — приватные диапазоны (10.x, 192.168.x и т.д.) отбрасываются, чтобы клиент не мог подделать X-Forwarded-For: 10.0.0.1 и спрятаться за внутренним на вид адресом.

<?php

function getClientIp(): ?string
{
    $headers = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];

    foreach ($headers as $header) {
        $value = $_SERVER[$header] ?? null;
        if (!$value) {
            continue;
        }

        // X-Forwarded-For can contain a comma-separated chain; the first entry is the original client
        foreach (explode(',', $value) as $candidate) {
            $ip = trim($candidate);
            if (isPublicIp($ip)) {
                return $ip;
            }
        }
    }

    return $_SERVER['REMOTE_ADDR'] ?? null;
}

function isPublicIp(string $ip): bool
{
    return (bool) filter_var(
        $ip,
        FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
    );
}

// Example usage
$ip = getClientIp(); // "203.0.113.42" — never a spoofed private/loopback address
PHP

Хеширование паролей bcrypt (PHP)

Хеширует и проверяет пароли через нативный PHP password_hash с bcrypt (cost 12). Включает проверку необходимости повторного хеширования при изменении параметров.

Как это работает: password_hash с алгоритмом PASSWORD_BCRYPT и cost: 12 — хороший баланс безопасности и производительности (~250мс). password_needs_rehash позволяет постепенно обновлять старые хеши при логине без принудительной смены паролей.

<?php

const HASH_COST = 12;

function hashPassword(string $password): string
{
    return password_hash($password, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
}

function verifyPassword(string $password, string $hash): bool
{
    return password_verify($password, $hash);
}

function needsRehash(string $hash): bool
{
    return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
}

// Example usage — регистрация
$hash = hashPassword($_POST['password']);
// Сохрани $hash в БД

// Авторизация
$user = $db->findByEmail($email);

if (!verifyPassword($plainPassword, $user['password_hash'])) {
    throw new \Exception('Invalid credentials');
}

// Обновить хеш если изменились параметры (повышение cost)
if (needsRehash($user['password_hash'])) {
    $db->updatePasswordHash($user['id'], hashPassword($plainPassword));
}
PHP

Цветной аватар с инициалами (HTML/CSS)

Рендерит круглый аватар с инициалами пользователя и цветом, полученным из его имени — лёгкий фолбэк, когда нет фото профиля.

Как это работает: имя хэшируется в оттенок (0-359°), поэтому один и тот же человек всегда получает один и тот же цвет, а инициалы — это просто первая буква имени и фамилии.

<?php

function buildInitials(?string $firstName, ?string $lastName): string
{
    $first = trim((string) $firstName);
    $last = trim((string) $lastName);

    if ($first === '' && $last === '') {
        return 'U';
    }

    return mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1));
}

function avatarColor(string $name): string
{
    $hash = 0;
    for ($i = 0; $i < mb_strlen($name); $i++) {
        $hash = mb_ord(mb_substr($name, $i, 1)) + (($hash << 5) - $hash);
    }
    $hue = abs($hash) % 360;

    return "hsl({$hue}, 80%, 60%)";
}

function renderAvatar(?string $firstName, ?string $lastName, int $size = 36): string
{
    $initials = buildInitials($firstName, $lastName);
    $color = avatarColor(trim($firstName . $lastName) ?: 'user');

    return sprintf(
        '<div style="width:%1$dpx;height:%1$dpx;border-radius:9999px;background:%2$s;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600;">%3$s</div>',
        $size,
        $color,
        htmlspecialchars($initials)
    );
}

// Example usage
echo renderAvatar('Alex', 'Rel'); // circular "AR" badge, color derived from the name
PHP

Получение геолокации по IP (ipapi.co)

Определяет город и страну посетителя по его IP-адресу через бесплатный API ipapi.co.

Как это работает: GET-запрос к ipapi.co/{ip}/json/ возвращает данные геолокации в формате JSON. Если оставить $ip пустым, API само определит IP вызывающей стороны — удобно для определения текущего посетителя на сервере.

<?php

function getIpInfo(string $ip = ''): array
{
    $url = 'https://ipapi.co/' . $ip . '/json/';
    $response = file_get_contents($url);

    return json_decode($response, true) ?? [];
}

// Example usage
$info = getIpInfo(); // empty $ip -> detects the server's own IP
echo $info['city'] . ', ' . $info['country_name'];

$info = getIpInfo('8.8.8.8'); // or look up a specific IP
echo $info['timezone'];
PHP

Маскирование токена/API-ключа для безопасного отображения

Показывает только первые и последние 4 символа секрета (например, API-ключа), чтобы его можно было безопасно логировать или отображать в интерфейсе.

Как это работает: сохраняются первые и последние 4 символа, а всё остальное заменяется звёздочками; очень короткие секреты (8 символов и меньше) маскируются полностью, чтобы не утекло ничего значимого.

<?php

function getTokenPreview(?string $token): ?string
{
    if (empty($token)) {
        return null;
    }

    $length = strlen($token);

    if ($length <= 8) {
        return str_repeat('*', $length);
    }

    return substr($token, 0, 4) . str_repeat('*', 4) . substr($token, -4);
}

// Example usage
getTokenPreview('sk_live_51Hh2eZC9x8f3JvQwErTyUi'); // "sk_l****EiUi"
getTokenPreview('short'); // "*****"
PHP

Разбор языкового тега BCP-47

Приводит языковые коды вроде en-GB, pt_BR или sr_RS (как их присылают браузеры, Telegram и т.д.) к паре язык + регион.

Как это работает: нижние подчёркивания приводятся к дефисам, тег разбивается на части, первая часть — код языка, а любая часть из 2 букв или 3 цифр после неё считается регионом.

<?php

function parseLanguageTag(?string $tag): array
{
    if (empty($tag)) {
        return ['language' => null, 'region' => null, 'canonical' => null];
    }

    $parts = array_values(array_filter(explode('-', str_replace('_', '-', trim($tag)))));
    if (empty($parts)) {
        return ['language' => null, 'region' => null, 'canonical' => null];
    }

    $language = strtolower($parts[0]);

    $region = null;
    foreach (array_slice($parts, 1) as $part) {
        if (preg_match('/^[A-Za-z]{2}$/', $part)) {
            $region = strtoupper($part);
            break;
        }
        if (preg_match('/^\d{3}$/', $part)) {
            $region = $part;
            break;
        }
    }

    return [
        'language' => $language,
        'region' => $region,
        'canonical' => $region ? "{$language}-{$region}" : $language,
    ];
}

// Example usage
parseLanguageTag('en-GB'); // ['language' => 'en', 'region' => 'GB', 'canonical' => 'en-GB']
parseLanguageTag('pt_BR'); // ['language' => 'pt', 'region' => 'BR', 'canonical' => 'pt-BR']
PHP

Rate Limiting через Redis (PHP)

Простой rate limiter на Redis — ограничивает количество запросов от пользователя за скользящее временное окно. Атомарный инкремент через INCR/EXPIRE без дополнительных зависимостей.

Как это работает: при каждом запросе атомарно инкрементируется счётчик в Redis с ключом, включающим идентификатор пользователя и временное окно. При первом обращении устанавливается TTL. Если счётчик превысил лимит — возвращается true (заблокирован).

<?php

function isRateLimited(Redis $redis, string $key, int $maxRequests, int $windowSeconds): bool
{
    $current = $redis->incr($key);

    if ($current === 1) {
        $redis->expire($key, $windowSeconds);
    }

    return $current > $maxRequests;
}

// Example usage — 10 запросов в минуту на пользователя
function checkUserRateLimit(Redis $redis, int $userId): bool
{
    $window = (int) floor(time() / 60); // сброс каждую минуту
    $key    = "rate:user:{$userId}:{$window}";
    return isRateLimited($redis, $key, 10, 60);
}

// В контроллере / вебхук-обработчике
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

if (checkUserRateLimit($redis, $userId)) {
    http_response_code(429);
    header('Retry-After: 60');
    exit(json_encode(['error' => 'Too many requests']));
}
PHP

Повтор операции с экспоненциальной задержкой и джиттером

Повторяет нестабильный вызов API с увеличивающимися задержками между попытками плюс случайный джиттер, чтобы много клиентов не повторяли запрос синхронно.

Как это работает: каждая повторная попытка удваивает задержку (200мс, 400мс, 800мс...) с ограничением в $maxDelayMs, плюс случайный джиттер 0-20% сверху. Джиттер важен, потому что если 1000 клиентов одновременно получат ошибку и повторят попытку через ровно одинаковую фиксированную задержку, они все снова ударят по серверу в один и тот же момент.

<?php

function retryWithBackoff(callable $operation, int $maxAttempts = 3, int $baseDelayMs = 200, int $maxDelayMs = 2000): mixed
{
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            return $operation();
        } catch (\Throwable $e) {
            if ($attempt >= $maxAttempts) {
                throw $e;
            }

            $delay = min($maxDelayMs, $baseDelayMs * 2 ** ($attempt - 1));
            $jitter = random_int(0, (int) ($delay * 0.2));
            usleep(($delay + $jitter) * 1000);
        }
    }

    throw new \RuntimeException('Unreachable');
}

// Example usage
$response = retryWithBackoff(fn () => Http::get('https://api.example.com/data')->throw());
PHP

Приведение строки к валидному UTF-8

Убирает или исправляет некорректные последовательности байтов, чтобы строку всегда можно было безопасно сохранить, залогировать или вернуть как JSON.

Как это работает: конвертация строки из UTF-8 в UTF-8 через mb_convert_encoding заставляет PHP заменить любые некорректные последовательности байтов вместо того, чтобы оставить их как есть — именно из-за них json_encode() иначе молча возвращает false.

<?php

function sanitizeUtf8(mixed $value): ?string
{
    if ($value === null || !is_string($value)) {
        return null;
    }

    if ($value === '') {
        return '';
    }

    $sanitized = mb_convert_encoding($value, 'UTF-8', 'UTF-8');

    return $sanitized !== false ? $sanitized : null;
}

// Example usage — prevents json_encode() from silently failing on bad input
$clean = sanitizeUtf8($userSuppliedString);
echo json_encode(['name' => $clean]);
PHP

Генерация безопасного случайного пароля

Создаёт криптографически безопасный случайный пароль с помощью random_bytes() и bin2hex() в PHP.

Как это работает: random_bytes() берёт байты из криптографически безопасного генератора ОС, а bin2hex() превращает их в печатаемую hex-строку вдвое длиннее исходного количества байт — поэтому $length / 2 байт дают пароль ровно из $length символов.

<?php

function generatePassword(int $length = 12): string
{
    return bin2hex(random_bytes((int) ceil($length / 2)));
}

$password = generatePassword(); // e.g. "a4c9b72f3d88"
PHP

Валидация email-адреса

Правильный встроенный способ проверить формат email в PHP — без самодельного регулярного выражения.

Как это работает: FILTER_VALIDATE_EMAIL следует синтаксическим правилам RFC 822/2822 и обрабатывает крайние случаи (закавыченные локальные части, IP-literal домены и т.д.) намного надёжнее, чем регулярное выражение, которое большинство разработчиков написали бы вручную.

<?php

function isValidEmail(string $email): bool
{
    return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}

// Example usage
isValidEmail('[email protected]'); // true
isValidEmail('not-an-email');     // false
// CONNECT

Контактная информация

Оставьте заявку или напишите напрямую в телеграм. Я отвечу в течение 24 часов.

Укажите email или Telegram, чтобы я мог вам ответить.