Built-In Web Server
PHP includes a development HTTP server in the CLI SAPI. It is the quickest way to send a real HTTP request to a small PHP project without first configuring Apache, Nginx, Caddy, or PHP-FPM.
Use it for local learning, controlled demonstrations, and focused application checks. PHP's manual explicitly says it is not a full-featured server and must not be used on a public network.
Start With An Explicit Document Root
From a project containing public/index.php, run:
php -S 127.0.0.1:8000 -t public
The parts have separate jobs:
-Sstarts the built-in server;127.0.0.1:8000selects the listening address and port;-t publicmakespublicthe document root.
Open http://127.0.0.1:8000/ or request it from another terminal:
curl -i http://127.0.0.1:8000/
curl -i shows response headers as well as the body. Press Ctrl+C in the server terminal to stop the process.
Always specify the intended document root. Without -t, PHP serves from the directory in which the command starts. Accidentally starting in a project root can place source, local configuration, dependency metadata, tests, and storage paths inside the server's visible tree.
A common layout is:
project/
|-- config/
|-- public/
| |-- assets/
| `-- index.php
|-- src/
|-- storage/
`-- vendor/
Only public/ is meant to receive direct HTTP requests. Application code can require files outside it, but the server should not expose those paths as static resources.
Create A Front Controller
A front controller is the PHP file through which application requests enter:
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'] ?? 'UNKNOWN';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
echo "PHP development server\n";
echo "SAPI: " . PHP_SAPI . "\n";
echo "Method: {$method}\n";
echo "URI: {$uri}\n";
A request to http://127.0.0.1:8000/orders?page=2 can produce:
PHP development server
SAPI: cli-server
Method: GET
URI: /orders?page=2
PHP_SAPI is cli-server, not cli and not fpm-fcgi. The server is launched by the CLI binary but handles HTTP requests through its dedicated SAPI.
Understand Default File Resolution
When a requested static file exists under the document root, the built-in server serves it with a standard MIME type. For example, public/assets/site.css is available at /assets/site.css.
When a request does not map directly to an existing file, the server can search for index.php or index.html in the requested directory and then through parent directories up to the document root. If it finds an index file, trailing URI content may be exposed through $_SERVER['PATH_INFO']; otherwise the response is 404 Not Found. This means a missing path can execute a parent index.php, and that application must set 404 itself when the route is unknown.
Do not build application routing around every detail of that fallback. A router script or framework front controller gives the project an explicit routing rule and is easier to test consistently.
Inspect An HTTP Request Correctly
$_SERVER['REQUEST_URI'] includes the path and query string. Separate the path before matching routes:
<?php
declare(strict_types=1);
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($requestUri, PHP_URL_PATH);
if (!is_string($path)) {
http_response_code(400);
echo "Invalid request URI\n";
exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'method' => $_SERVER['REQUEST_METHOD'] ?? 'UNKNOWN',
'path' => $path,
'query' => $_GET,
'sapi' => PHP_SAPI,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . PHP_EOL;
Test different request shapes instead of refreshing only one browser page:
curl -i "http://127.0.0.1:8000/search?q=php&page=2"
curl -i -X POST http://127.0.0.1:8000/orders
curl -i -H "Accept: application/json" http://127.0.0.1:8000/
A browser often makes additional requests such as /favicon.ico, so more than one access-log line does not necessarily mean the application duplicated a request.
Use A Router Script For Application Routes
A router script runs at the start of every request. If it returns false, the built-in server serves the requested resource normally. Otherwise, the router's output becomes the response.
Place router.php in the project root:
<?php
declare(strict_types=1);
$publicRoot = realpath(__DIR__ . '/public');
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($requestUri, PHP_URL_PATH);
if ($publicRoot === false || !is_string($path)) {
http_response_code(400);
echo "Invalid server configuration or request path.\n";
exit;
}
$decodedPath = rawurldecode($path);
if (str_contains($decodedPath, "\0")) {
http_response_code(400);
echo "Invalid request path.\n";
exit;
}
$candidatePath = $publicRoot
. DIRECTORY_SEPARATOR
. ltrim($decodedPath, "/\\");
$candidate = realpath($candidatePath);
$isPublicFile = $candidate !== false
&& str_starts_with($candidate, $publicRoot . DIRECTORY_SEPARATOR)
&& is_file($candidate);
if ($isPublicFile) {
return false;
}
require $publicRoot . DIRECTORY_SEPARATOR . 'index.php';
Start it with both the document root and router:
php -S 127.0.0.1:8000 -t public router.php
The -t public part is essential. return false delegates to the server, and the server must resolve that request inside the same intended public root.
The realpath() boundary check prevents a path containing parent-directory segments from being treated as an approved public file. The built-in server also enforces its document root, but the router should not make security decisions through naive string concatenation.
Set Status And Headers Before Output
The built-in server follows normal PHP response rules. Send status and headers before body output:
<?php
declare(strict_types=1);
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
['error' => 'Route not found'],
JSON_THROW_ON_ERROR,
) . PHP_EOL;
Check the complete response with curl -i. A JSON body saying "not found" is not enough if the HTTP status remains 200 OK. Clients, caches, monitors, and tests use the status separately from the body.
Do not enable permissive cross-origin headers merely to hide a browser error. CORS, cookies, sessions, redirects, uploads, and authentication still need deliberate application rules during local development.
Know Which Configuration Is Running
The built-in server uses the CLI PHP executable and its configuration. Inspect them before diagnosing an extension or limit:
php -v
php --ini
php -m
A project can apply a documented one-process override:
php -d display_errors=1 -S 127.0.0.1:8000 -t public router.php
Development error display can help locally, but response bodies containing warnings can corrupt JSON or reveal paths and credentials. Production should log appropriate detail and return controlled error responses.
A successful request here does not prove that PHP-FPM has the same extensions, INI files, environment variables, operating-system user, filesystem permissions, or timeouts. The next lesson separates those server architectures.
Expect One Blocking Process By Default
The normal built-in server runs one single-threaded process. If one request waits on a slow network call, lock, stream, or deliberate sleep(), another request can stall behind it. This makes the server unsuitable for load tests, concurrency conclusions, long-running workers, or production traffic.
PHP supports an experimental PHP_CLI_SERVER_WORKERS setting on platforms other than Windows, but the manual still warns that the server and that worker feature are not for production. Use the same architecture as the deployed system when concurrency behaviour matters.
Bind To The Smallest Necessary Interface
Use 127.0.0.1 for local-only IPv4 access:
php -S 127.0.0.1:8000 -t public
Binding to all IPv4 interfaces exposes the process to networks that can reach your machine:
php -S 0.0.0.0:8000 -t public
Only do that for a controlled need, such as testing from another device on a trusted network. A development application may contain debug output, weak test credentials, destructive routes, or no TLS. A firewall prompt is a security decision, not an installation formality.
If the port is already occupied, stop the other process or choose another unprivileged port such as 8080. Do not terminate an unknown process merely to reclaim port 8000.
Distinguish Query, Form, And Raw Body Data
Different HTTP inputs reach PHP through different interfaces. Query parameters such as ?page=2 populate $_GET. A form sent as application/x-www-form-urlencoded or multipart/form-data can populate $_POST. A JSON request body does not automatically become $_POST; read php://input and decode it deliberately.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
http_response_code(400);
echo json_encode(['error' => 'Request body could not be read'], JSON_THROW_ON_ERROR);
exit;
}
$decoded = str_starts_with($contentType, 'application/json')
? json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR)
: null;
echo json_encode([
'query' => $_GET,
'form' => $_POST,
'json' => $decoded,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . PHP_EOL;
A production endpoint also needs a body-size limit, JSON exception handling, schema validation, authentication, and authorization. The built-in server does not make incoming data trustworthy; it only makes local requests convenient.
Exercise the body explicitly:
curl -i -X POST \
-H "Content-Type: application/json" \
--data '{"sku":"BOOK-42","quantity":2}' \
http://127.0.0.1:8000/orders
Read The Server Terminal
Keep the server terminal visible while testing. It reports startup details, accepted connections, request methods and paths, statuses, and startup failures such as an occupied port or missing document root. PHP warnings may also appear there depending on configuration.
Use those logs to correlate one client request with one server response, but do not treat them as a production logging system. They lack the structured context, retention, access controls, rotation, and central collection expected from application and infrastructure logs.
When a request fails, separate the layers:
| Symptom | First check |
|---|---|
| Connection refused | Is the server process running on that address and port? |
| Browser reaches the wrong project | Which directory and -t root were used at startup? |
| Static file returns 404 | Does it exist under the document root, and did the router return false? |
| Application route returns 404 | Did the router include the front controller, and what path was parsed? |
| JSON is invalid | Did a warning, notice, or debug print precede the JSON body? |
| Extension works in CLI check but not deployment | Are the SAPI, binary, and INI files actually the same? |
| Second request hangs | Is the first request blocking the single server process? |
Change one layer at a time. Confirm the listening process, request URL, HTTP response, router decision, and application code in that order. Restart the server after changing startup flags or environment variables; editing a PHP source file normally takes effect on the next request, but process-level configuration does not change inside an already running server.
Do Not Treat It As Production Parity
The built-in server does not reproduce a production reverse proxy or web-server stack. It does not validate:
- Nginx, Apache, or Caddy routing rules;
- FastCGI parameters and PHP-FPM pools;
- TLS certificates and HTTP-to-HTTPS redirects;
- proxy headers and trusted-proxy configuration;
- production users, groups, and file permissions;
- process supervision, restarts, health checks, or scaling;
- production caching, compression, buffering, and request limits.
It answers a narrower question: how does this PHP application behave when reached through a simple local HTTP server?
What You Should Be Able To Do
After this lesson, you should be able to start the server with an explicit public root, inspect requests and responses with curl, route application paths through a front controller, delegate safe static files, set correct statuses and headers, and explain why cli-server is not PHP-FPM or a production web server.
The authoritative reference is PHP's built-in web server manual.
Practice
Practice: Start A Server With A Public Boundary
Build a tiny project that proves which files the built-in server can serve directly.
Project Layout
Create:
server-practice/
|-- private-note.txt
`-- public/
|-- assets/
| `-- status.txt
`-- index.php
Put a harmless marker line in each text file. Create public/index.php so that it:
- uses strict types
- sends
Content-Type: text/plain; charset=utf-8 - safely extracts the request path from
REQUEST_URI - returns status
400when the URI cannot be parsed - prints
PHP_SAPI, the request method, and the parsed path on separate labelled lines
Start the server from server-practice/ on 127.0.0.1:8000 with public/ as the explicit document root.
Use curl -i to verify:
/executespublic/index.php/assets/status.txtserves the public static file/private-note.txtdoes not expose the file outsidepublic/
Record the observed HTTP statuses. Afterward, explain why starting the same command without -t public would weaken the intended project boundary.
Show solution
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($requestUri, PHP_URL_PATH);
if (!is_string($path)) {
http_response_code(400);
echo "Invalid request URI\n";
exit;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'UNKNOWN';
echo 'SAPI: ' . PHP_SAPI . PHP_EOL;
echo 'Method: ' . $method . PHP_EOL;
echo 'Path: ' . $path . PHP_EOL;
Example marker files:
public/assets/status.txt: public asset reached
private-note.txt: outside document root
From the project root, start:
php -S 127.0.0.1:8000 -t public
Test each boundary from another terminal:
curl -i http://127.0.0.1:8000/
curl -i http://127.0.0.1:8000/assets/status.txt
curl -i http://127.0.0.1:8000/private-note.txt
The first two requests should return 200 OK. For /private-note.txt, the built-in server can fall back to public/index.php and therefore also return 200 OK; the response should show Path: /private-note.txt but must not contain outside document root. The document-root boundary prevents direct file access, while application routing is responsible for choosing a 404 status for unknown paths.
-t public makes the intended web-facing directory explicit even when the command starts from the project root. Without it, the current directory becomes the document root and private-note.txt, along with any other directly addressable project-root file, can fall inside the server's static file tree.
Task: Build A Request Inspector
Create public/request-info.php, an endpoint for comparing query parameters and JSON request bodies.
Contract
The endpoint must:
- use strict types
- always identify its response as
application/json; charset=utf-8 - accept only
GETandPOST - return
405 Method Not AllowedwithAllow: GET, POSTfor another method - parse the path from
REQUEST_URIand return400 Bad Requestif it is invalid - read the raw body from
php://inputand return400if the read fails - decode a non-empty body when its content type begins with
application/json - catch
JsonExceptionand return a small JSON error with status400 - print a successful JSON report containing the method, path,
$_GET, content type, raw body byte count, decoded JSON value, andPHP_SAPI - encode every response with
JSON_THROW_ON_ERROR
Use curl -i to test:
- a
GETrequest with repeated query parameters - a
POSTrequest containing a valid JSON object - a
POSTrequest containing malformed JSON - a
PUTrequest that receives status405and theAllowheader
After the code, explain why a JSON request body is read from php://input rather than expected in $_POST.
Show solution
The endpoint uses one response function so every branch returns JSON and terminates immediately.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
function respond(array $payload, int $status): never
{
http_response_code($status);
echo json_encode(
$payload,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
) . PHP_EOL;
exit;
}
$methodValue = $_SERVER['REQUEST_METHOD'] ?? 'UNKNOWN';
$method = is_string($methodValue) ? $methodValue : 'UNKNOWN';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
respond(['error' => 'Method not allowed'], 405);
}
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$path = is_string($requestUri)
? parse_url($requestUri, PHP_URL_PATH)
: false;
if (!is_string($path)) {
respond(['error' => 'Invalid request URI'], 400);
}
$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
respond(['error' => 'Request body could not be read'], 400);
}
$contentTypeValue = $_SERVER['CONTENT_TYPE'] ?? '';
$contentType = is_string($contentTypeValue) ? $contentTypeValue : '';
$decodedJson = null;
if ($rawBody !== '' && str_starts_with(strtolower($contentType), 'application/json')) {
try {
$decodedJson = json_decode(
$rawBody,
true,
flags: JSON_THROW_ON_ERROR,
);
} catch (JsonException) {
respond(['error' => 'Malformed JSON body'], 400);
}
}
respond([
'method' => $method,
'path' => $path,
'query' => $_GET,
'content_type' => $contentType,
'body_bytes' => strlen($rawBody),
'json' => $decodedJson,
'sapi' => PHP_SAPI,
], 200);
Start the server with php -S 127.0.0.1:8000 -t public, then test:
curl -i "http://127.0.0.1:8000/request-info.php?page=2&tag%5B%5D=php&tag%5B%5D=cli"
curl -i \
-H "Content-Type: application/json" \
--data '{"sku":"BOOK-42","quantity":2}' \
http://127.0.0.1:8000/request-info.php
curl -i \
-H "Content-Type: application/json" \
--data '{"sku":' \
http://127.0.0.1:8000/request-info.php
curl -i -X PUT http://127.0.0.1:8000/request-info.php
PHP populates $_POST for supported form encodings, not for an arbitrary JSON document. php://input exposes the raw request body, after which the application checks the content type, decodes JSON, handles syntax errors, and validates the resulting data against its own contract.
Task: Build A Safe Development Router
Create a project-root router.php and a public/index.php front controller.
Router Requirements
The router must:
- use strict types
- resolve the canonical
public/directory withrealpath() - parse and URL-decode the request path
- return status
400when the public root or request path cannot be resolved safely - resolve the requested candidate with
realpath() - return
falseonly when the candidate is a real file inside the canonical public root - send every missing file and application route to
public/index.php
The front controller must return JSON for:
/with a short application message and status200/healthwith{"status":"ok"}and status200- every other path with an error object and status
404
Start the server with both -t public and router.php. Add public/assets/app.css and a harmless private-note.txt outside public/.
Use curl -i to verify the two application routes, the static CSS file, a missing route, and an encoded parent-directory request such as /%2e%2e/private-note.txt. The private marker must never appear in a response.
After the code, explain precisely what return false delegates to and why checking only is_file(__DIR__ . '/public' . $path) is a weaker boundary.
Show solution
<?php
declare(strict_types=1);
$publicRoot = realpath(__DIR__ . '/public');
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$path = is_string($requestUri)
? parse_url($requestUri, PHP_URL_PATH)
: false;
if ($publicRoot === false || !is_string($path)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Invalid server configuration or request path'], JSON_THROW_ON_ERROR);
exit;
}
$decodedPath = rawurldecode($path);
if (str_contains($decodedPath, "\0")) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Invalid request path'], JSON_THROW_ON_ERROR);
exit;
}
$candidatePath = $publicRoot
. DIRECTORY_SEPARATOR
. ltrim($decodedPath, "/\\");
$candidate = realpath($candidatePath);
$isPublicFile = $candidate !== false
&& str_starts_with($candidate, $publicRoot . DIRECTORY_SEPARATOR)
&& is_file($candidate);
if ($isPublicFile) {
return false;
}
require $publicRoot . DIRECTORY_SEPARATOR . 'index.php';
Create public/index.php:
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$path = is_string($requestUri)
? parse_url($requestUri, PHP_URL_PATH)
: false;
if (!is_string($path)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request URI'], JSON_THROW_ON_ERROR) . PHP_EOL;
exit;
}
[$status, $payload] = match ($path) {
'/' => [200, ['message' => 'Development application']],
'/health' => [200, ['status' => 'ok']],
default => [404, ['error' => 'Route not found', 'path' => $path]],
};
http_response_code($status);
echo json_encode(
$payload,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
) . PHP_EOL;
Start the server from the project root:
php -S 127.0.0.1:8000 -t public router.php
Exercise every branch:
curl -i http://127.0.0.1:8000/
curl -i http://127.0.0.1:8000/health
curl -i http://127.0.0.1:8000/assets/app.css
curl -i http://127.0.0.1:8000/missing
curl --path-as-is -i http://127.0.0.1:8000/%2e%2e/private-note.txt
In this router context, return false tells PHP's built-in server to serve the requested resource through its normal static-file handling. Because startup used -t public, that delegated handling uses public/ as its document root.
A direct is_file(__DIR__ . '/public' . $path) check does not establish a canonical boundary. Parent-directory segments and symlinks can resolve somewhere other than the apparent string path. Comparing canonical realpath() results ensures the selected file actually remains below the canonical public root before delegation.