I remember the first time I pasted a perfectly innocent API URL into ReqBin and boom the response was a full-blown HTML page scolding me for not having JavaScript enabled. The PHP script on my server was only three lines long, yet here I was staring at a message that clearly didn’t come from PHP at all. I’ll walk you through what’s really happening, show you how to prove it to yourself, and then turn that tiny health-check script into a miniature practice API you can keep extending.
The Mystery Page Isn’t Yours It’s the Gatekeeper
Before any HTTP request reaches your PHP interpreter it usually passes through at least one protective layer: a CDN, WAF, reverse proxy, or even a single-page-app framework. If that layer isn’t satisfied it swaps your real response with a “JavaScript required” splash. Here’s what that looks like in practice:
Where the page really comes from | What it’s doing | Why you only see it in ReqBin |
---|---|---|
Cloudflare / Imunify / Sucuri | Runs a bot-challenge (“Turnstile”, JavaScript cookie, etc.) | ReqBin fires a bare-bones cURL request—no cookies, no fancy headers—so the WAF assumes it’s a scraper |
Reverse-proxy / load balancer | Injects a maintenance or anti-bot page whenever a rule fails | Your normal browser sailed through the rule earlier; ReqBin didn’t |
Static-site framework (Next.js, Angular…) | Serves the framework’s index.html , which politely says the site needs JS | Your PHP sits in /api , but the tester hit the root path and got the SPA splash instead |
Key point: your PHP never ran. Something in front intercepted the request and served that HTML instead.
Quick Fixes I Reach For First
- Hit the exact endpoint in ReqBin
Include/api.php
,/v1/ping
,/api/health
whatever the real path is. - Disable or bypass the challenge on API routes
Cloudflare → Security → WAF → Skip Turnstile for/api/*
. - Allow-list trusted origins/IPs if you must keep the WAF.
- Return real JSON so testers negotiate the right MIME type:
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'API_CONNECTED']);
Try those, reload ReqBin, and nine times out of ten the mystery page disappears.
From “Hello World” to a Mini Practice API
Once the basics work, I like to turn that single echo statement into a safe sandbox for experiments CORS, auth, database hits, and structured error replies. Below is my go-to starter file. Copy it to api/index.php
, tweak the constants in config.php
, and you’re good:
<?php
declare(strict_types=1);
/* ---------- 1. HEADERS ---------- */
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Content-Type: application/json; charset=utf-8');
/* Stop CORS pre-flight right here */
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
/* ---------- 2. CONFIG (never commit secrets) ---------- */
require_once __DIR__ . '/config.php'; // defines DB_DSN, DB_USER, DB_PASS, API_USER, API_PASS
/* ---------- 3. HELPER ---------- */
function jsonOut(array $data, int $code = 200): void
{
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
exit;
}
/* ---------- 4. BASIC AUTH ---------- */
if (
!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) ||
$_SERVER['PHP_AUTH_USER'] !== API_USER ||
$_SERVER['PHP_AUTH_PW'] !== API_PASS
) {
header('WWW-Authenticate: Basic realm="DemoAPI"');
jsonOut(['error' => 'Unauthorized'], 401);
}
/* ---------- 5. ROUTER ---------- */
$path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
$method = $_SERVER['REQUEST_METHOD'];
/* GET /api/ping */
if ($path === 'api/ping' && $method === 'GET') {
jsonOut(['message' => 'pong', 'epoch' => time()]);
}
/* GET /api/users[?q=alice] */
if ($path === 'api/users' && $method === 'GET') {
$q = $_GET['q'] ?? '';
try {
$pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare(
'SELECT id, name, email FROM users WHERE name LIKE :q LIMIT 20'
);
$stmt->execute([':q' => "%$q%"]);
jsonOut(['count' => $stmt->rowCount(), 'data' => $stmt->fetchAll()]);
} catch (Throwable $e) {
jsonOut(['error' => $e->getMessage()], 500);
}
}
/* ---------- 6. FALLBACK ---------- */
jsonOut(['error' => 'Route not found'], 404);
What You Can Practice With This File
Task | How I test it |
---|---|
CORS pre-flight | OPTIONS /api/ping → expect HTTP 204 |
Health check | GET /api/ping → { "message": "pong" } |
Filtered lookup | GET /api/users?q=ali |
Auth failure | Wrong credentials → 401 Unauthorized |
DB exception | Break the SQL → see structured 500 JSON |
WAF behavior | curl with/without User-Agent to watch Cloudflare flip |
Ideas for Further Practice
- POST
/api/users
– validate request JSON, insert a row, echo the new ID. - Redis rate-limit – reply with
429 Too Many Requests
plusX-RateLimit-Remaining
. - Request logging – append method, route, IP, microsecond timing to
access.log
.
Each add-on teaches a real-world skill while keeping the surface area small.
Final Thought
Every mysterious “JavaScript required” screen is a reminder that HTTP requests travel through a lot of layers before they hit your code. Once you understand who those gatekeepers are and why they act the way they do you’ll fix test-bench surprises in seconds. Even better, you can turn the humble “API CONNECTED” echo into a living playground where you practise auth, CORS, SQL, rate-limiting, and everything else an API does in production. That way, the next oddball response that lands in ReqBin becomes just another bug you’ve already rehearsed fixing.