PHP in 2026
Installing PHP and Using the Built-in Server
What a modern PHP installation actually consists of, how to inspect it, and how to serve a project locally with php -S and a router script.
The site works on your machine. Then a colleague pulls the branch, runs it, and gets
a white page. The log says Call to undefined function mb_strlen(). In their
terminal, php -m lists mbstring without complaint. Both
statements are true at once, and the reason is the thing this lesson is about: PHP is
not one thing on your machine, it is several, and they read different configuration.
Most PHP trouble in the first week of a project comes from not knowing which of those PHP installations is answering. You can fix that permanently by learning three commands and one mental model, and the model is the reason the commands matter.
A 2026 PHP installation means 8.3 or newer
PHP 8.2 leaves security support in December 2026, which makes it the wrong floor for anything that will still be running next year. PHP 8.3 is the oldest branch worth starting a project on, with security support running to the end of 2027. The 8.4 and 8.5 lines are the current ones. The examples in this module were run here on 8.5.6, and the code is written to run on 8.3 and newer unless a lesson says otherwise.
The version number you see belongs to that invocation, not to the one your
web server uses. On a typical Linux host, /usr/bin/php is the CLI
build, while the web server loads a separate FPM binary from a different path. They are
often installed from the same package, and they are still distinct processes with
distinct configuration files. Check both before you conclude anything.
Version floors, not version targets
The PHP examples in this module were executed on 8.5.6 and written to run on 8.3 or newer. Where a feature arrived later than 8.3, the lesson names the release. The Composer commands are described rather than run: Composer was not installed on the machine that checked this lesson.
Three commands tell you what you are running
Learn these first, because every later problem is easier with them.
php -v # version, build date, whether OPcache is loaded
php -m # the extensions this build has compiled and enabled
php --ini # which php.ini files this binary reads, in order
Those three look trivial and they answer most configuration questions. php
-v also tells you the SAPI you are in, indirectly: the first line ends with
(cli). The web server binary reports something else, usually
(fpm-fcgi) or (cgi-fcgi), and the built-in server reports
(cli-server). When a bug only appears over HTTP, that parenthetical is the
first clue.
php -m prints one extension per line under [PHP Modules],
then a second section for the ones compiled in, then the Zend extensions. Do not read
the two sections as enabled versus available; both lists are loaded in that process. If
you need the full details, including the exact INI value in force, php -i
dumps everything. It is long, so pipe it:
php -i | grep -i "loaded configuration"
php -r 'var_dump(ini_get("memory_limit"), ini_get("display_errors"));'
I reach for the second form. ini_get() reports what the
running process decided after every INI file and every command-line override was
applied, which is the only answer that describes the request you are debugging. On the
machine used for this lesson, php -i shows a configuration path of
/opt/homebrew/etc/php/8.5, a loaded file of
/opt/homebrew/etc/php/8.5/php.ini, and a scan directory of
/opt/homebrew/etc/php/8.5/conf.d. On Debian and Ubuntu, the scan directory
is where php8.3-mbstring and friends leave their own .ini
files, which is why installing an extension package is not the same as editing
php.ini.
php.ini is found, not chosen
PHP searches a fixed list of locations when it starts. It checks the compiled-in
default path first, then the directory reported by php --ini as the scan
directory, where each additional .ini file is read in alphabetical order.
Later files win, which is how distribution packages enable extensions without touching
the main file.
php --ini prints all of it in the order it happens:
Configuration File (php.ini) Path: /opt/homebrew/etc/php/8.5
Loaded Configuration File: /opt/homebrew/etc/php/8.5/php.ini
Scan for additional .ini files in: /opt/homebrew/etc/php/8.5/conf.d
Additional .ini files parsed: (none)
That output came from the CLI binary. Run the same command against the FPM binary and
you may get a different answer, because FPM is a separate executable that resolves its
own configuration path relative to its own compiled-in prefix. The authoritative check
is not the command line at all: write a script that dumps
php_ini_loaded_file() and request it through the web server. That tells you
which configuration your users are hitting.
Two flags are worth knowing for local work. php -n ignores every INI file,
which is useful for proving that a behaviour comes from configuration rather than from
code. php -d memory_limit=512M script.php overrides one setting for one
run, which is faster than editing a file and remembering to undo it.
The built-in server is a real server with a small memory
Since PHP 5.4 the CLI binary ships a web server, and it is enough for development.
php -S localhost:8000 -t public
The -S flag takes an address and port. -t sets the document
root, and you should almost always pass it: the default is the current directory, which
means php -S localhost:8000 run from a project root will happily serve your
.env and vendor/ over HTTP. The server logs each request to
standard error, so a 404 appears in your terminal rather than in a file you have to
find.
It handles one request at a time. This is the detail people forget when they write a
page that makes two of its own endpoints fetch from each other, or open two browser tabs
against a slow endpoint, and the second request appears to hang. The server is busy until the first one
finishes. If you need parallel requests locally, the environment variable
PHP_CLI_SERVER_WORKERS spawns extra worker processes on systems that
support forking, and a container or a real FPM setup is the more honest answer.
The other difference from production is that cli-server shares the CLI's
INI handling. Extensions you enabled for the command line are usually the ones it uses,
which is convenient and is exactly why it is a bad proxy for your production
configuration.
A router script turns the file tree into an application
Out of the box the server maps the request path onto the file tree: a request for
/about.php runs that file, a request for /style.css returns it
as-is, and a request for /nope looks for an index.php in that
directory and then, failing that, returns 404. Real applications do not want that. They
want /products/42 to reach one front controller that decides what to do.
The optional third argument to php -S is a router script, and it is just
a PHP file that runs on every request:
php -S localhost:8000 -t public public/router.php
The contract is small and worth memorising. The router runs first. If it returns
false, the server falls back to its normal file handling for that path,
which is how you let the web server serve images and stylesheets without routing them
through PHP. If it returns anything else, whatever it echoed is the response, and the
server does not look for a file. So the router's job is to answer one question: does
this request belong to a real file, or to the application?
<?php
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = __DIR__ . '/public' . $path;
if ($path !== '/' && is_file($file)) {
return false;
}
echo "router handled ", $path, PHP_EOL;
Save that as router.php next to public/, start the server
with the command above, and a request to /products/42 prints
router handled /products/42, while /logo.svg is returned by the
server as a static file. Two caveats. *_SERVER['REQUEST_URI'] contains the
query string, so split it with parse_url() before you use it as a path.
And the concatenation above assumes the router sits one level above the document root;
if the two are in the same directory, build the path from the document root instead.
The failure mode: the CLI and the web server disagree
This is the bug from the opening paragraph, and now you can name it. The CLI build
and the FPM build read different php.ini files and load different extension
sets. A dedicated PHP install may compile mbstring for both; a distribution
package may ship php8.3-cli and php8.3-fpm separately, with
extensions enabled per package. When only one of them has an extension, code that works
in the terminal fails over HTTP with an undefined function error.
Diagnose it by asking each process the same question. Write a file and request it:
<?php
declare(strict_types=1);
$checks = ['mbstring', 'curl', 'intl', 'pdo_sqlite'];
foreach ($checks as $extension) {
printf(
"%-12s %s\n",
$extension,
extension_loaded($extension) ? 'yes' : 'no',
);
}
echo php_sapi_name(), ' | ', PHP_VERSION, ' | ', php_ini_loaded_file(), PHP_EOL;
Run it with php checks.php and again through the web server. When the
two outputs differ, you are looking at a configuration split, not a code bug, and the
fix belongs in the extension setup for the SAPI that is missing it. Changing the code
to avoid the extension is a real option too, and the wrong one when the extension is
doing work you actually need.
The limits of the built-in server are easy to forget. It does not do TLS, it
has no process manager, its .htaccess support does not exist, and its
routing differs from nginx or Apache in ways that will bite you if you rely on it to
develop URL rewriting. Use it to run your code, not to validate your production web
server configuration.
Create a directory with this layout:
lab/
public/
index.php
style.css
src/
config.php
router.php
Give public/index.php a page that prints php_sapi_name(),
PHP_VERSION, php_ini_loaded_file() and the result of
extension_loaded() for two extensions of your choice.
public/style.css can hold one rule.
Write router.php so that requests for / and any path
without a matching file are handled by public/index.php, while requests
for real files are served directly by the server. Start the server with
php -S localhost:8000 -t public router.php, then check three URLs: the
root, /style.css, and /products/42.
Finally, put a deliberate typo in a function name inside src/config.php
and have index.php require it. Describe in one sentence what the browser
shows and why the page is not simply blank or cleanly error-free.
Show the solution and the reasoning
The important design decision is the router's one job: decide between a static file and the application. Everything else follows from that.
<?php
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$documentRoot = __DIR__ . '/public';
if ($path !== '/' && is_file($documentRoot . $path)) {
return false;
}
require $documentRoot . '/index.php';
Returning false is where people go wrong. It is not "not found" and
it is not an error: it tells the built-in server to continue with its normal handling,
which returns the file with a sensible content type. Echoing the CSS from PHP instead
would work for the browser and would also mean every image on the site goes through
your router.
Using require rather than include for the front controller
is deliberate. If the file is missing, you want a fatal error, not a blank page. The
$path !== '/' guard stops the root request from being treated as a file
named by the empty string, which some filesystems will happily accept as a directory.
For the typo, the browser shows the HTML that had already been echoed before the
fatal error, followed by the error message. On this machine, with
display_errors on, the response contains the partial page and then
Uncaught Error: Call to undefined function. That is the request lifecycle
from the next lesson in miniature: PHP streams output as it goes, so an error halfway
down the file cannot retract the bytes already sent. On a host with
display_errors off, you get the same partial page with nothing after it,
which is worse to debug. Check the server's error log, not the browser.
Two reasonable variants exist. You can move the file-existence check into an explicit content-type map, which is more code for no benefit here. Or you can skip the static branch entirely and route everything through PHP, which is fine for an API with no assets and wrong for a site with a stylesheet.
Progress
Mark this lesson done and it stays marked in this browser.