Skip to content
beginner Phase 1 · Environment Setup and Language Fundamentals

Installing PHP and a Local Server

Set up a PHP runtime with a bundled web server for local development.

35m
0 problems
Topic Progress 0%

Getting PHP onto Windows, macOS, or Linux

A teammate hands you a Laravel repository and says "just run php artisan serve". Your machine has no PHP at all. Two desks over, someone else has three PHP versions installed, and each one resolves differently depending on whether they type the command in a terminal, an IDE task, or a CI container. Before you write a line of PHP, settle two questions: where does a trustworthy build come from on your platform, and how do you prove your shell is running the one you just installed?

Official PHP binaries are published for Windows, macOS, and Linux, and there is also a nightly development snapshot channel — the master-branch builds on windows.php.net and the alpha/beta/RC releases on php.net — used to test what will become the next version. You rarely need snapshots; you do need to know they exist so a bug report saying "reproduced on master builds" makes sense.

Windows. The download page offers x64 Thread Safe (TS) and Non Thread Safe (NTS) zips built with Visual Studio. Thread safety only matters under a multi-threaded SAPI such as Apache's mod_php, where the engine must coordinate between request threads. For the CLI, the built-in server, and php-fpm/IIS FastCGI, NTS is the correct choice. The zip is self-contained:

c:\php\
  php.exe                 the CLI and the built-in web server
  php8.dll                the engine
  ext\                    extension DLLs (php_pdo.dll, php_mysqli.dll, php_curl.dll ...)
  php.ini-development     dev defaults: display_errors=On, error_reporting=E_ALL
  php.ini-production      hardened defaults you ship with

Unzip it, copy php.ini-development to php.ini, and add c:\php to the system PATH. Two things bite here: the Microsoft Visual C++ Redistributable must be installed or php.exe exits instantly with error 0xc0000135 (DLL load failed), and PATH edits do not apply to terminals that were already open — start a new one before testing.

macOS. brew install php puts the current stable under the Homebrew prefix (/opt/homebrew on Apple Silicon, /usr/local on Intel). Homebrew keeps the versioned keg on disk, so when the formula moves from 8.3 to 8.4 your older projects can still be pointed at the previous binary instead of breaking silently. Homebrew only tracks the current stable formula, though; to run 8.1 and 8.3 side by side on a Mac, add the shivammathur/php tap (brew install shivammathur/php/php@8.1) or use phpenv, which compiles each version under ~/.phpenv/versions and switches per directory.

Linux. Distribution packages are the default: sudo apt install php8.3-cli on Ubuntu 24.04, sudo dnf install php-cli on Fedora. They lag behind php.net by design. When a project needs a newer 8.x on Debian or Ubuntu, the ondrej/php PPA ships every supported branch side by side: sudo add-apt-repository ppa:ondrej/php && sudo apt update && sudo apt install php8.3-cli.

Hermetic alternative. For a repository shared across laptops and CI, the official Docker image pins the runtime exactly:

docker run --rm -v "$PWD":/app -w /app php:8.3-cli php index.php

CI runs the same image, so "works on my machine" stops being a coin flip.

Whichever route you took, finish with the same three checks — command -v php (which file executes), php -v (which version that file is), php --ini (which configuration it loads). Those three lines are what you paste into any "PHP doesn't work on my box" report, and they are the first thing an interviewer will watch you run.

Serving a Project with php -S and a Router Script

You do not need Apache or Nginx to see a PHP page. The PHP binary ships a development web server, and starting it is one command:

php -S localhost:8000

That binds 127.0.0.1:8000, sets the document root to your current directory, and serves .php files by executing them. Open http://localhost:8000/index.php and the output of your script comes back. Two flags adjust the defaults: -t public moves the document root (the convention for every modern framework, so public/index.php is the front controller and src/ is never web-reachable), and binding 0.0.0.0 instead of localhost lets a phone or another laptop on your network reach the server during a demo.

