PHP backend recipes: HTTP, security, formatting
Thirteen PHP snippets for the server side: the real client IP behind a proxy, bcrypt hashing, a Redis rate limiter, retries with exponential backoff and jitter, BCP-47 parsing, UTF-8 sanitising, and token masking.
PHP still runs a large share of the web's request handling, and these are the pieces that sit closest to the request itself: who sent it, whether to accept it, and how to answer without leaking anything.
The client IP is the one thing you cannot trust
$_SERVER['REMOTE_ADDR'] is the address of whatever connected to you — which, behind a load balancer or a CDN, is the load balancer. The usual fix is to read X-Forwarded-For, and the usual mistake is to read it unconditionally: that header is client-supplied, so an attacker can put any address in it and defeat every IP-based rate limit and ban you have. The header is only meaningful when the connection came from a proxy you control, and the value you want is the last entry your infrastructure appended, not the first one in the list.
Passwords, tokens and the timing question
password_hash() with the default algorithm is the correct answer for passwords, and password_verify() handles the comparison in constant time for you. The trap is elsewhere: comparing API keys, webhook signatures or session tokens with == leaks their contents through timing, because string comparison returns early on the first differing byte. That is what hash_equals() exists for.
Masking a token for display is the other half of the same problem — logs and support screens are where secrets leak most often, and showing the first and last few characters is enough to identify a key without exposing it.
Rate limiting and retries are two sides of one coin
A Redis-backed limiter answers "should I accept this request", and retry-with-backoff answers "what do I do when someone else's limiter says no to me". The detail that gets skipped is jitter: without it, every client that failed at the same moment retries at the same moment, and the recovery attempt becomes a second outage. Adding randomness to the delay is the whole fix.
Encoding is where invisible bugs live
Invalid UTF-8 does not announce itself. It travels through your application unnoticed and surfaces as a database error, a broken JSON response, or a blank page — usually far from where it entered. Sanitising at the boundary is cheaper than tracing it later.
Parsing a BCP-47 language tag correctly matters for the same reason: en-GB, en_gb and en all mean something slightly different, and naive string comparison against a hardcoded list quietly fails for everyone outside it.
Make a GET request with cURL
The classic PHP recipe for fetching JSON from an external API without any extra dependencies.
How it works: curl_init opens a handle to the URL, CURLOPT_RETURNTRANSFER makes curl_exec return the response body as a string instead of printing it, and curl_getinfo after execution reads the HTTP status code.
<?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'];
}
Format bytes as a human-readable size
Turns a raw byte count into "1.5 MB", "820 KB", etc. — the standard file-size display helper.
How it works: log($bytes, 1024) finds which power of 1024 the byte count falls into (0 = bytes, 1 = KB, 2 = MB...), then divides by that power to get the display value.
<?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"
Get the real client IP behind a proxy/CDN
Reads the visitor's real IP from CDN/proxy headers (Cloudflare, X-Forwarded-For, etc.), falling back to $_SERVER['REMOTE_ADDR'], and rejects private/spoofed values.
How it works: proxy/CDN headers are checked in order of trust (Cloudflare's own header first, since it can't be spoofed once Cloudflare sits in front of the app), and every candidate is validated as a real public IP before being trusted — private ranges (10.x, 192.168.x, etc.) are rejected so a client can't fake X-Forwarded-For: 10.0.0.1 to hide behind an internal-looking address.
<?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
Bcrypt Password Hashing (PHP)
Hashes and verifies passwords using PHP's native password_hash with bcrypt (cost 12). Includes a rehash check for when hashing parameters change.
How it works: password_hash with PASSWORD_BCRYPT and cost: 12 is a good security/performance balance (~250ms). password_needs_rehash lets you gradually upgrade old hashes at login without forcing a password reset.
<?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 — registration
$hash = hashPassword($_POST['password']);
// Store $hash in the database
// Login
$user = $db->findByEmail($email);
if (!verifyPassword($plainPassword, $user['password_hash'])) {
throw new \Exception('Invalid credentials');
}
// Upgrade the hash if parameters changed (e.g. increased cost)
if (needsRehash($user['password_hash'])) {
$db->updatePasswordHash($user['id'], hashPassword($plainPassword));
}
Generate a colored initials avatar (HTML/CSS)
Renders a circular avatar with the user's initials and a color derived from their name — a lightweight fallback when there's no profile photo.
How it works: the name is hashed into a hue (0-359°) so the same person always gets the same color, and the initials are just the first letter of the first and last name.
<?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
Get IP geolocation info (ipapi.co)
Look up a visitor's city, country and timezone from their IP address using the free ipapi.co API.
How it works: a GET request to ipapi.co/{ip}/json/ returns the geolocation data as JSON. Leaving $ip empty makes the API auto-detect the caller's own IP — handy for looking up the current visitor on your server.
<?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'];
Mask a token/API key for safe display
Shows only the first and last 4 characters of a secret (e.g. an API key) so it's safe to log or display in a UI.
How it works: it keeps the first and last 4 characters and replaces everything in between with asterisks; very short secrets (8 characters or fewer) are masked entirely so nothing meaningful leaks.
<?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'); // "*****"
Parse a BCP-47 language tag
Normalizes language codes like en-GB, pt_BR, or sr_RS (as sent by browsers, Telegram, etc.) into a language + region pair.
How it works: underscores are normalized to hyphens, the tag is split into parts, the first part is the language code, and any 2-letter or 3-digit part after it is treated as the region.
<?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']
Redis Rate Limiter (PHP)
A simple Redis-based rate limiter — restricts the number of requests from a user within a sliding time window. Atomic increment via INCR/EXPIRE with no extra dependencies.
How it works: on each request, a counter in Redis is atomically incremented using a key that includes the user identifier and time window. On the first hit, a TTL is set. If the counter exceeds the limit, the function returns true (blocked).
<?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 requests per minute per user
function checkUserRateLimit(Redis $redis, int $userId): bool
{
$window = (int) floor(time() / 60); // resets every minute
$key = "rate:user:{$userId}:{$window}";
return isRateLimited($redis, $key, 10, 60);
}
// In a controller / webhook handler
$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']));
}
Retry an operation with exponential backoff + jitter
Retries a flaky API call with increasing delays between attempts, plus random jitter so many clients don't retry in lockstep.
How it works: each retry doubles the delay (200ms, 400ms, 800ms...) capped at $maxDelayMs, plus a random 0-20% jitter on top. Jitter matters because if 1,000 clients all fail at once and retry after exactly the same fixed delay, they all hammer the server again at the same instant.
<?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());
Sanitize a string to valid UTF-8
Strips or repairs invalid byte sequences so a string is always safe to store, log, or return as JSON.
How it works: converting a string from UTF-8 to UTF-8 with mb_convert_encoding forces PHP to replace any invalid byte sequences instead of leaving them in place — which is exactly what would otherwise make json_encode() silently fail.
<?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]);
Generate a secure random password
Create a cryptographically secure random password using PHP's random_bytes() and bin2hex().
How it works: random_bytes() pulls bytes from the OS's cryptographically secure generator, and bin2hex() turns them into a printable hex string twice as long as the byte count — so $length / 2 bytes produce a password of exactly $length characters.
<?php
function generatePassword(int $length = 12): string
{
return bin2hex(random_bytes((int) ceil($length / 2)));
}
$password = generatePassword(); // e.g. "a4c9b72f3d88"
Validate an email address
The correct built-in way to check an email's format in PHP — no need for a hand-rolled regex.
How it works: FILTER_VALIDATE_EMAIL follows RFC 822/2822 syntax rules and handles edge cases (quoted local parts, IP-literal domains, etc.) far more reliably than a regex most developers would write by hand.
<?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
More snippet collections
Contact Information
Submit a request or write to me directly on Telegram. I'll reply within 24 hours.