PHP Built-in Web Server
Since PHP 5.4, the CLI SAX comes with a small web server baked straight into the interpreter — no Apache, no Nginx, no virtual host configuration. One command, php -S localhost:8000, turns the current directory into a served site. It exists purely to make local development and quick demos fast: point it at a folder, open a browser, and you are running PHP without touching a single line of server config.
Starting the server
Start serving the current directory
cd my-project php -S localhost:8000
PHP 8.2.12 Development Server (http://localhost:8000) started
With that running, visiting http://localhost:8000/index.php (or just http://localhost:8000/, which maps to index.php by default) executes the file exactly as Apache would, and any other .php file in the directory is reachable the same way by its filename. Static files — .html, .css, .js, images — are served as-is. Press Ctrl+C in the terminal to stop the server.
Choosing a document root
By default the server serves whatever directory you launched it from. Use the -t flag to point it at a different folder without cd-ing into it first — useful when your PHP entry point lives in a public/ subfolder, which is the standard layout for most modern frameworks.
Serve a public/ subfolder as the document root
php -S localhost:8000 -t public/
This mirrors how a real deployment usually works: the web server's document root is public/, and everything above it — application code, vendor libraries, .env files — sits outside the served folder and is therefore never directly downloadable by a visitor.
What it is genuinely good for
Local development— spin up a working PHP environment in one command with no config files.Quick demos— hand someone a folder of PHP files and one command reproduces the exact same server on their machine.Testing a single script— verify a script that expects to run through a web server (uses$_GET,$_SERVER, sends headers) without setting up a full Apache vhost.CI smoke tests— many test suites launch the built-in server as a background process, run HTTP-level integration tests against it, then kill it.
Why it is not a production server
No concurrency by default — one slow request blocks every other visitor.
No TLS/HTTPS termination.
No
.htaccesssupport and no fine-grained access rules.No process supervision — if it crashes, nothing restarts it automatically.
Routing scripts
Real applications often want every request — regardless of the URL path — funneled through one PHP file that decides what to do based on the path itself. The built-in server supports this natively: pass the path to a "router script" as the last argument, and that script gets first look at every incoming request before any file is served directly. This is exactly how small frameworks and mini-routers implement clean URLs without Apache's mod_rewrite.
router.php — a minimal request router
<?php
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
// Let real files (CSS, JS, images) be served as-is.
if ($uri !== '/' && file_exists(__DIR__ . '/public' . $uri)) {
return false; // fall back to the built-in server's default handling
}
switch ($uri) {
case '/':
echo "Welcome to the home page.";
break;
case '/about':
echo "This is the about page.";
break;
case '/users':
header('Content-Type: application/json');
echo json_encode(['users' => ['Alice', 'Bob', 'Carol']]);
break;
default:
http_response_code(404);
echo "404 - Page not found: {$uri}";
break;
}Returning false from a routing script is a special signal to the built-in server: "I chose not to handle this request myself, serve the requested file normally." That is what makes the file_exists check above work — actual static assets fall through to the default file-serving behavior, while everything else is decided by the switch statement.
Starting the server with a router
Launch with router.php intercepting every request
php -S localhost:8000 router.php
Now every URL — http://localhost:8000/, http://localhost:8000/about, http://localhost:8000/users — is handled entirely inside router.php, based on the value of $_SERVER['REQUEST_URI'], rather than PHP looking for a matching file on disk. Visiting /users returns JSON built from the array in the switch statement above.
GET / -> Welcome to the home page.
GET /about -> This is the about page.
GET /users -> {"users":["Alice","Bob","Carol"]}
GET /does-not-exist -> 404 - Page not found: /does-not-existThis is precisely the mechanism that lets tiny hand-rolled "micro-frameworks" — and, in local development, full frameworks like Laravel and Symfony — expose clean, extensionless URLs through the built-in server without any Apache rewrite rules at all: they ship a router script (Laravel's is public/router.php in some setups) that all requests funnel through in dev mode.
Combining a document root and a router
Both flags together
php -S localhost:8000 -t public/ public/router.php
Inspecting requests as they arrive
Unlike Apache, the built-in server logs every request straight to the terminal you launched it from — no separate access.log file to tail. This makes it convenient for quickly seeing exactly what a page (or an AJAX call) requested while you are debugging.
Terminal output while the server is running
PHP 8.2.12 Development Server (http://localhost:8000) started [Thu Jul 02 10:15:02 2026] 127.0.0.1:52344 [200]: GET / [Thu Jul 02 10:15:04 2026] 127.0.0.1:52346 [200]: GET /users [Thu Jul 02 10:15:07 2026] 127.0.0.1:52348 [404]: GET /missing