php -S 0.0.0.0:8080 -t public          # serve ./public to the LAN

Three properties of this server decide how you use it.

It is single-threaded. One request is handled at a time. A page that triggers five parallel image or XHR requests queues them behind each other, which is why a page can feel sluggish locally and be instant in production. On Linux and macOS you can fork workers with an environment variable, available since PHP 7.4:

PHP_CLI_SERVER_WORKERS=4 php -S localhost:8000

It has no per-directory configuration. There is no .htaccess, no <Directory> blocks, no mod_rewrite. If your deployment relies on Apache rewriting /users/42 into index.php?action=user&id=42, you reproduce that behavior with a router script — any PHP file you pass as the last argument runs before every request:

<?php
// router.php — front controller with a static-file escape hatch
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path !== '/' && file_exists(__DIR__ . '/public' . $path)
    && !is_dir(__DIR__ . '/public' . $path)) {
    return false;   // let the server stream the static file itself
}

require __DIR__ . '/public/index.php';

Returning false hands the request back to the server to serve the file from disk; anything else — a rendered response, a require, a redirect — means your script handled it completely. Start it with php -S localhost:8000 router.php and clean URLs work the same way they will behind Nginx.

It is a development tool, not infrastructure. It has no access logs worth shipping, no TLS, no slow-client handling, and it executes every request in one process, so a exit or a fatal error kills the whole server rather than one request. Treat it exactly like a webpack dev server: great for the edit-refresh loop, never the thing you deploy. For anything shared, run php-fpm behind a real web server instead.

Two smaller behaviors are worth knowing before they surprise you. When the server starts it prints Listening on http://localhost:8000, Document root is ..., and then one line per request — [Wed Oct 08 2025 10:14:22] 127.0.0.1:53111 [200]: GET /style.css — which is all the logging you get. And for a directory path the server tries index.php first, then index.html, so php -S localhost:8000 -t public already serves a framework front controller at / without a router; the router only becomes necessary when a URL maps to no real file on disk.

php artisan serve in Laravel and symfony server:start are thin wrappers over this exact binary — they add a port flag, a file-watcher restart, and a prettier banner. The day the wrapper is unavailable (inside a minimal Docker container, a CI job, a pair-programming VM) the underlying command is php -S 127.0.0.1:8000 -t public index.php, and knowing it directly saves the session. One demo-day trap: sessions. The built-in server holds the session file lock for the duration of a request, and since parallel requests now serialize behind both the lock and the single thread, a chatty page can feel several times slower locally than behind php-fpm.

The habit worth keeping: keep the router script in the repository. It documents your URL-to-script mapping, it makes the built-in server behave like production, and it costs ten lines.

Reading php -v, php --ini, and php -m Before You Blame the Code

"Undefined function str_contains()" is reported as an application bug roughly once a week in every PHP shop. Almost every time it is an environment fact: the wrong interpreter, the wrong php.ini, or the wrong extensions. Three commands answer all three questions in under five seconds.

php -v
PHP 8.3.11 (cli) (built: Aug 29 2024 18:02:49) (NTS Visual C++ 2019 x64)
Copyright (c) The PHP Group
Zend Engine v4.3.11, Copyright (c) Zend Technologies

The first line carries everything: 8.3.11 is the version, (cli) is the SAPI (the same binary also serves php -S; under Apache you would see apache2handler, under php-fpm fpm-fcgi), NTS confirms a non-thread-safe build, and the build date tells you how stale a distro package is. In a monorepo, add the version to your shell prompt or check it in a composer.json require.php constraint so a stale machine fails fast instead of halfway through a feature.

php --ini
Configuration File (php.ini) Path: C:\php
Loaded Configuration File:         C:\php\php.ini
Scan for additional .ini files in: C:\php\ext

On many Linux installs the middle line reads (none) — no php.ini was ever installed, so every setting is a compiled default and every extension is off. That single line explains most "it works locally, not on the server" reports. php -i dumps the full resolved configuration (it is phpinfo() from the shell) and php -m lists the compiled-and-loaded modules; grepping those two beats guessing.

