PHP Built-in Web Server
What Is the Built-in Web Server?
In simple terms: PHP includes a small web server that you can start with one command. It lets you open your PHP files in a browser without installing Apache or Nginx.
Technically: The built-in web server is a feature of the PHP command-line interface (CLI), available since PHP 5.4. It listens for HTTP requests on a chosen address and port, executes PHP files for those requests, and serves static files such as CSS, JavaScript, and images.
Why It Exists
A browser cannot run PHP. Something must receive the HTTP request, pass the .php file to the PHP engine, and send back the result. Normally that is a full web server such as Nginx with PHP-FPM — powerful, but more setup than you need while learning or building locally. The built-in server removes that setup so you can focus on writing code.
When to Use It
- Learning PHP and running tutorial examples
- Local development of websites, APIs, and front controllers
- Quick demos and testing
When Not to Use It
- Never on a production (live) server. It is designed for development only: it has no HTTPS, very limited performance tuning, and has not been hardened against attacks the way Nginx or Apache have.
- When you need features of a real server, such as
.htaccessrewrite rules, virtual hosts, or HTTPS. Those require Apache, Nginx, or a similar server.
Starting the Server
Syntax
php -S <address>:<port> [-t <document-root>] [router-script]
| Part | Meaning |
|---|---|
-S | Start the built-in server. The capital S is required. |
<address> | The network address to listen on, usually localhost |
<port> | The port number, for example 8000 |
-t <document-root> | Optional. The folder that URLs map to. Defaults to the current directory. |
router-script | Optional. A PHP file that runs for every request (explained later). |
Simple Example
Create a folder with a file named index.php:
<?php
echo "<h1>The server is working</h1>";
echo "<p>Current time: " . date('H:i:s') . "</p>";
Open a terminal in that folder and run:
php -S localhost:8000
The terminal shows a message similar to:
[Thu Oct 8 10:15:02 2026] PHP 8.5.1 Development Server (http://localhost:8000) started
Open http://localhost:8000 in your browser.
Output
The server is working
Current time: 10:15:07
How It Works
- The server listens on port
8000oflocalhost(your own computer). - The URL
http://localhost:8000requests/— the root of the site. - When a URL points to a directory, the server looks for
index.php, thenindex.html, and serves the first one it finds. index.phpis executed, and its output is sent to the browser.- Each request is logged in the terminal:
[Thu Oct 8 10:15:07 2026] [::1]:52344 Accepted
[Thu Oct 8 10:15:07 2026] [::1]:52344 [200]: GET /
[Thu Oct 8 10:15:07 2026] [::1]:52344 Closing
The [200] is the HTTP status code (200 means success). These logs are useful while debugging: you can see every request, including requests for missing files that return [404].
The server keeps running until you stop it with Ctrl + C in the terminal.
Note: The terminal also shows PHP warnings and errors that occur while handling requests, so keep it visible while you work.
How URLs Map to Files
Without a router script, the server maps URLs directly to files inside the document root.
Given this folder:
my-site/
├── index.php
├── about.php
├── css/
│ └── style.css
└── blog/
└── index.php
and the server started inside my-site:
| URL | What the Server Does |
|---|---|
http://localhost:8000/ | Runs index.php |
http://localhost:8000/about.php | Runs about.php |
http://localhost:8000/css/style.css | Sends style.css as a static file with the text/css content type |
http://localhost:8000/blog/ | Runs blog/index.php |
http://localhost:8000/contact.php | Returns 404 Not Found because the file does not exist |
Fallback Behavior for Missing Paths
When a URL does not match a file, the server looks for index.php or index.html in the requested directory. If neither exists, it continues searching in the parent directories up to the document root. If an index file is found, it is executed and the remaining part of the URL is available in $_SERVER['PATH_INFO']. If no index file is found, the response is 404.
This behavior is convenient but can be surprising: a request for /blog/some/unknown/path may be answered by blog/index.php. If you see an unexpected page instead of a 404, this fallback is usually the reason.
Choosing the Document Root With -t
Why a Separate Public Folder Is Used
In real projects, not every file should be reachable through a URL. Configuration files, database credentials, and application code should be outside the folder that the web server exposes. A typical structure is:
my-app/
├── config/
│ └── database.php ← must NOT be accessible by URL
├── src/
│ └── helpers.php ← application code
└── public/
├── index.php ← the only entry point
└── css/
└── style.css
Start the server with public as the document root:
php -S localhost:8000 -t public
What Changes
http://localhost:8000/runspublic/index.php.http://localhost:8000/css/style.cssservespublic/css/style.css.- There is no URL that reaches
config/database.php, because it is outside the document root.
public/index.php can still load application code with paths relative to its own location:
<?php
require __DIR__ . '/../src/helpers.php';
__DIR__ is the directory containing the current file (my-app/public), so /../src/helpers.php points to my-app/src/helpers.php. This works because PHP code can read any file the operating system allows — only URLs are limited to the document root.
Practicing this structure with the built-in server prepares you for production, where Nginx or Apache is configured with the same public folder as its document root. Frameworks such as Laravel and Symfony use exactly this layout.
Using a Router Script
What It Is and Why It Is Needed
Without a router, every URL must match a real file. Modern applications usually prefer clean URLs such as /products/42 instead of /product.php?id=42. On Apache or Nginx, this is done with rewrite rules. The built-in server does not support rewrite rules, but it supports a router script instead.
A router script is a PHP file that runs at the start of every request. It decides how to handle the request.
The rule that controls its behavior:
- If the router script returns
false, the server handles the request normally — for example, it serves the requested static file. - If the router script produces output (or returns anything else), that output is the response.
Basic Router Example
<?php
// router.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/') {
echo '<h1>Home</h1>';
} elseif ($path === '/about') {
echo '<h1>About Us</h1>';
} elseif ($path === '/contact') {
echo '<h1>Contact</h1>';
} else {
http_response_code(404);
echo '<h1>404 - Page Not Found</h1>';
}
Start the server with the router:
php -S localhost:8000 router.php
Output
| URL | Page Shows | Status Code |
|---|---|---|
/ | Home | 200 |
/about | About Us | 200 |
/contact | Contact | 200 |
/pricing | 404 - Page Not Found | 404 |
How the Router Works
$_SERVER['REQUEST_URI']contains the requested path and query string, for example/about?ref=footer.parse_url(..., PHP_URL_PATH)extracts only the path (/about), so query strings do not break the comparisons.- The
if / elseifchain chooses what to output for each path. http_response_code(404)sets the HTTP status code. Without it, the "not found" page would be sent with status200, which misleads browsers and search engines into treating it as a real page.
This router has a problem, though: a request for /css/style.css also falls into the else branch and returns a 404, because the router handles every request, including static files. The next example fixes that.
Practical Example: Front Controller With Static Files
Most PHP applications use a front controller — a single index.php that receives all requests and decides what to do. The following setup works with the public folder structure shown earlier.
public/index.php
<?php
// Let the built-in server deliver existing static files directly
if (PHP_SAPI === 'cli-server') {
$requestedFile = __DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (is_file($requestedFile)) {
return false;
}
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$pages = [
'/' => 'Home',
'/about' => 'About Us',
'/contact' => 'Contact',
];
if (isset($pages[$path])) {
$title = $pages[$path];
} else {
http_response_code(404);
$title = 'Page Not Found';
}
?>
<!DOCTYPE html>
<html>
<head>
<title><?= htmlspecialchars($title) ?></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<h1><?= htmlspecialchars($title) ?></h1>
</body>
</html>
Start the server, using public as the document root and public/index.php as the router:
php -S localhost:8000 -t public public/index.php
Step-by-Step Explanation
PHP_SAPI === 'cli-server'—PHP_SAPIis a constant that identifies how PHP is running. Its value iscli-serveronly under the built-in server. Wrapping the static-file check in this condition means the sameindex.phpworks unchanged on Nginx or Apache later, where the web server delivers static files itself.__DIR__ . parse_url(...)— builds the full filesystem path of the requested URL insidepublic. For/css/style.css, this becomes.../public/css/style.css.is_file($requestedFile)— checks whether that path is an existing file (not a directory).return false;— tells the built-in server: "I am not handling this; serve the file normally." The stylesheet is sent with the correct content type.$pagesarray — maps paths to page titles. Using an array instead of a longifchain makes adding pages a one-line change.isset($pages[$path])— checks whether the path exists as a key in the array.htmlspecialchars($title)— escapes special HTML characters before output. Here the titles are fixed, but escaping every dynamic value is a habit that prevents cross-site scripting (XSS) when values later come from users or databases.<?= ... ?>— short echo tag, equivalent to<?php echo ... ?>.
Expected Behavior
http://localhost:8000/about→ "About Us" page, styled withstyle.csshttp://localhost:8000/css/style.css→ the raw CSS filehttp://localhost:8000/unknown→ "Page Not Found" with status404
This is the same pattern frameworks use: one entry point, clean URLs, and static assets served directly.
Useful Variations
Use a Different Port
php -S localhost:8080
Use another port when 8000 is already taken by another program.
Pass Configuration Values
The -d option sets a php.ini value for this server only:
php -d display_errors=1 -d error_reporting=E_ALL -S localhost:8000
This is useful when you want to enable error display temporarily without editing php.ini.
Use a Specific php.ini File
php -c ./php-dev.ini -S localhost:8000
-c makes PHP load the given configuration file instead of the default one.
Access the Server From Other Devices
php -S 0.0.0.0:8000
0.0.0.0 means "listen on all network interfaces". Other devices on the same network — such as your phone — can then open http://<your-computer-ip>:8000 to test the site on a real mobile screen.
Warning: Only use
0.0.0.0on a trusted network. Anyone who can reach your computer on that port can access the site, including any debug pages or test data.
Handle Several Requests at Once
By default, the built-in server handles one request at a time. On Linux and macOS, you can start multiple worker processes:
PHP_CLI_SERVER_WORKERS=4 php -S localhost:8000
This feature is available since PHP 7.4, is marked experimental, and is not supported on Windows.
Common Mistakes
Using a Lowercase -s
php -s localhost:8000
What happens: PHP does not start a server. Lowercase -s is a different option (it outputs syntax-highlighted source).
Fix: Use the capital -S.
"Address already in use"
[Thu Oct 8 10:20:00 2026] Failed to listen on localhost:8000 (reason: Address already in use)
Why it happens: Another program — or another PHP server you forgot to stop — is using the port.
Fix: Stop the other process (Ctrl + C in its terminal) or choose another port such as 8001.
Starting the Server in the Wrong Folder
What happens: Every page returns 404 Not Found.
Why it happens: Without -t, the document root is the directory where you ran the command. If you started the server in the parent folder, URLs no longer match your files.
Fix: Run the command inside the project folder, or specify the folder with -t.
Page Hangs When It Calls Its Own Server
What happens: A script uses file_get_contents('http://localhost:8000/api.php') or cURL to call an endpoint on the same server, and the page never finishes loading.
Why it happens: The single-process server is busy handling the first request, so it cannot answer the second one. Each request waits for the other — a deadlock.
Fix: Use PHP_CLI_SERVER_WORKERS (Linux/macOS), run the API on a second server instance with a different port, or call the code directly instead of over HTTP.
Expecting .htaccess Rules to Work
What happens: Clean URLs that work on Apache return 404 locally.
Why it happens: .htaccess is an Apache feature. The built-in server ignores it.
Fix: Use a router script, as shown above.
Using It in Production
Why it happens: It is easy to start, so it may seem like a quick way to host a site.
Why it is a problem: It lacks HTTPS, process management, request limits, and the security hardening of production servers. Under load, it becomes slow or unresponsive.
Fix: Use Nginx or Apache with PHP-FPM for any live site. See the PHP-FPM lesson.
Best Practices
- Mirror production structure locally. Use a
publicfolder with-t publicso that file exposure works the same way locally and on the live server. - Use one front controller. Route all requests through
public/index.phpand keep thecli-serverstatic-file check so the same file works everywhere. - Always set correct status codes. Use
http_response_code()for 404 and other errors, so browsers, APIs, and search engines receive accurate responses. - Keep the terminal visible. Request logs and PHP errors appear there first.
- Save the start command. In larger projects, store it in a script or in
composer.jsonso every developer starts the server the same way:
{
"scripts": {
"serve": "php -S localhost:8000 -t public public/index.php"
}
}
Then run composer serve. Composer scripts are covered in the Composer lesson. Note that Composer scripts have a default timeout of 300 seconds; for a long-running server, either add "process-timeout": 0 under "config" in composer.json or prefix the command with Composer0Config::disableProcessTimeout in the script list.
Official reference: https://www.php.net/manual/en/features.commandline.webserver.php