skip to content

SAPIs & Built-In Server

The same script behaves differently under the CLI, PHP-FPM, Apache's module or the built-in php -S server. Interviewers check that you know which SAPI you are in and what it changes.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

4

How do you run a local site with PHP's built-in php -S server and a router script, and why is it unfit for production?

level: juniorimportance: should knowfreq 45%

answer

  1. php -S host:port
  2. -t sets the document root
  3. router returns false: default handling
  4. one single-threaded process by default
  5. PHP_CLI_SERVER_WORKERS since 7.4

basics

~20 s

php -S localhost:8000 -t public router.php starts PHP's development server; the router runs on every request and returning false hands the request back to default file handling. It runs one single-threaded process and is not built for public traffic.

solid answer

~50 s

`php -S localhost:8000` starts the **built-in web server** that ships with the PHP CLI; `-t public` sets the document root, which otherwise is the directory you started it in. A PHP file named after the address becomes a **router script**: it runs at the start of every request, and if it returns `false` the server falls back to its default handling, executing a requested `.php` file or sending any other file as-is. Without a router, a path with no file falls back to `index.php` or `index.html`. Inside it, `php_sapi_name()` returns `cli-server`. It is meant for development, tests and demos only: by default it runs **one single-threaded process**, so one slow request stalls every other, and the manual says it should not be used on a public network. `PHP_CLI_SERVER_WORKERS` can fork several workers for testing concurrency, except on Windows.

code

php · 11 lines
php
<?php
// router.php, used as: php -S localhost:8000 -t public router.php
declare(strict_types=1);

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path !== '/' && is_file(__DIR__ . '/public' . $path)) {
    return false; // default handling: serve the asset or run the .php file
}

require __DIR__ . '/public/index.php'; // front controller for every other URL

go deeper

for a junior

Know the command php -S localhost:8000 -t public and that it is for local development only.

for a middle

Explain how the router script works, what returning false does, and why a single-threaded server stalls on one slow request.

for a senior

Use it deliberately for tests and demos, with workers for concurrency tests, and never let it reach a public network or a deployment path.

for a principal

Keep development parity in mind: decide which production behaviours of the real web server and FPM stack local tooling must reproduce.

## What the built-in server is The command-line binary can act as a small HTTP server. You start it with the `-S` option and an address: ```bash php -S localhost:8000 ``` It is part of the CLI SAPI, and inside a request `php_sapi_name()` returns **`cli-server`**. It needs no Apache, Nginx or PHP-FPM, which makes it handy for: - local development of a small app or an API; - running end-to-end tests against a throwaway server; - demos in a controlled environment. ## Options that matter | Option | Effect | |---|---| | `-S localhost:8000` | listen on that host and port; `0.0.0.0:8000` listens on every interface | | `-t public` | serve files from `public/` instead of the current directory | | `router.php` (a file argument) | run this script at the start of every request | | `PHP_CLI_SERVER_WORKERS=4` (environment) | fork four workers; available since PHP 7.4, not on Windows | ## How a request is resolved without a router 1. The URI is mapped to a path under the document root. 2. A `.php` file is executed; any other existing file is sent with a MIME type from the server's built-in list. 3. If the path names no file, the server looks for `index.php` or `index.html`, walking up toward the document root, and sets `$_SERVER['PATH_INFO']` to the rest of the URI. 4. If nothing is found, it answers `404`. ## The router script Most front-controller apps need every URL that is not a real asset to reach `index.php`. A **router script** gives you that control. It runs first on every request, and its return value decides what happens next: - **returns `false`**: the server continues with its default handling, so an existing asset is served as-is and a requested `.php` file is executed; - **returns anything else, or nothing**: whatever the router printed is the response. A typical router serves existing static files by returning `false` and hands everything else to the application's front controller. Because the router can also check `php_sapi_name() === 'cli-server'`, some projects reuse one front controller file for the built-in server and for production. ## Why it is not for production The manual is explicit: the server is designed to aid development, is not a full-featured web server, and should not be used on a public network. The practical reasons: - **One process, one thread.** By default a single request that blocks, waiting on a slow API for example, stalls every other request. - **Not a full web server.** It has no TLS, no process supervision and none of the access control or tuning a production server provides. - **Worker mode is for testing.** `PHP_CLI_SERVER_WORKERS` lets you test code that needs concurrent requests, not scale a site. In production PHP runs behind a real web server, most often through PHP-FPM or Apache's PHP module, whose configuration is a separate topic. ## Differences you notice during development - It is **not** the plain CLI: the CLI's hardcoded `max_execution_time=0` applies only to the `cli` SAPI, so under `cli-server` the php.ini time limit (30 seconds by default) still applies. - The terminal shows an access log line per request. - The index-file fallback changed in PHP 8.4: it now also applies when the requested path looks like a file (its last segment contains a dot) but does not exist; earlier versions answered `404` straight away. - Headers and output buffering behave as in any web SAPI, since a response is being built for a browser. ## A typical development workflow For the admin UI of a newsletter tool, a developer might work like this: 1. keep public assets and `index.php` under `public/`, and a `router.php` next to it; 2. start `php -S localhost:8000 -t public router.php` in a terminal and leave it running; 3. edit code and reload the browser: changed files are picked up without restarting the server; 4. watch the terminal for the access log and for errors; 5. for a test that fires parallel requests, restart with `PHP_CLI_SERVER_WORKERS` set. When the app is deployed, `router.php` is not used at all: the production web server's own rewrite rules send non-file URLs to `public/index.php`.

  • What does a router script's return false do when the request is for /send.php?
    It hands the request back to the server's default handling. For a path ending in .php that exists under the document root, that means the server executes the file; for any other existing file it sends the file as-is.
  • Why can one slow request make the whole local site hang?
    By default the built-in server is one single-threaded process, so it handles one request at a time. A request blocked on a slow API or a sleep holds every other request behind it. PHP_CLI_SERVER_WORKERS forks several workers where the OS supports it.

saying these in an interview costs you the question

  • php -S is a lightweight production server for small sites.
  • Returning false from the router sends a 404 response.
  • The built-in server handles many requests in parallel by default.
  • php_sapi_name() returns cli inside a request served by php -S.
  • The built-in server needs PHP-FPM running behind it.
open as a page

Why does a PHP cron script run through the CLI never hit max_execution_time, while the same code times out on the web?

level: middleimportance: should knowfreq 48%

basics

~20 s

The CLI SAPI hardcodes max_execution_time=0, meaning no limit, and php.ini cannot change that; web SAPIs use the configured value, 30 seconds by default. The same script is unlimited from cron and cut off on the web.

open as a page

In PHP, how does a script tell whether the CLI or a web server started it, and which names can it get?

level: middleimportance: should knowfreq 45%

basics

~10 s

php_sapi_name() and the PHP_SAPI constant name the server API running the script: cli from a terminal or cron, cli-server under php -S, fpm-fcgi under PHP-FPM, apache2handler under Apache's module, cgi-fcgi under php-cgi.

open as a page

On Linux, why can a PHP web request stuck waiting on a slow external API run far past max_execution_time = 30, and what does set_time_limit() actually reset?

level: seniorimportance: should knowfreq 33%

basics

~20 s

On standard non-thread-safe Linux builds, max_execution_time counts CPU time the process uses, so waiting on I/O such as an API call or query barely counts. set_time_limit() sets a new limit and restarts the counter from zero.

open as a page