Why the version number matters so much: PHP 8.x changed language semantics, not just speed. PHP 8.0 introduced match, named arguments, union types, attributes, and changed string-to-number comparison ("abc" == 0 became false; it was true before). PHP 8.1 added enums, readonly properties, and never; PHP 8.2 readonly classes and null/false in union types; PHP 8.3 typed class constants and json_validate(); PHP 8.4 property hooks and asymmetric visibility. On top of that, PHP 8.0 promoted several notices to warnings and turned reading a property of null into a warning that surfaces through error_reporting. Code written for 8.3 does not run on 7.4, and code written for 7.4 can silently misbehave on 8.3 because comparison semantics changed underneath it.

When a machine carries several versions, the winner is decided by PATH order, not by installer date. Debian and Ubuntu expose that explicitly:

update-alternatives --config php    # pick among /usr/bin/php8.2, php8.3 ...
php -v                              # confirm the choice actually took

For a runtime fact without a file, php -r runs a snippet straight from the command line — the fastest sanity check there is:

php -r 'echo PHP_VERSION, " ", PHP_SAPI, PHP_EOL;'
php -r 'var_dump(extension_loaded("curl"), function_exists("str_contains"));'

The interpreter exports facts about itself as constants: PHP_VERSION, PHP_OS_FAMILY, and PHP_BINARY — the exact path of the running interpreter, which settles cron-versus-shell PATH disputes in one line. extension_loaded() and function_exists() are how application code should fail fast on a misconfigured host, and they answer the same question php -m answers for you interactively.

One subtlety: php --ini describes the CLI SAPI only. Every SAPI loads its own configuration — php-fpm reads /etc/php/8.3/fpm/php.ini plus its pool files, Apache's mod_php reads /etc/php/8.3/apache2/php.ini — so a memory_limit you raised in the CLI file never reaches the web process. Three SAPIs, three configurations, and the only way to see the web one is phpinfo() rendered in a browser. Many teams still pin the version in composer.json as the first line of defense:

{ "require": { "php": "^8.3" } }

With that constraint, an 8.1 machine is stopped at composer install with "your php version (8.1.25) does not satisfy that requirement" instead of failing three features later.

Make the three-command check — command -v php, php -v, php --ini — the reflex before you debug any PHP code you did not just write. You are localizing the fault to the environment layer first, which is the only way to avoid "fixing" application code that was never broken.

Quiz

1. On Windows you download the PHP zip, add the directory to PATH, and `php.exe` exits immediately with error 0xc0000135. What is missing?

Question 1 options

2. You run `php -S localhost:8000 router.php` and the router needs the server itself to deliver an existing CSS file. What must the router do?

Question 2 options

3. `php --ini` reports `Loaded Configuration File: (none)` on a Linux server. What does that mean?

Question 3 options

4. A page makes five parallel XHR requests to the built-in server and they are answered one after another. What explains the queueing?

Question 4 options

Flashcards

Question

Which PHP build should you pick on Windows for CLI and the built-in server, and what is Thread Safe for?

Answer

Non Thread Safe (NTS). Thread Safety only matters under a multi-threaded SAPI such as Apache mod_php, where request threads share the engine; the CLI, the built-in server, and php-fpm/IIS FastCGI never need it.

Question

What three commands verify a fresh PHP install, and what does each answer?

Answer

`command -v php` (which binary the shell resolves), `php -v` (version, SAPI, NTS/TS, build date), `php --ini` (which php.ini is loaded, or (none)). Together they localize any PHP problem to binary, version, or configuration.

Question

How do you run the built-in server against a public/ document root, and how do you make clean URLs work?

Answer

`php -S localhost:8000 -t public`. For clean URLs pass a router script: `php -S localhost:8000 router.php`, where the script requires the front controller for app routes and `return false` lets the server stream existing static files.

Question

What are the built-in server's three hard limits compared with Apache or php-fpm?

Answer

It handles one request at a time (mitigate with PHP_CLI_SERVER_WORKERS on Linux/macOS), it has no per-directory config (.htaccess, rewrite rules), and it is not production infrastructure: no TLS, no slow-client handling, and a fatal error kills the process.

Question

Name two language-semantics changes in PHP 8.x that break or silently alter older code.

Answer

PHP 8.0 changed string-to-number comparison so `"abc" == 0` is now false (it was true), and promoted notices to warnings (reading a property of null, undefined variables). Later versions add enums and readonly (8.1), typed class constants (8.3), and property hooks (8.4), none of which parse on 7.4.

Revision Notes

Key Takeaways

  • 1. Official builds exist for Windows (NTS zip + php.ini-development), macOS (brew, versioned keg), and Linux (distro packages, ondrej/php PPA for newest 8.x), with Docker as the hermetic option
  • 2. The built-in server (`php -S`) is a one-command dev loop: `-t public` for the document root, a router script whose `return false` serves static files, PHP_CLI_SERVER_WORKERS for concurrency
  • 3. It is single-threaded and has no .htaccess support — a dev tool, never deployment infrastructure
  • 4. `php -v`, `php --ini`, and `php -m` answer which binary, which config, and which extensions in seconds; (none) in php --ini means compiled defaults
  • 5. PHP 8.x changed semantics, not only performance: match/named args (8.0), enums/readonly (8.1), typed constants (8.3), property hooks (8.4), plus flipped comparison rules

Interview Tips

  • • "Set up PHP on a fresh machine" — answer in three moves: install the NTS build for your platform, copy php.ini-development to php.ini, then prove it with command -v php, php -v, php --ini
  • • "What is the built-in server good for and not good for?" — dev loop only: single-threaded, no per-directory config, no TLS; name PHP_CLI_SERVER_WORKERS and the router script as the two escape hatches
  • • "Why does my code say undefined function str_contains()?" — frame it as an environment problem: the resolved interpreter predates 8.0, check `php -v` and PATH order before touching code
  • • "How do you keep local and CI PHP in sync?" — pin the same Docker image (php:8.3-cli) or add a composer.json `require.php` constraint so a mismatched machine fails immediately

Cheat Sheet

Installing PHP and a Local Server

Install sources:

Platform Route Watch out for
Windows x64 NTS zip from windows.php.net copy php.ini-development → php.ini; VC++ Redistributable required (else 0xc0000135); new terminal after PATH edit
macOS brew install php versioned keg kept; command -v php decides which binary wins
Linux apt install php8.3-cli / dnf install php-cli; ppa:ondrej/php for newest 8.x distro builds lag; update-alternatives --config php switches versions
Hermetic docker run --rm -v "$PWD":/app -w /app php:8.3-cli php index.php same image locally and in CI

Built-in dev server:

php -S localhost:8000               # docroot = cwd, single-threaded
php -S 0.0.0.0:8080 -t public       # custom docroot, LAN-visible
PHP_CLI_SERVER_WORKERS=4 php -S localhost:8000   # fork workers (7.4+)
php -S localhost:8000 router.php    # router: `return false` serves the static file

No .htaccess, no TLS, no slow-client handling — development only.

Environment checks (run these before debugging code):

command -v php      # which binary
php -v              # version + SAPI + NTS/TS + build date
php --ini           # which php.ini ( "(none)" = everything at compiled defaults )
php -m              # loaded extensions
php -i | grep x     # full resolved config

Version semantics that bite:

Version Added / changed
8.0 match, named args, union types, attributes, JIT; "abc" == 0 is now false
8.1 enums, readonly properties, never
8.2 readonly classes, null/false standalone types
8.3 typed class constants, json_validate()
8.4 property hooks, asymmetric visibility

Continue Learning

Before You Start

  • No prerequisites listed.

Next Topics

Related Reading