Most PHP developers are aware that PHP has included a built-in webserver aimed at development since the days of PHP 5.4. Despite already being a decade old that tool never got any significant traction in the community, and most people prefer working with more realistic (but complex) setups based on Apache or nginx with php-fpm.

In my opinion this happened because instead of being a simple static file server with the added ability to interpret PHP files it has some complex logic that is not really needed in modern PHP apps, and often behaves in ways that are not very intuitive.

In this post I will try my best to explain how this server actually works, then show you how to force it to behave in a similar way to nginx+php-fpm so that you can add it to your toolset and use it when you need a really simple development setup1.

The (re)cursed algorithm

Without further ado, here’s how the official documentation describes its behavior:

URI requests are served from the current working directory where PHP was started, unless the -t option is used to specify an explicit document root. If a URI request does not specify a file, then either index.php or index.html in the given directory are returned. If neither file exists, the lookup for index.php and index.html will be continued in the parent directory and so on until one is found or the document root has been reached. If an index.php or index.html is found, it is returned and $_SERVER[‘PATH_INFO’] is set to the trailing part of the URI. Otherwise a 404 response code is returned.

If a PHP file is given on the command line when the web server is started it is treated as a “router” script. The script is run at the start of each HTTP request. If this script returns false, then the requested resource is returned as-is. Otherwise the script’s output is returned to the browser.

That is quite a mouthful. Even the precedence of the index files is not very clear2. Let’s break down the first paragraph into simpler steps:

  1. PHP determines the document root. By default it’s the current directory, but this can be customized with the -t option.
  2. On each request it first tries to serve the URI as a static file (or execute it if it ends in .php) and if this file exists then it’s done.
  3. If the URI is a directory or doesn’t exist it tries to execute an URI/index.php file. If this file doesn’t exist either, it tries to serve a URI/index.html file.
  4. If neither of the previous files existed, it backs up one level in the directory hierarchy and tries to execute index.php or serve index.html again.
  5. This process goes on until a suitable index.php or index.html file is found, or the execution reaches the root directory (/). In that case the server returns a hardcoded HTTP 404 HTML response.

The second paragraph adds an optional first step, let’s call it zero:

  1. If a path to a PHP script is supplied in the command line, it will act as the "router script". On each request this script is the first thing that runs. If it returns bool(false) the server will proceed to step 1. If it doesn’t return false the server won’t do anything else, and whatever this script outputs will be served as the final response.

This step-by-step explanation might be a bit more explicit, but I think it still needs an example to fully digest it. And notice a few of the footguns lying around with this approach.

Our toy application

Let’s pretend we are developing a traditional Symfony-ish application. Here’s the rough directory hierarchy:

├── composer.json
├── composer.lock
├── public/
│   ├── app.php
│   ├── about/
│   │   └── index.html
│   └── assets/
│       ├── css/
│       │   └── bulma.css
│       └── js/
│           └── htmx.js
├── src/
├── tests/
├── vendor/

The document root is the public directory, with some static files and a single php script, app.php, the front controller. We want to serve these static files directly, without hitting PHP at all. Only when the browser requests a URI that does not point to a static file will we want to forward the request to the front controller.

To sum up, we want the sort of setup you’d implement using nginx’s try_files directive.

Finally, we base our front controller on the sample router scripts number #3 and #4 from the documentation:


// webapp/public/app.php

// When the PHP server is running let it handle URIs that end in .css, .js or .html
if (php_sapi_name() === 'cli-server' && preg_match('/\.(?:css|js|html)$/', $_SERVER['REQUEST_URI'])) {
    return false;

// Otherwise bootstrap and run our Symfony application
// ...

Let’s chdir into webapp/, run the server like this: $ php -S -t public public/app.php and see a couple edge cases.

Sample Request 1: GET /assets/css/bootstrap.css

  • Desired behavior:
    1. Server tries to serve /assets/css/bootstrap.css, fails and forwards request to app.php
    2. Symfony performs routing, doesn’t find a route for /assets/css/bootstrap.css and returns your custom 404 page
  • Actual behavior:
    1. app.php runs and returns false (URI ends in .css), so the PHP server handles the request
    2. Server tries to serve /assets/css/bootstrap.css, fails
    3. Server tries to run /assets/css/index.php, fails
    4. Server tries to serve /assets/css/index.html, fails
    5. Server tries to run /assets/index.php, fails
    6. Server tries to serve /assets/index.html, fails
    7. Server tries to run /index.php, fails
    8. Server tries to serve /index.html fails
    9. Server returns its hardcoded HTML 404 page3

What’s specially insidious in this example is that in both scenarios you get a 404 response, but they are not the same and you arrive at them in totally different ways. If you don’t have a clear mental model of how the built-in server works this behaviour would probably leave you scratching your head for a good while. That is, if you pay attention and happen to notice that you don’t receive the expected 404.

Sample Request 2: GET /about

  • Desired behaviour:
    1. Server serves /about/index.html and does not run PHP at all
  • Actual behaviour
    1. app.php does not return false, so it runs Symfony and returns your custom 404 page

Again, quite an unintuitive resolution.

The Problem

I could possibly come up with a few more edge cases but I think that’s enough to understand the root cause of the problem.

Simply put, the server runs backwards. We don’t want a “router script” that runs first and has the power to activate or disable static file serving. We want to run static file serving first and then forward the requests that don’t match a file in the document root to a “front controller”.

Moreover, if the server cannot serve a static file we want to avoid that recursive fallback algorithm altogether. Of all that functionally, at most we want to keep serving URI/index.html when URI is a directory and contains an HTML index file, but nothing else.

Needless to say we cannot do any of this without hacking on PHP’s source code. But fortunately we can still write a router script that forces the server to behave like that.

A router script to rule them all

This one can (and should) go outside the document root (at webapp/cli-app.php for instance), and should work almost verbatim in any codebase:


// webapp/cli-app.php

// $_SERVER['DOCUMENT_ROOT'] is the absolute path to the public directory in your filesystem, e.g. /var/www/webapp/public
// $_SERVER['REQUEST_URI'] is the URI of the HTTP request, e.g. /assets/css/bulma.css

// If $path is a direct file hit let the cli server handle this simple case.
if (is_file($path)) {
    return false;

// If $path is a directory and contains an index.html file let the
// cli server handle it, because we know it _will_ serve that index.html
if (is_dir($path) && is_file("$path/index.html")) {
    return false;

// All other cases should be handled by the real front controller
require_once __DIR__ . '/public/app.php';

Even though we cannot change the server’s behaviour we can exploit to great effect the fact that the router script is always the first thing that runs. So we only allow the builtin server to handle two very narrow cases (the static file hits) that we know that won’t trigger any of the unwanted functionality. This way everything but the static files end up being served by the front controller.

Since we’ve decoupled the router script from the front controller we don’t need to call php_sapi_name() to check if it’s value is 'cli-server'. The production web server will simply ignore the router script because it’s not even inside the document root.


Now that we’ve tamed the PHP server we can safely and easily replace nginx+php-fpm with it if we want:

# docker-compose.yml
version: "3.8"

    image: php:8.1
    command: php -S -t public cli-app.php
      - PHP_CLI_SERVER_WORKERS=4 # PHP +7.4 only. Allows serving more than one request at the same time
      - ""
      - .:/var/www/webapp
    working_dir: /var/www/webapp

I’ve set up a repository in my GitHub profile to showcase the concept and play with it, you can find it here.

  1. For instance, for a demo project

  2. index.php is always first, according to the ultimate source of truth

  3. Here’s a little visual if you’ve never seen it: