PHP Basics — Modern Server-Side Scripting
Status: Active | Last Updated: 2026-08-29 Category: Languages — PHP Prerequisites: Basic programming concepts (see kb/basics/) Tags: php, web, server, composer, scripting Estimated Time: 4-6 hours (Self-paced, includes lab time)
Summary
PHP powers a large portion of the web (WordPress, Drupal, many CMS and e-commerce platforms). Modern PHP (8.0+) has features — typed properties, union types, attributes, named arguments — that make it a modern, competitive language. This article covers PHP syntax, modern features, and when PHP makes sense.
What You'll Learn (Core Competencies)
- Write idiomatic PHP 8+ with strict types, typed properties, and constructor property promotion
- Use modern PHP 8 features: named arguments, union types, attributes, enums, match expressions
- Manage dependencies and autoloading with Composer and PSR-4
- Apply the right control flow primitives (
match, null-safe operator, null coalescing) for cleaner code - Decide when PHP is the right choice vs TypeScript, Python, or Go
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Why PHP
PHP is easy to deploy: upload a .php file to a web server and it runs. It requires no build step, no separate runtime installation (the server has PHP built in or installed), and integrates naturally with HTML. Modern PHP has improved dramatically:
- Typed properties (
public string $name) - Union types (
string|int) - Attributes for metadata (like annotations)
- Named arguments (
function greet(name: "world")) - JIT compilation in PHP 8 for performance
The Request/Response Execution Model
PHP is a shared-nothing, request-scoped language. Every HTTP request (or CLI invocation) starts with a fresh PHP process, executes the script, returns the response, and exits:
| Stage | What Happens | Notes |
|---|---|---|
| Request arrives | Web server (Nginx/Apache) routes to a .php file |
Or CLI invocation |
| Process starts | PHP engine compiles the script to opcodes | Or loads from opcache |
| Script runs | Top-level code, then handles the request | No state carries over from the previous request |
| Response sent | Output is buffered, sent to the client | Or written to stdout in CLI |
| Process exits | All state is destroyed | Memory is freed |
Because each request is isolated, there is no concept of in-memory state between requests. Persistence is achieved through external systems: databases, caches (Redis, Memcached), session storage, files. This is the opposite of long-running servers like Node.js or Go, where one process serves many requests and shares state.
PHP 8: A Modern Language
PHP 8 (released 2020) was a major turning point. The language picked up features that put it on par with TypeScript and other modern typed languages:
- Just-in-time (JIT) compilation in the OPcache engine — selected hot code paths are compiled to native machine code, narrowing the performance gap with Go and Java.
- Named arguments — call functions with parameter names for clarity and to skip optional arguments.
- Constructor property promotion — declare and initialize object properties in the constructor signature.
- Attributes — structured metadata that replaces the old docblock annotation pattern.
- Enums — first-class support for enumerated types.
- Match expressions — expression-based control flow that is strict and exhaustive.
For new projects in 2026, PHP 8.2+ is the baseline. Anything earlier is legacy.
2. Deep Dive & Implementation
Basic Syntax
<?php
$name = "fogserv"; // string
$count = 42; // int
$ratio = 3.14; // float
$enabled = true; // bool
$items = ["a", "b"]; // array
function greet(string $name): string {
return "Hello, $name";
}
echo greet($name);
?>
Variables start with $. Arrays work for both indexed and associative data (["key" => "value"]). Functions declare parameter and return types.
Variables and Types
<?php
declare(strict_types=1); // enforce types strictly (recommended)
$integer = 42; // int
$float = 3.14; // float
$string = "hello"; // string
$bool = true; // bool
$string = "world"; // variables are mutable
$null = null; // null value
// Arrays
$indexed = ["apple", "banana", "cherry"];
$assoc = ["a" => 1, "b" => 2];
$mixed = [1, "hello", ["nested"]];
// Constants (constants are case-sensitive by default)
const VERSION = "1.0.0";
define("APP_MODE", "production");
// Type casting
$int_str = (int)"42"; // 42
$float_str = (float)"3.14"; // 3.14
$str_int = (string)42; // "42"
// Type checking
if (is_int($integer) && is_string($string)) {
echo "types are correct\n";
}
Strict types (declare(strict_types=1)) change how type coercion works. Without it, a function expecting int will coerce "42" to 42. With strict types, it throws a TypeError. Always use strict_types=1 in production.
Strings
$first = "Alice";
$last = 'Smith';
// Interpolation with double quotes
$full = "$first $last"; // "Alice Smith"
$full = $first . ' ' . $last; // same with concatenation
// Multiline with heredoc
$text = <<<EOT
This is a multiline string.
Variables like $first are interpolated.
EOT;
$multiline = "line one\nline two\nline three";
// Useful methods
$len = strlen($full); // length
$sub = substr("hello", 1, 3); // "ell"
$pos = strpos($text, "line"); // first occurrence
$replaced = str_replace("old", "new", $text);
$trimmed = trim(" text "); // "text"
Functions
// Basic function
function add(int $a, int $b): int {
return $a + $b;
}
// Default parameters
function greet(string $name = "World"): string {
return "Hello, $name";
}
// Variadic arguments
function sum(...$numbers): int {
return array_sum($numbers);
}
echo sum(1, 2, 3, 4); // 10
// Named arguments (PHP 8+)
echo greet(name: "Fogserv");
// Arrow functions (short closures)
$square = fn($n) => $n * $n;
$evens = array_filter([1, 2, 3, 4], fn($n) => $n % 2 === 0);
Arrays and Collections
$users = [
["name" => "Alice", "age" => 30],
["name" => "Bob", "age" => 25],
];
// Array methods
$names = array_map(fn($u) => $u["name"], $users);
$ages = array_column($users, "age");
$filtered = array_filter($users, fn($u) => $u["age"] > 25);
// Splice, push, pop
$stack = [];
array_push($stack, 1, 2, 3);
$last = array_pop($stack); // 3
// Sorting
sort($names); // by value, ascending
ksort($assoc); // by key
usort($users, fn($a, $b) => $a["age"] <=> $b["age"]); // custom compare
Control Flow
// Match (PHP 8) — more robust than switch
$status = 404;
$message = match($status) {
200 => "OK",
404 => "Not found",
500 => "Server error",
default => "Unknown",
};
// Null coalescing and null-safe
$name = $user["name"] ?? "Guest"; // if null or missing, use "Guest"
$city = $user["address"]["city"] ?? "Unknown"; // nested
// Null coalesce assignment
$name ??= "Unknown"; // assigns only if null
// Null-safe operator (PHP 8.0)
$name = $user?->profile?->name; // if $user or profile is null, returns null
Modern PHP 8 Features
Named Arguments
function create_user(
string $name,
int $age = 18,
bool $verified = false,
array $tags = []
): array {
return ["name" => $name, "age" => $age, "verified" => $verified, "tags" => $tags];
}
// Call in any order — no confusion
$user = create_user(
name: "Alice",
verified: true,
tags: ["admin", "premium"]
);
Union Types
A parameter can accept multiple types:
function process_id(string|int $id): string {
return (string)$id;
}
function handle_result(Result|string $res): void {
if ($res instanceof Result) {
$res->display();
} else {
echo $res;
}
}
Attributes (Annotations)
Attributes provide metadata that reflection APIs can inspect. They replace docblock annotations:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Controller {
public function __construct(
public string $route,
public array $middleware = []
) {}
}
#[Route("/users", method: "GET")]
#[Controller(route: "/users")]
class UserController {
public function index(): array {
return ["users" => ["Alice", "Bob"]];
}
}
Constructor Property Promotion
class Point {
// Before PHP 8: declare properties, then assign in constructor
// After PHP 8: declare + initialize in constructor signature
public function __construct(
public float $x,
public float $y,
private float $z = 0.0 // private promoted property
) {}
}
$p = new Point(x: 1.5, y: 2.5);
echo $p->x; // 1.5
This eliminates boilerplate and makes class intent obvious at a glance.
Match Expression
$status = 404;
$text = match ($status) {
200 => "OK",
201 => "Created",
404, 405 => "Not found",
default => "Unknown",
};
match is expression-based (returns a value), strict (===) comparison, and exhaustive — missing cases will trigger errors at analysis time.
Enumerations (Enums)
#[Value("red")]
enum Color: string {
case Red = 'red';
case Green = 'green';
case Blue = 'blue';
public function hex(): string {
return match ($this) {
self::Red => '#ff0000',
self::Green => '#00ff00',
self::Blue => '#0000ff',
};
}
}
function describe(Color $c): string {
return $c->value . ' => ' . $c->hex();
}
echo describe(Color::Red); // "red => #ff0000"
Enums provide type safety — you can't pass a raw string where a Color is expected, and the compiler knows the full set of allowed values.
Mixed Type and Named Parameters
function process(mixed $data, int $timeout = 30): mixed {
// mixed = any type (int, float, string, array, object, null)
// Named parameters for readability
return process($data, timeout: 60);
}
Readonly Properties
class Config {
public readonly string $version;
public function __construct() {
$this->version = "2.0"; // can only be assigned once, in constructor scope
}
}
Readonly properties provide immutability without making the whole object immutable.
Composer and Dependency Management
Composer is PHP's package manager. It manages dependencies via composer.json, resolves versions, installs packages, and generates autoload files.
Project Setup
# Initialize
composer init
# Interactive prompts create composer.json
# Require a package
composer require symfony/http-foundation
# Require for development only
composer require --dev phpunit/phpunit
# Install from lock file
composer install
# Update all (respects version constraints)
composer update
composer.json
{
"name": "fogserv/user-api",
"description": "User management API",
"type": "library",
"license": "MIT",
"require": {
"php": ">=8.2",
"symfony/http-foundation": "^6.4",
"monolog/monolog": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true,
"platform": {
"php": "8.2"
}
},
"minimum-stability": "stable"
}
The platform config lets you specify the PHP version you deploy to, so dependencies are resolved for that version, not your local machine.
Autoloading
Composer generates vendor/autoload.php. Use PSR-4 (directory-level) or Classmap autoloading:
// vendor/autoload.php handles this
require __DIR__ . '/vendor/autoload.php';
use App\Services\UserService; // App\Services maps to src/Services/
$service = new UserService();
PSR-4 is preferred for large projects. Classmap is faster but requires regeneration (composer dump-autoload) when files change.
Scripts
Run custom commands with composer run:
{
"scripts": {
"test": "phpunit",
"lint": "phpstan analyse src",
"start": "php -S localhost:8000 -t public",
"migrate": ["php bin/migrate.php"]
}
}
Run with:
composer run test
composer run lint
Lock File and Reproducibility
composer.lock pins exact package versions. Always commit composer.lock for applications (not libraries, since libraries don't control which versions users install). This ensures every deploy uses the same dependency tree.
Guided Checkpoint
Install PHP and Composer, scaffold a project, and run a small CLI:
# Verify PHP 8.2+ and Composer are available
php --version # must be 8.2 or later
composer --version
mkdir -p /tmp/php-checkpoint && cd /tmp/php-checkpoint
composer init --name="fogserv/checkpoint" --type="project" --no-interaction
composer require --dev phpstan/phpstan --no-interaction
mkdir -p src
cat > src/Greeter.php <<'EOF'
<?php
declare(strict_types=1);
namespace Fogserv\Checkpoint;
final class Greeter {
public function __construct(
private readonly string $prefix = "Hello"
) {}
public function greet(string $name): string {
return "{$this->prefix}, {$name}!";
}
}
EOF
cat > check.php <<'EOF'
<?php
require __DIR__ . '/vendor/autoload.php';
$g = new \Fogserv\Checkpoint\Greeter();
echo $g->greet("fogserv") . "\n";
EOF
php check.php
# Expected output: Hello, fogserv!
3. Anti-Patterns & Common Pitfalls
These failure modes appear repeatedly in PHP codebases. Each is detectable, preventable, and rooted in PHP's dynamic typing or request-scoped execution model.
Anti-Pattern: Skipping declare(strict_types=1)
Without strict types, PHP coerces values silently. A function expecting int will accept the string "42" and convert it. A function expecting array will accept null and produce a warning rather than a TypeError:
// Bad: implicit coercion — bugs hide in production
function add(int $a, int $b): int {
return $a + $b;
}
add("1", "2"); // returns 3, no error
// Good: strict types — coercion throws
declare(strict_types=1);
function add(int $a, int $b): int {
return $a + $b;
}
add("1", "2"); // TypeError: must be of type int
Fix: add declare(strict_types=1); as the very first line of every PHP file. It is a one-character protection that prevents entire categories of bugs.
Anti-Pattern: Storing State in Globals Between Requests
Because each request is isolated, globals reset every time. Code that depends on shared state across requests — global caches, in-memory counters, instance singletons — produces inconsistent behavior:
// Bad: state is lost between requests
$GLOBALS['request_count'] = ($GLOBALS['request_count'] ?? 0) + 1;
echo "Request #{$GLOBALS['request_count']}";
Fix: use external storage for state that must persist: Redis, Memcached, database, file-based session. For per-request caches, use a local variable or a scoped service.
Anti-Pattern: Trusting User Input Without Validation
$_GET, $_POST, $_COOKIE, and $_REQUEST are untyped arrays of strings. Using them directly in queries, file paths, or output produces SQL injection, path traversal, and XSS:
// Bad: SQL injection
$name = $_POST['name'];
$db->query("SELECT * FROM users WHERE name = '$name'");
// Good: parameterized query
$stmt = $db->prepare("SELECT * FROM users WHERE name = :name");
$stmt->execute(['name' => $_POST['name']]);
Fix: validate every input. Use filter_input for basic type coercion, parameterized queries for SQL, and htmlspecialchars for any output that may contain user data.
Anti-Pattern: Using switch Where match Is Cleaner
match (PHP 8) is expression-based, strict (===), and supports multiple labels. switch is statement-based, uses loose comparison (==), and falls through by default — all of which are sources of subtle bugs:
// Bad: switch with fallthrough and loose comparison
switch ($status) {
case 200: // matches 200, but also "200", 200.0, etc.
$msg = "OK";
break;
case 404: // missing break — falls through!
case 405:
$msg = "Not found";
break;
}
// Good: match is strict, exhaustive, and an expression
$msg = match ($status) {
200 => "OK",
404, 405 => "Not found",
default => "Unknown",
};
Fix: prefer match for value-to-value mapping. Use match even for simple cases — it is clearer and prevents future bugs when new cases are added.
Anti-Pattern: Storing Complex Data in $_SESSION Without Serialization Awareness
$_SESSION values are serialized between requests. Storing closures, resource handles, or objects with circular references produces warnings or data loss:
// Bad: closure in session — cannot be serialized
$_SESSION['handler'] = function() { /* ... */ };
// Better: store a string identifier and reconstruct on use
$_SESSION['handler_id'] = "default";
$handler = match ($_SESSION['handler_id']) {
"default" => new DefaultHandler(),
"advanced" => new AdvancedHandler(),
};
Fix: store simple types (int, string, array, serializable objects) in $_SESSION. Use serialize()/unserialize() or json_encode/json_decode to control the format.
4. Independent Challenge
Design a small request router using modern PHP 8 features, without step-by-step guidance.
Without step-by-step guidance, produce:
- A
Routerclass that maps HTTP method + path patterns to handler callables. Use attributes (PHP 8) or constructor-style registration. - Support path parameters, e.g.,
/users/{id}should match/users/42and extractid=42as a parameter. - Support middleware: each route has a list of middlewares that wrap the handler.
- A
dispatch(string $method, string $path): Responsemethod that returns a 404 response if no route matches. - Use a
Responseclass with constructor property promotion for status, headers, and body.
Constraints:
- Must use
declare(strict_types=1)in every file. - Must use PHP 8.0+ features: named arguments, constructor property promotion, union types, null-safe operator.
- Must not use
switch— prefermatch. - Path parameter extraction must handle edge cases (empty path, trailing slash, encoded characters).
- The router must support at least 5 routes in a working example.
Deliverable: a Router.php file with a routes.php configuration example and a public/index.php that dispatches a sample request. Verify by running the built-in PHP server (php -S localhost:8000) and curling each route. No solution is provided.
5. Consolidation & Key Invariants
- Always start files with
declare(strict_types=1);. This is a one-line protection against entire categories of type-coercion bugs. - Each request is isolated — never store state in globals expecting it to persist. Use external storage (Redis, database) for shared state.
- Validate every input from
$_GET,$_POST,$_COOKIE,$_REQUEST, and HTTP headers. Usefilter_input, parameterized queries, andhtmlspecialcharsfor output. - Prefer
matchoverswitch.matchis strict, expression-based, and supports multiple labels.switchuses loose comparison and falls through by default. - Use constructor property promotion (PHP 8) for clean DTOs and value objects. It eliminates boilerplate without sacrificing type safety.
- Commit
composer.lockfor applications, not libraries. The lock file pins versions so every deploy uses the same dependency tree. - PHP 8.2+ is the baseline for new projects. Anything earlier is legacy and missing essential features (enums, match, named arguments, readonly, fibers).
6. Next Steps
- php.net/manual — official reference, excellent examples
- Composer documentation — package, dependency, and version management
- Practice: build a small REST API with Symfony or Laravel, then add PHPUnit tests
Next Article: typescript-basics — for full-stack projects where the frontend and backend share types, or language-comparison for a broader overview.
Content depth matches kb/containers/docker-concepts.md — production-level patterns, real syntax, and practical examples.
Change Log
| Date | Change |
|---|---|
| 2026-09-15 | Migrated to canonical 6-section template (Architectural Overview, Deep Dive with Guided Checkpoint, Anti-Patterns, Independent Challenge, Consolidation, Next Steps) |
| 2026-08-29 | Full hydration of PHP Basics lesson |
| 2026-08-29 | Added sections: Why PHP, Basic Syntax, Modern PHP 8 Features, Composer |
| 2026-09-15 | Added Next Steps links, preserved prerequisites and tags |