Build the same reporting API in PHP. Uses PDO for database access, native PHP sessions for authentication, and password_hash/password_verify for secure password handling.
No framework needed: parse REQUEST_URI and REQUEST_METHOD manually. Switch on the path to dispatch to handler functions. This is the same pattern used in many small PHP APIs — read the URL, match it against known routes, and call the appropriate function.
The routing logic is straightforward. Extract the path from the request URI, strip any base directory prefix (so the API works whether deployed at the root or inside a subdirectory), then use a series of if statements to match method + path combinations:
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Strip base path if behind a subdirectory
$path = preg_replace('#^.*/api#', '/api', $path);
// Route: POST /api/login
if ($method === 'POST' && $path === '/api/login') {
// handle login...
}
// Route: GET /api/dashboard
if ($method === 'GET' && $path === '/api/dashboard') {
// handle dashboard...
}
Each route block reads any necessary input (query parameters from $_GET, JSON body from php://input), runs the database query, and calls jsonResponse() to send the result. If no route matches, a 404 response is returned at the end of the try block.
PHP has built-in session support. Call session_start() at the top of the script, and PHP automatically reads the session cookie from the request (or creates a new session if none exists). Session data lives in the $_SESSION superglobal — an associative array that persists across requests for the same user.
The login flow works like this:
password_verify() to check the password against the stored bcrypt hash.session_regenerate_id(true) to prevent session fixation, then store the user data in $_SESSION['user'].Set-Cookie header with the session ID. The browser stores this cookie and sends it with every subsequent request.session_start();
// After successful authentication:
session_regenerate_id(true);
$_SESSION['user'] = [
'email' => $user['email'],
'displayName' => $user['display_name'],
'role' => $user['role'],
];
Logout is equally simple — session_destroy() deletes the server-side session data, effectively logging the user out:
// POST /api/logout
session_destroy();
jsonResponse(['success' => true]);
Protected routes check $_SESSION['user'] before proceeding. The requireAuth() helper function handles this — if no user is stored in the session, it sends a 401 response and exits immediately:
function requireAuth(): void {
if (empty($_SESSION['user'])) {
jsonResponse(['success' => false, 'error' => 'Authentication required'], 401);
}
}
The API exposes seven endpoints. Two handle authentication, and five return analytics data. All data endpoints require an active session.
Reads email and password from the JSON request body. Calls the authenticate() function in auth.php, which queries the users table and verifies the password hash. On success, regenerates the session ID and stores user data in the session. Returns the user object (email, display name, role).
$body = json_decode(file_get_contents('php://input'), true);
$email = $body['email'] ?? '';
$password = $body['password'] ?? '';
$user = authenticate($pdo, $email, $password);
if (!$user) {
jsonResponse(['success' => false, 'error' => 'Invalid credentials'], 401);
}
session_regenerate_id(true);
$_SESSION['user'] = $user;
jsonResponse(['success' => true, 'data' => $user]);
Destroys the current session. No request body needed. The session cookie becomes invalid on the server side.
Returns summary metrics in a single query using four subqueries: total pageviews, total unique sessions, average load time in milliseconds, and total errors. All filtered by the date range from the start and end query parameters (defaults to the last 30 days).
$stmt = $pdo->prepare('
SELECT
(SELECT COUNT(*) FROM pageviews
WHERE server_timestamp BETWEEN ? AND ?
AND type = "pageview") AS total_pageviews,
(SELECT COUNT(DISTINCT session_id) FROM pageviews
WHERE server_timestamp BETWEEN ? AND ?) AS total_sessions,
(SELECT ROUND(AVG(load_time)) FROM performance
WHERE server_timestamp BETWEEN ? AND ?) AS avg_load_time_ms,
(SELECT COUNT(*) FROM errors
WHERE server_timestamp BETWEEN ? AND ?) AS total_errors
');
$stmt->execute([$start, $end, $start, $end, $start, $end, $start, $end]);
Returns two datasets: pageviews grouped by day (for the line chart) and the top 20 pages by view count (for the table). Both queries filter by date range and type = 'pageview'.
Returns average load time, TTFB, LCP, and CLS grouped by page URL. Ordered by slowest pages first, limited to 20 results. The samples count helps the frontend determine confidence in the averages.
Returns two datasets: error messages grouped by frequency (with last-seen timestamps) and a daily error count trend. The frequency list surfaces the most common errors, while the trend line shows whether errors are increasing or decreasing over time.
Returns session counts by day and aggregate engagement statistics: average session duration, average pages per session, and bounce rate (percentage of single-page sessions).
Every endpoint returns JSON. Rather than repeating the same three lines everywhere, a helper function handles the response boilerplate:
function jsonResponse($data, int $status = 200): void {
http_response_code($status);
echo json_encode($data);
exit;
}
The function sets the HTTP status code, encodes the data as JSON, writes it to the output, and exits. The exit call is important — it prevents any code after the response from executing, which avoids accidental double-output or side effects from later route matches.
The Content-Type: application/json header is set once at the top of api.php (before any routing), so it applies to all responses including error cases. This means every response from the API is valid JSON, even 404s and 500s:
// 404 for unknown routes
jsonResponse(['success' => false, 'error' => 'Not found'], 404);
// 500 in the catch block
jsonResponse(['success' => false, 'error' => 'Internal server error'], 500);
success boolean. Successful responses include a data field; error responses include an error message. This consistency makes the frontend code simpler — it can always check response.success before accessing the data.
Several security measures are built into this small API. None of them are complex, but each addresses a specific class of vulnerability.
Every database query uses parameterized placeholders (?) instead of string concatenation. PDO sends the query structure and the values separately to MySQL, making SQL injection impossible. This is the single most important security practice for any database-backed application.
// Safe: parameterized query
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
// Dangerous: string concatenation (NEVER do this)
$pdo->query("SELECT * FROM users WHERE email = '$email'");
Passwords are stored as bcrypt hashes using PHP's password_hash() function with PASSWORD_BCRYPT. Bcrypt is a slow, salted hashing algorithm designed specifically for passwords. Even if the database is compromised, attackers cannot recover the original passwords without a brute-force attack that would take years per password.
Verification uses password_verify(), which handles the salt extraction and timing-safe comparison internally. You never need to manage salts manually.
After a successful login, session_regenerate_id(true) creates a new session ID and deletes the old one. This prevents session fixation attacks, where an attacker sets a known session ID before the user logs in and then hijacks the session after authentication.
PHP's default session cookie configuration sets httponly to true, which prevents JavaScript from reading the session cookie via document.cookie. This mitigates cross-site scripting (XSS) attacks that try to steal session tokens. For production, you would also set secure to true (HTTPS only) and samesite to Strict or Lax.
Access-Control-Allow-Origin to http://localhost:8080 for local development. In production, this must be set to your actual domain. Never use * with Access-Control-Allow-Credentials: true — browsers will reject the request.
Both implementations expose the same seven endpoints and return identical JSON responses. The differences are in the language runtime, libraries, and deployment model.
| Aspect | Node.js (Module 01) | PHP (Module 02) |
|---|---|---|
| Runtime model | Single-threaded event loop; long-running process | Process-per-request; script starts and stops for each request |
| Session handling | express-session with a session store (memory, Redis, or database) | Built-in $_SESSION backed by server-side files (or Redis/DB with custom handler) |
| Password hashing | bcrypt npm package |
Built-in password_hash() / password_verify() |
| Database driver | mysql2 npm package with promise API |
Built-in PDO extension with prepared statements |
| JSON handling | express.json() middleware; res.json() |
json_decode(file_get_contents('php://input')); json_encode() |
| Routing | Express router with app.get(), app.post() |
Manual: parse REQUEST_URI, match with if statements |
| Deployment | Run with node server.js; needs process manager (pm2, systemd) |
Drop files in web root; Apache/Nginx handles process lifecycle |
| Dependencies | express, mysql2, bcrypt, express-session, cors, dotenv | Zero external dependencies — everything is built into PHP |
The PHP version has a notable advantage in simplicity: zero external dependencies. Everything needed — session management, password hashing, database access, JSON encoding — is built into the language. The Node.js version requires installing and managing six npm packages to achieve the same functionality.
On the other hand, the Node.js version benefits from Express's cleaner routing syntax and middleware architecture. As the API grows beyond a handful of routes, Express's app.get('/path', handler) pattern scales more gracefully than a growing chain of if statements.