Tags: web-dev reference

Reference - PHP

Language: PHP 8.5 — current stable, released 20 November 2025
Checked: 2026-08-17


A language built around one architectural fact: the process dies after every request. Everything else — the superglobals, the lack of an event loop, the way state is handled — follows from that, and it’s the thing that makes PHP feel alien coming from Node.


§0 The one idea

PHP dies after every request.

A request arrives, PHP starts, runs your script from scratch, produces output, and the process exits. Nothing survives. No module-level state, no connection pool held in a variable, no in-memory cache between requests.

REQUEST 1   boot → run → output → DIE
REQUEST 2   boot → run → output → DIE
REQUEST 3   boot → run → output → DIE

vs JS: Node boots once and stays running. A const cache = {} at module scope persists across every request, and a memory leak accumulates until the process restarts. In PHP neither is possible — every request gets a clean interpreter, so a leak lasts milliseconds and a global is meaningless between requests.

What this buys: crashes are contained to one request, memory leaks are self-healing, and there is no concurrency between requests inside a process — so no data races on shared state, ever.

What it costs: anything expensive is paid on every single request unless it’s cached externally. There is no long-lived connection pool, no warm in-memory state, and no background work after the response — The Request Lifecycle in PHP.

vs JS: async is the default in Node because blocking would stall every other request. In PHP, blocking I/O is normal and correct — the process is serving exactly one request and has nothing else to stall.


1. The smallest working form

<?php
echo "Hello";

That’s a complete PHP file. No imports, no boilerplate, no entry function. The opening tag is required; the closing ?> is optional and should be omitted in pure-PHP files, because any whitespace after it is sent to the browser and breaks headers.

Variables carry a $:

<?php
$name = "Serum";
echo "Product: $name";        // interpolates
echo 'Product: $name';        // does NOT

Double quotes interpolate, single quotes don’t. Single quotes are marginally faster and, more usefully, mean what they say.


2. The array, which is two data structures

PHP has one collection type and it does both jobs, which is the single biggest adjustment coming from another language.

<?php
$list = ['a', 'b', 'c'];              // keys 0,1,2
$map  = ['id' => 42, 'name' => 'X'];  // string keys
$both = ['a', 'key' => 'b', 'c'];     // legal

It is an ordered hash map. Insertion order is preserved for both integer and string keys, so it’s a list and a dictionary at once — closer to JS’s Map than to either Array or Object.

<?php
$a = ['x' => 1];
$b = $a;          // COPIES the array
$b['x'] = 2;
echo $a['x'];     // 1

vs JS: arrays and objects are assigned by reference in JavaScript; PHP arrays are copied by value. Objects are still by reference — the split catches people constantly — Memory Models.

The functions worth knowing:

<?php
array_map(fn($p) => $p * 2, $prices);
array_filter($rows, fn($r) => $r['active']);
array_reduce($items, fn($c, $i) => $c + $i, 0);
array_column($orders, 'total', 'id');   // pluck
array_key_exists('k', $a);              // even if null
isset($a['k']);                         // and not null
in_array($x, $a, strict: true);         // always strict

array_map takes the callback first; array_filter takes the array first. There is no rule, the inconsistency is historical, and everyone looks it up.


3. Functions

<?php
function total(float $net, float $vat = 0.20): float {
    return $net * (1 + $vat);
}
 
total(50.0);              // 60.0
total(50.0, 0.05);        // 52.5
total(vat: 0.05, net: 50.0);   // named arguments

Named arguments (8.0) let you skip defaults and reorder, and they make a call site readable without opening the signature.

<?php
function sum(int ...$n): int {       // variadic
    return array_sum($n);
}
sum(1, 2, 3);
sum(...[1, 2, 3]);                   // spread

4. Type declarations

PHP is gradually typed — declarations are optional, checked at runtime, and coerced unless you say otherwise.

<?php
declare(strict_types=1);   // FIRST line, per file
<?php
function f(int $x): string { return "$x"; }
 
f("5");   // without strict_types: coerced to 5
          // with strict_types:    TypeError

Put declare(strict_types=1) at the top of every file. It is per-file, not global, and without it the type declarations are suggestions.

<?php
?int $a;              // nullable
int|string $b;        // union            (8.0)
Countable&Iterator $c;   // intersection  (8.1)
(A&B)|null $d;        // DNF              (8.2)
never                 // never returns    (8.1)

