PHPPHP Built-in Web Server

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

Bash
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

Bash
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
Never use the built-in server in production
The PHP manual itself is explicit about this, and it is worth internalizing why. By default the built-in server is **single-threaded** — it handles one request at a time, so a second visitor waits until the first request finishes processing. It has no built-in HTTPS support, no request-level access controls, no `.htaccess`-style per-directory rules, and none of the process -management, worker pooling, or hardening that Apache, Nginx, or PHP-FPM provide. It was designed for a developer's laptop, not for traffic from the internet.
  • No concurrency by default — one slow request blocks every other visitor.

  • No TLS/HTTPS termination.

  • No .htaccess support 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
<?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

Bash
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-exist

This 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

Bash
php -S localhost:8000 -t public/ public/router.php
Order matters
The document root (`-t`) is a flag, so it can appear anywhere before the router script argument, but the router script path itself must always be the last argument on the command line. If you rename or move `router.php`, update this command to match.
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

Text
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
Tip
Use the built-in server for local development and automated tests, but keep a real deployment target — Apache, Nginx with PHP-FPM, or a managed platform — as the only place production traffic actually lands. Treat any behavior difference you notice between the two (concurrency, header handling, `.htaccess` rules) as a reminder that the built-in server is a development convenience, not a scaled -down production server.