/flags/php/php-s-built-in-web-server
php -S localhost:8000: start the built-in development web server
- 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:
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
waitOutput
{"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 200The 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
| Command | Effect |
|---|---|
| php -S localhost:8000 | Serve the current directory on port 8000, local connections only |
| php -S 0.0.0.0:8000 | Listen on all interfaces (other devices, containers) |
| php -S localhost:8000 -t public | Use public/ as the document root |
| php -S localhost:8000 router.php | Run router.php for every request; return false to fall through |
| php -S localhost:8000 -t public public/index.php | Front-controller setup used by many frameworks |
| php -d display_errors=1 -S localhost:8000 | Combine 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.0instead oflocalhost. - A missing asset returns HTML: the fallback to
index.phpdescribed above. In a router, checkfile_exists(__DIR__ . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH))before returningfalse, and sendhttp_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.