5. Classes, built up

<?php
class Order {
    public function __construct(
        public readonly string $id,
        private float $total = 0.0,
    ) {}
 
    public function total(): float {
        return $this->total;
    }
}

Three things at once, all worth naming:

  • Constructor property promotion (8.0) — parameters prefixed with a visibility keyword become properties. It replaces the declare-then-assign boilerplate that dominated older PHP
  • readonly (8.1) — assignable once, from inside the class. Attempting to change it throws
  • $this is lexical. vs JS: no bind, no that = this, no arrow-function workaround. $this inside a method is always the object
<?php
final class Money { }              // no subclassing
readonly class Point { }           // ALL props readonly (8.2)
abstract class Repo { }
interface Payable { public function pay(): void; }

Traits

Horizontal reuse — the mechanism PHP has instead of multiple inheritance.

<?php
trait Timestamps {
    public ?DateTimeImmutable $createdAt = null;
    public function touch(): void {
        $this->createdAt = new DateTimeImmutable();
    }
}
 
class Order { use Timestamps; }

vs JS: no equivalent. It’s copy-paste at compile time, with conflict resolution rules — powerful, and easy to overuse into a class nobody can trace.

Enums

<?php
enum Status: string {
    case Draft     = 'draft';
    case Paid      = 'paid';
    case Cancelled = 'cancelled';
 
    public function isFinal(): bool {
        return $this === self::Cancelled;
    }
}
 
Status::Paid->value;              // 'paid'
Status::from('paid');             // Status::Paid
Status::tryFrom('nope');          // null, no throw
Status::cases();                  // all of them

Real enums (8.1) — singleton instances, comparable with ===, able to carry methods and implement interfaces. vs TS: nothing like TypeScript’s numeric enums; these are proper objects — State Machines.

Property hooks and asymmetric visibility

The two 8.4 additions that change how models are written.

<?php
class Product {
    public string $name {
        get => ucfirst($this->name);
        set => trim($value);
    }
 
    // readable anywhere, writable only inside
    public private(set) float $price = 0.0;
}

Property hooks remove most getter/setter pairs, and private(set) removes most of the rest — a public read with a guarded write, without a method.


6. Null handling

<?php
$name = $user['name'] ?? 'Guest';       // null coalescing
$config['x'] ??= 'default';             // assign if null
$city = $order?->address?->city;        // nullsafe (8.0)

?? checks for null or undefined without a warning; ?: checks truthiness. They are not interchangeable — 0 ?: 'x' gives 'x', 0 ?? 'x' gives 0.

?-> short-circuits the whole chain. If $order is null the expression is null; it does not attempt ->address.


7. Control flow, and match

<?php
$label = match($status) {
    Status::Draft            => 'Not sent',
    Status::Paid,
    Status::Cancelled        => 'Closed',
    default                  => 'Unknown',
};

match beats switch on three counts (8.0): it’s an expression returning a value, it compares with === rather than ==, and it throws on no match instead of falling through silently. Use match.

<?php
foreach ($orders as $order) { }
foreach ($map as $key => $value) { }
foreach ($rows as ['id' => $id]) { }   // destructuring

8. Closures and callables

<?php
$vat = 0.2;
 
$f = function (float $n) use ($vat): float {
    return $n * (1 + $vat);
};
 
$g = fn(float $n): float => $n * (1 + $vat);

These two are equivalent except for capture: function requires an explicit use clause; arrow functions capture the enclosing scope automatically, by value.

vs JS: a JavaScript closure captures everything in scope by default, by reference — Closures. PHP’s function closures capture nothing unless told — which is verbose and removes a whole class of accidental capture.

<?php
$fn = strlen(...);                  // first-class callable (8.1)
$fn = $order->total(...);
array_map(strtoupper(...), $names);

9. Errors and exceptions

<?php
try {
    $order = $repo->find($id);
} catch (NotFoundException | InvalidArgumentException $e) {
    log($e->getMessage());
} catch (Throwable $e) {          // catches EVERYTHING
    throw $e;
} finally {
    $conn->close();
}
Throwable
├─ Error          engine failures — TypeError,
│                 ValueError, ArgumentCountError
└─ Exception      your application's
   └─ RuntimeException, LogicException, …

