Skip to content

/flags/php/php-s-built-in-web-server

php -S localhost:8000: start the built-in development web server

verified onenvironmentlocal · windows 10.0.26200 arm64 · GNU bash 5.3.15 (Git Bash)updated
tool
PHP
command
php
flag
-S <addr>:<port> [-t docroot] [router.php]
default
document root: the current directory
since
PHP 5.4.0

php -S localhost:8000 starts PHP's built-in web server in the current directory: .php files are executed, other files are served as static content, and requests for paths that do not exist fall back to index.php. Add -t public to serve another folder, and a router script as the last argument to decide per request what happens. It is meant for local development only, never for production.

Router, static files and fallback

The example uses PHP 8.4 compiled to WebAssembly (php-wasm, from the WordPress Playground project); its small wrapper copies the folder into PHP's virtual file system and runs the CLI with the same arguments as php. The server stops after a few seconds by itself:

bash
npm init -y >/dev/null
npm install --silent --no-audit --no-fund @php-wasm/node@3.1.56 @php-wasm/universal@3.1.56 >/dev/null 2>&1
cat > php.mjs <<'JS'
// Minimal "php" command: copies the files of this folder into the virtual file system and runs the CLI
import fs from "node:fs";
import { PHP } from "@php-wasm/universal";
import { loadNodeRuntime } from "@php-wasm/node";
const php = new PHP(await loadNodeRuntime("8.4", { emscriptenOptions: { processId: 1 } }));
php.mkdir("/app");
for (const f of fs.readdirSync(".").filter((f) => fs.statSync(f).isFile())) php.writeFile(`/app/${f}`, fs.readFileSync(f));
php.chdir("/app");
const r = await php.cli(["php", ...process.argv.slice(2)]);
process.stdout.write(await r.stdoutText);
process.exit(await r.exitCode);
JS
echo '<?php echo "index.php handled ", $_SERVER["REQUEST_URI"], "\n";' > index.php
echo 'hello from a static file' > notes.txt
cat > router.php <<'EOF'
<?php
if (str_starts_with($_SERVER["REQUEST_URI"], "/api/")) {
    header("Content-Type: application/json");
    echo json_encode(["path" => $_SERVER["REQUEST_URI"], "sapi" => PHP_SAPI]), "\n";
    return true;
}
return false;   // let the built-in server handle the request normally
EOF
timeout 10 node php.mjs -S 127.0.0.1:5300 router.php >/dev/null 2>&1 &
sleep 5
curl -s http://127.0.0.1:5300/api/users
curl -s http://127.0.0.1:5300/notes.txt
curl -sI http://127.0.0.1:5300/notes.txt | grep -i '^content-type'
curl -s http://127.0.0.1:5300/
curl -s -w 'status %{http_code}\n' http://127.0.0.1:5300/missing.png
wait

Output

{"path":"\/api\/users","sapi":"cli-server"}
hello from a static file
Content-Type: text/plain; charset=UTF-8
index.php handled /
index.php handled /missing.png
status 200

The router answers /api/... itself (PHP_SAPI is cli-server). For everything else it returns false, so the server serves notes.txt with a MIME type from its built-in table, runs index.php for /, and also runs index.php with status 200 for /missing.png, a file that does not exist.

Syntax and options

CommandEffect
php -S localhost:8000Serve the current directory on port 8000, local connections only
php -S 0.0.0.0:8000Listen on all interfaces (other devices, containers)
php -S localhost:8000 -t publicUse public/ as the document root
php -S localhost:8000 router.phpRun router.php for every request; return false to fall through
php -S localhost:8000 -t public public/index.phpFront-controller setup used by many frameworks
php -d display_errors=1 -S localhost:8000Combine with ini overrides for the server process
PHP_CLI_SERVER_WORKERS=4 php -S ...Several worker processes (PHP 7.4+, not on Windows)

Each request is logged on the terminal with the client address, status and path; the log goes to standard error.

Common problems

  • Failed to listen on localhost:8000 (reason: Address already in use): another process uses the port. Pick another port or stop the other server.
  • Reachable locally but not from a container or phone: bind to 0.0.0.0 instead of localhost.
  • A missing asset returns HTML: the fallback to index.php described above. In a router, check file_exists(__DIR__ . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH)) before returning false, and send http_response_code(404) otherwise.
  • Headers already sent warnings behave as with any SAPI: output before header() breaks it, see Cannot modify header information.
  • Slow parallel requests: the server handles one request at a time by default, so a page that calls its own API on the same server can block; use PHP_CLI_SERVER_WORKERS.

Framework commands such as php artisan serve and symfony server:start (in its fallback mode) wrap this same server.

FAQ

Can I use php -S in production?

No. The PHP manual states that the built-in server is designed to aid application development and should not be used on a public network. It runs a single process that handles one request at a time unless PHP_CLI_SERVER_WORKERS is set, and has none of the hardening of nginx or Apache with PHP-FPM.

How do I make the server reachable from other devices or a container?

Bind to all interfaces: php -S 0.0.0.0:8000. localhost or 127.0.0.1 accepts only connections from the same machine, which is why a Docker port mapping to a server started on 127.0.0.1 shows connection refused or an empty reply.

Why does every URL return my index.php?

When a requested file does not exist, the built-in server falls back to index.php (or index.html) of the document root, so a missing image or a typo in a path runs the front controller instead of returning 404. Use a router script that returns false only for files that exist, as on this page.

Sources