catch (Exception $e) does not catch a TypeError. Catch Throwable when you genuinely mean everything — this is the most common error-handling mistake in PHP — Error Handling Strategies.

<?php
catch (Throwable) { }         // no variable needed (8.0)

10. Attributes and generators

<?php
#[Route('/orders/{id}', methods: ['GET'])]
public function show(string $id): Response { }

Attributes (8.0) are structured metadata readable by reflection — the same idea as decorators, and the basis of routing and validation in every modern framework.

<?php
function readRows(string $file): Generator {
    $h = fopen($file, 'r');
    while (($line = fgetcsv($h)) !== false) {
        yield $line;                 // one row in memory
    }
    fclose($h);
}
 
foreach (readRows('orders.csv') as $row) { }

Generators are how you process a file larger than memory — the single most useful thing in this section for real work.

vs JS: the same idea — function*, yield, lazy iteration — with an explicit protocol any object can implement — Iterators and Generators.


11. Databases

PHP’s database layer is PDO — one API across MySQL, Postgres, SQLite and others, with the driver swapped underneath.

<?php
$db = new PDO(
    'mysql:host=localhost;dbname=shop;charset=utf8mb4',
    $user,
    $pass,
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ],
);

Three options worth setting deliberately:

  • ERRMODE_EXCEPTION — the default since PHP 8.0, and worth stating anyway. Older code silently returned false on failure, which is how a failed query becomes a blank page
  • FETCH_ASSOC — otherwise every row arrives twice, keyed by both name and position
  • EMULATE_PREPARES => false — with emulation on (the MySQL default), PDO builds the query string itself rather than sending a real prepared statement. Turn it off so the database does the separation
  • charset=utf8mb4 in the DSN — not utf8, which is a three-byte subset that cannot store emoji — Character Encoding

Prepared statements

<?php
// named placeholders
$stmt = $db->prepare(
    'SELECT * FROM orders WHERE status = :status'
);
$stmt->execute(['status' => 'paid']);
 
// positional — equivalent
$stmt = $db->prepare(
    'SELECT * FROM orders WHERE status = ?'
);
$stmt->execute(['paid']);

Both forms are equivalent. Named placeholders read better with more than two parameters; positional are shorter. You cannot mix them in one statement.

You cannot parameterise an identifier. Table names, column names and ASC/DESC are not values, and no placeholder will bind them:

<?php
// BROKEN — will not work
$db->prepare('SELECT * FROM orders ORDER BY ?');
 
// correct — allow-list, never interpolate
$allowed = ['total', 'created_at'];
$col = in_array($sort, $allowed, true)
     ? $sort
     : 'created_at';
$db->prepare("SELECT * FROM orders ORDER BY $col");

That allow-list is the only safe pattern here, and dynamic sorting is where SQL injection most often survives in otherwise-parameterised code — Common Vulnerabilities.

Fetching

<?php
$stmt->fetch();          // one row, or false
$stmt->fetchAll();       // every row — watch memory
$stmt->fetchColumn();    // one scalar
$stmt->rowCount();       // affected rows (writes)
 
// stream large results rather than fetchAll
foreach ($stmt as $row) {
    // one row at a time
}

fetchAll() on a large table loads all of it into memory. Iterating the statement streams — the same reasoning as generators above.

Transactions

<?php
$db->beginTransaction();
try {
    $db->prepare('UPDATE stock SET qty = qty - ?
                   WHERE id = ? AND qty >= ?')
       ->execute([$n, $id, $n]);
 
    $db->prepare('INSERT INTO orders (id, total)
                  VALUES (?, ?)')
       ->execute([$orderId, $total]);
 
    $db->commit();
} catch (Throwable $e) {
    $db->rollBack();
    throw $e;
}

Note the arithmetic is in the SQL, not read-modify-write in PHP — that’s what makes it atomic — Transactions and ACID, Race Conditions.

The connection is per request

There is no connection pool, because there is no long-lived process to hold one — every request opens a connection and drops it. PDO::ATTR_PERSISTENT reuses connections across requests and brings its own problems: transaction state and temporary tables can leak between unrelated requests.

The real answer is an external pooler — pgBouncer, ProxySQL — sitting between PHP and the database. This is the most consequential practical difference from a Node or Java stack, and it follows directly from §0 — The Request Lifecycle in PHP.

ORMs

Doctrine (data mapper) and Eloquent (active record) are the two you’ll meet. Both generate SQL you didn’t write, so log the actual queries before diagnosing anything — lazy-loaded relations inside a loop are the standard way a PHP page issues 200 queries — N+1 Queries, Query Planning.


12. PHP 8.5 additions

<?php
$slug = $title
    |> trim(...)
    |> strtolower(...)
    |> fn($s) => str_replace(' ', '-', $s);

The pipe operator |> chains callables left to right, replacing nested calls that had to be read inside-out.

<?php
$paid = clone($order, ['status' => Status::Paid]);

clone with property updates makes the “with-er” pattern workable on readonly classes, which previously had no way to produce a modified copy.

Also in 8.5: a built-in URI extension for RFC 3986 and WHATWG URL parsing, and #[\NoDiscard] to warn when a return value is ignored.

[CHECK: exact syntax of clone with an array and of |> against the 8.5 release notes before writing production code — this is summarised from the announcement rather than the RFCs.]


Equivalent forms

Where two spellings mean the same thing:

<?php
// array syntax — identical
$a = array(1, 2);
$a = [1, 2];                 // prefer this
 
// string concatenation — identical
$s = 'Hello ' . $name;
$s = "Hello $name";
$s = "Hello {$order->name}"; // braces for expressions
 
// closures — differ only in capture
function ($x) use ($y) { return $x + $y; }
fn($x) => $x + $y;
 
// null check — NOT equivalent
$a ?? 'd';    // null or undefined
$a ?: 'd';    // any falsy value

Required versus optional

ThingRequired?
<?php opening tagYes
Closing ?>No — omit it in pure PHP files
declare(strict_types=1)No, but always
Type declarationsNo — and they do nothing without strict types
$ on variablesYes
SemicolonsYes — no automatic insertion
namespaceNo, until you have Composer
public on methodsNo — it’s the default
Constructor bodyNo — promotion often leaves it empty
return typeNo, but it’s the highest-value annotation

The kitchen sink

Labelled as such — do not write classes like this. It exists to show the features coexisting:

<?php
declare(strict_types=1);
 
namespace App\Orders;
 
use DateTimeImmutable;
use JsonSerializable;
 
#[Entity(table: 'orders')]
final readonly class Order implements JsonSerializable
{
    public function __construct(
        public string $id,
        public Status $status = Status::Draft,
        private array $lines = [],
        public ?DateTimeImmutable $placedAt = null,
    ) {}
 
    public function total(): float {
        return array_reduce(
            $this->lines,
            fn(float $c, array $l) => $c + $l['qty'] * $l['price'],
            0.0,
        );
    }
 
    public function label(): string {
        return match($this->status) {
            Status::Draft            => 'Not yet placed',
            Status::Paid,
            Status::Cancelled        => 'Closed',
        };
    }
 
    public function jsonSerialize(): array {
        return [
            'id'     => $this->id,
            'status' => $this->status->value,
            'total'  => $this->total(),
        ];
    }
}

A complete file, top to bottom

<?php
declare(strict_types=1);
 
namespace App\Orders;
 
use App\Orders\Exception\OrderNotFound;
use PDO;
 
final class OrderRepository
{
    public function __construct(
        private readonly PDO $db,
    ) {}
 
    public function find(string $id): Order
    {
        $stmt = $this->db->prepare(
            'SELECT id, status, placed_at
               FROM orders
              WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
 
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
 
        if ($row === false) {
            throw new OrderNotFound($id);
        }
 
        return new Order(
            id:     $row['id'],
            status: Status::from($row['status']),
            placedAt: $row['placed_at']
                ? new \DateTimeImmutable($row['placed_at'])
                : null,
        );
    }
}

Note the prepared statement. Parameters are sent separately from the SQL, so the value can never be parsed as a statement — this is the only correct way to build a query — Common Vulnerabilities.


Versions and support

8.5   current stable — 20 Nov 2025
8.6   in beta at time of writing
8.4   supported
8.3   supported
8.2   security fixes only, to 31 Dec 2026

each branch: 2 years active support,
             then 2 years security only

Anything below 8.0 is a rewrite, not an upgrade. Named arguments, promotion, match, enums, attributes and typed properties together changed how the language is written, and code from the 5.x era shares little with modern PHP beyond syntax.