Output & Running
Hello, World
Every PHP file on this page opens with
<?php. That tag is not decoration: a PHP file is a template that happens to contain code, and anything outside the tag is copied to the output verbatim. A file with no tag at all is a valid PHP program that prints itself.print("Hello, World!")<?php
echo "Hello, World!\n";echo is a language construct rather than a function, so it needs no parentheses and returns nothing — and, unlike print(), it adds no newline, which is why every example here writes "\n" itself. The closing ?> tag is deliberately omitted: a stray blank line after it would be sent to the browser and break a header, so leaving it off is the convention in every modern PHP codebase.Printing several values
PHP had string interpolation decades before Python had f-strings, and it needs no prefix letter — a double-quoted string interpolates and a single-quoted one does not.
name = "Ada"
age = 36
print(f"{name} is {age}")
print(name, "is", age)<?php
$name = "Ada";
$age = 36;
echo "$name is $age\n";
echo $name, " is ", $age, "\n";The convenience stops short of Python's: only a bare variable, an array index and a property access interpolate directly, so anything with a method call or arithmetic in it needs the braced form
"{$order->total()}". echo accepts a comma-separated list, which is the closest thing to print's several arguments — but it inserts no spaces between them, so you write the spaces yourself.Inspecting a value while debugging
Handing a container to
echo does not do what handing one to print does — it emits an "Array to string conversion" warning and prints the word Array. PHP splits the human view and the machine view into two different functions.person = {"name": "Ada", "tags": ["math", "code"]}
print(person)
print(repr(person))<?php
$person = ["name" => "Ada", "tags" => ["math", "code"]];
print_r($person);
echo var_export($person, true), "\n";print_r is the readable dump, closest to print(obj); var_export emits valid PHP source and is the analogue of repr; var_dump is a third form that adds the type and length of every leaf, and is the one you actually reach for when a type is the question. Each takes a flag to return the string instead of echoing it, which is what the bare true in var_export($person, true) is doing.Running a file, and the REPL nobody uses
The command-line shapes line up almost exactly. What differs is what each community actually does with them.
# python script.py
# python -m mypackage
# python -c 'print(1 + 1)'
# python <- a REPL you actually live in
print("Python: a script, a module, or the REPL")<?php
// php script.php
// php -r 'echo 1 + 1;'
// php -a <- a REPL almost nobody uses
// php -S localhost:8000 <- the built-in web server
echo "PHP: a script, a one-liner, or a request\n";A Python team lives in the REPL; PHP's interactive shell exists and is used by roughly nobody, because the natural way to run PHP is to point a web server at a directory.
php -S ships inside the language and serves a directory over HTTP with no configuration at all, which is the quickest way to watch a PHP file behave the way it will in production.Types & Who Checks Them
Type hints check nothing; declared types are enforced
This is the row the whole page turns on, and the one place where the Python reader's tooling is the looser of the two. The two annotations look like the same idea borrowed in both directions. They are not: Python's is a comment a separate program may choose to read, and PHP's is an instruction to the engine.
def greet(name: str) -> str:
return "Hello, " + name
print(greet("Ada"))
try:
print(greet(42)) # accepted, then fails INSIDE the function
except TypeError as error:
print("TypeError:", error)<?php
function greet(string $name): string {
return "Hello, " . $name;
}
echo greet("Ada"), "\n";
try {
echo greet([1, 2]), "\n"; // rejected AT THE CALL
} catch (TypeError $error) {
echo "TypeError: the argument never reached the body\n";
}Python stores the annotation in
greet.__annotations__ and otherwise ignores it, so greet(42) is accepted and then fails a line later complaining about + — an error message about the wrong thing, in the wrong place. PHP checks the argument as the call is made and names the parameter, the expected type, and the caller's file and line. Type checking in Python is real and valuable, but it lives in mypy or pyright and runs when you ask it to; a Python program in production has no idea its annotations exist.strict_types, and the coercion it turns off
PHP's enforcement has two settings, and the stricter one has to be asked for per file. Without it the engine tries to convert an argument to the declared type before giving up, so
double("21") quietly succeeds and returns 42.def double(value: int) -> int:
return value * 2
print(double(21))
print(double("21")) # no check at all: "2121"<?php
declare(strict_types=1);
function double(int $value): int {
return $value * 2;
}
echo double(21), "\n";
try {
echo double("21"), "\n";
} catch (TypeError $error) {
echo "TypeError: a numeric string is still not an int\n";
}declare(strict_types=1) must be the very first statement in the file, and it governs the calls made in that file rather than the functions declared in it — the detail that catches everyone. Every serious PHP codebase turns it on at the top of every file and most frameworks generate it. Python has no equivalent because it has nothing to switch off: double("21") returns the string "2121", which is worse than either PHP mode, and the annotation prevented none of it.Union and nullable types
The syntax is the same pipe, and it arrived in the two languages within a year of each other — PHP 8.0 in 2020, Python 3.10 in 2021.
def parse(value: int | str | None) -> str:
if value is None:
return "nothing"
return f"got {value}"
print(parse(7))
print(parse("seven"))
print(parse(None))<?php
declare(strict_types=1);
function parse(int|string|null $value): string {
if ($value === null) {
return "nothing";
}
return "got $value";
}
echo parse(7), "\n";
echo parse("seven"), "\n";
echo parse(null), "\n";PHP writes
?int as shorthand for int|null, much as Python once wrote Optional[int]. The difference is enforcement again: PHP checks the union at the boundary of every call, so a value that reaches the body genuinely is one of the listed types and the === null test is the only branch needed. In Python the same signature guarantees nothing, and defensive code inside the function is not paranoia.Typed properties, and the uninitialized state
Both languages let you annotate an attribute rather than only a parameter, and again only one acts on it. A typed PHP property is guarded on every write for the life of the object, not just at construction.
class Order:
total: float
label: str
def __init__(self, total: float, label: str):
self.total = total
self.label = label
order = Order(19.99, "books")
order.total = "free" # allowed; nothing complains
print(order.total)<?php
declare(strict_types=1);
class Order {
public float $total;
public string $label;
public function __construct(float $total, string $label) {
$this->total = $total;
$this->label = $label;
}
}
$order = new Order(19.99, "books");
try {
$order->total = "free";
} catch (TypeError $error) {
echo "TypeError: the property refused a string\n";
}
echo $order->total, "\n";A typed PHP property also has a third state Python has no word for: uninitialized. Declaring
public float $total; with no default leaves the property holding no value at all, and reading it before it is written raises an Error rather than handing back null. That is a genuinely useful guarantee — a half-constructed object cannot quietly leak a null downstream, which is exactly what a Python class with self.total = None in __init__ does.Equality: == is not the operator you think it is
PHP has two equality operators, and the shorter one does not mean what a Python reader assumes. This is worth memorizing before writing any PHP at all.
print(1 == 1.0)
print("1" == 1)
print(0 == "")
print([] == False)<?php
var_dump(1 == 1.0);
var_dump("1" == 1);
var_dump(0 == "");
var_dump([] == false);== in PHP compares after conversion, so "1" == 1 is true; === compares type and value the way Python's == does in these cases, and is what you should write essentially always. PHP 8 tightened the rules a great deal — 0 == "hello" was true before PHP 8 and is false now — but 0 == "" is still true, which is enough of a trap to justify the blanket rule. Python converts only between numeric types (1 == 1.0), the one case the two languages agree on.The Request Lifecycle
A fresh interpreter for every request
This is the single biggest thing a Python web developer has to unlearn, and it explains most of what looks strange about PHP's libraries. Nothing you put in a variable, a global, or a class static survives the response.
# A long-lived Django or FastAPI process:
# the module body runs ONCE, at import, and then
# serves thousands of requests from that memory.
CACHE = {}
CONNECTION_POOL = "opened once, reused for weeks"
def handle(key):
if key not in CACHE:
CACHE[key] = f"computed {key}"
return CACHE[key]
print(handle("a"), "| the second request sees this too")<?php
// A PHP request:
// this whole file is parsed, executed and DISCARDED.
// The next request starts from an empty world.
$cache = [];
function handle(array &$cache, string $key): string {
if (!isset($cache[$key])) {
$cache[$key] = "computed $key";
}
return $cache[$key];
}
echo handle($cache, "a"), " | the next request sees NONE of this\n";PHP's model is called shared-nothing: the interpreter boots, includes your files, produces a response, and tears the entire process state down. That is why PHP needs no application server and no worker restarts on deploy — replacing the file is the deploy — and why a memory leak is nearly impossible to write. It is also why every cache, session and connection pool in PHP lives in an external service (Redis, Memcached, the database) rather than in a module-level dictionary. Modern setups blunt the cost with OPcache, which keeps the compiled bytecode between requests, and with long-running runtimes like Swoole and FrankenPHP — but the default, and the one every library assumes, is the fresh world.
Request input: superglobals against a request object
PHP predates the idea of a request object. The engine fills a handful of magic global arrays before the first line of your code runs, and they are visible from every function in the program without being passed anywhere.
# Flask
# from flask import request
# @app.route("/search")
# def search():
# term = request.args.get("q", "")
# return f"searching for {term}"
#
# The request is an OBJECT passed to your view.
print("Python: the framework hands you the request")<?php
// $_GET, $_POST, $_SERVER, $_COOKIE, $_SESSION, $_FILES
// are populated by the engine before your code starts.
//
// $term = $_GET['q'] ?? '';
// echo "searching for $term";
//
// The request is AMBIENT, readable from anywhere.
echo "PHP: the engine has already put it in a global\n";Every modern PHP framework wraps these in a proper request object exactly because ambient global input is untestable and unmockable, so a Laravel or Symfony codebase looks much more like Flask than like this. But the superglobals are still there, still populated, and still what a WordPress plugin or a legacy script will reach for — so recognizing them matters even when you never write them.
$_SESSION is the odd one out: it survives between requests, because it is backed by a file or a cache server rather than by memory.State that outlives a call but not a request
A class attribute and a static property behave identically inside one run. The lifetime around them is what differs, and it is the difference that decides whether a memoization cache is a good idea.
class Counter:
visits = 0
@classmethod
def visit(cls):
cls.visits += 1
return cls.visits
print(Counter.visit())
print(Counter.visit())
# In a long-lived server this keeps climbing
# across every request the process handles.<?php
class Counter {
public static int $visits = 0;
public static function visit(): int {
return ++self::$visits;
}
}
echo Counter::visit(), "\n";
echo Counter::visit(), "\n";
// The NEXT request starts this back at 0.In Python a class-level counter is process-wide and effectively permanent, which makes it a fine memoization cache and also a fine way to leak memory and to have one user's data appear in another user's response. In PHP the same code is a per-request scratchpad: perfectly safe, and useless as a cache. That asymmetry is why PHP libraries reach for a static property freely where a Python library would be careful, and why "just cache it in a static" is real advice in one language and a bug report in the other.
Sigils & Surface Syntax
The dollar sign, braces, and semicolons
This is pure surface, and getting it out of the way early stops it distracting from everything that follows. Nothing here is a semantic difference — it is what makes PHP look alien on first contact.
total = 0
for number in [1, 2, 3]:
if number % 2 == 1:
total += number
print(total)<?php
$total = 0;
foreach ([1, 2, 3] as $number) {
if ($number % 2 === 1) {
$total += $number;
}
}
echo $total, "\n";Every variable carries a
$, every statement ends in a semicolon, every block is braced, and indentation means nothing to the parser. The $ is not noise: because a variable is always sigil-marked, PHP can interpolate one straight into a string with no prefix and can write $$name for a variable whose name is in another variable. Nothing else in this row will surprise you again after ten minutes.Dot, arrow, and double colon
Python spells every kind of member access with a dot. PHP uses three different punctuation marks and they are not interchangeable, so picking the wrong one is a parse error rather than a subtle bug.
import math
class Circle:
TAU = math.pi * 2
def __init__(self, radius):
self.radius = radius
def circumference(self):
return Circle.TAU * self.radius
circle = Circle(2)
print(circle.radius)
print(round(circle.circumference(), 4))
print(round(Circle.TAU, 4))<?php
class Circle {
const TAU = M_PI * 2;
public function __construct(public float $radius) {}
public function circumference(): float {
return self::TAU * $this->radius;
}
}
$circle = new Circle(2);
echo $circle->radius, "\n";
echo round($circle->circumference(), 4), "\n";
echo round(Circle::TAU, 4), "\n";-> reaches into an instance, :: reaches into a class (constants, statics, and parent::/self::), and . is not member access at all — it is string concatenation, which is the single most common typo a Python developer makes in their first week. Note also that the instance is $this rather than an explicit first parameter, and that $this->radius has no second dollar sign on the property name.Concatenation is a dot, and + is arithmetic only
The operator that joins two strings in Python means something else entirely in PHP, and the operator PHP uses for it means something else entirely in Python.
first = "web"
second = "site"
print(first + second)
print("ab" * 3)
print(len(first))<?php
$first = "web";
$second = "site";
echo $first . $second, "\n";
echo str_repeat("ab", 3), "\n";
echo strlen($first), "\n";PHP's
+ on two strings tries to add them as numbers, so "web" + "site" is a TypeError in PHP 8 rather than the concatenation a Python reader intends. Applied to two arrays, + is the union operator, which is a completely separate surprise covered in the arrays section. There is no * for repetition either; str_repeat does that job, and strlen replaces len — one of many places where PHP reaches for a function where Python reaches for a method or a builtin operator.Blocks that are not blocks: the alternative syntax
PHP has a second spelling for every control structure —
if:/endif;, foreach:/endforeach; — that exists so a block can be interrupted by raw HTML and picked up again afterwards.# Python has one syntax, used everywhere,
# and templating is a separate library (Jinja, Django).
items = ["a", "b"]
for item in items:
print(f"<li>{item}</li>")<?php
// PHP has a second block syntax that exists FOR templates,
// because a PHP file is itself a template.
$items = ["a", "b"];
foreach ($items as $item): ?>
<li><?= htmlspecialchars($item) ?></li>
<?php endforeach;This is the shape a Laravel Blade or WordPress theme file has, and reading it is unavoidable even if you never write it.
<?= is shorthand for <?php echo, and htmlspecialchars is the escaping that a Jinja or Django template would apply automatically — PHP does not escape by default, which is the root of a large fraction of the language's security reputation. Blade's {{ }} and Twig's do escape, so a modern codebase gets the Django behavior back by choosing a template engine.Strings
Single quotes and double quotes are different types of string
In Python the two quote characters are interchangeable and the
f prefix decides whether interpolation happens. In PHP the quote character itself decides, which means changing a quote style can silently change what a line does.name = "Ada"
print("hello {name}")
print(f"hello {name}")
print('hello {name}')<?php
$name = "Ada";
echo 'hello $name', "\n";
echo "hello $name", "\n";
echo <<<TEXT
hello $name, over several
lines, still interpolating
TEXT;
echo "\n";A single-quoted PHP string is nearly literal — only
\\' and \\\\ mean anything — while a double-quoted string interpolates variables and honors escapes like \n. That is why 'hello $name' prints the dollar sign. The heredoc (<<<TEXT) is the multi-line interpolating form, roughly Python's triple-quoted f-string; its nowdoc sibling <<<'TEXT' is the non-interpolating one. The closing marker must sit on its own line, and since PHP 7.3 it may be indented.Functions, not methods — and the needle/haystack problem
A PHP string is a primitive, not an object, so there is nothing to call a method on. Every string operation is a global function, and the value being operated on is an argument like any other.
sentence = " the quick brown fox "
print(sentence.strip())
print(sentence.strip().upper())
print(sentence.replace("quick", "slow").strip())
print(sentence.strip().split(" "))<?php
$sentence = " the quick brown fox ";
echo trim($sentence), "\n";
echo strtoupper(trim($sentence)), "\n";
echo trim(str_replace("quick", "slow", $sentence)), "\n";
print_r(explode(" ", trim($sentence)));The practical costs are two. Chained transformations read inside-out —
trim(str_replace(...)) instead of .replace(...).strip() — which is why PHP 8.5's pipe operator was such a celebrated addition. And the argument order is famously inconsistent: str_replace($search, $replace, $subject) puts the subject last while strpos($haystack, $needle) puts it first, an artifact of the standard library having grown from two different C libraries in the 1990s. There is no fixing it without breaking every program ever written, so PHP developers keep the manual open and so should you.Testing for a substring
These three functions arrived in PHP 8.0, and their absence before then is the reason so much older PHP is written the wrong way.
path = "reports/2026/summary.csv"
print("2026" in path)
print(path.startswith("reports/"))
print(path.endswith(".csv"))
print(path.index("summary"))<?php
$path = "reports/2026/summary.csv";
var_dump(str_contains($path, "2026"));
var_dump(str_starts_with($path, "reports/"));
var_dump(str_ends_with($path, ".csv"));
var_dump(strpos($path, "summary"));Until 2020 the only way to ask "does this contain that" was
strpos($path, "2026") !== false — and because strpos returns 0 for a match at the start, writing the natural if (strpos(...)) silently reported "not found" for anything at position zero. That single trap generated an extraordinary number of bugs. str_contains, str_starts_with and str_ends_with fixed it, and are the direct equivalents of Python's in, startswith and endswith.Splitting and joining, and their argument order
Both operations exist in both languages and neither name matches. The names are also the two that Python developers most often reach for by muscle memory and get wrong.
line = "name,email,age"
fields = line.split(",")
print(fields)
print(",".join(fields))
print("-".join(reversed(fields)))<?php
$line = "name,email,age";
$fields = explode(",", $line);
print_r($fields);
echo implode(",", $fields), "\n";
echo implode("-", array_reverse($fields)), "\n";explode is split and implode is join, with the separator first in both — which is at least self-consistent, and matches Python's ",".join(...) in spirit if not in shape. implode historically accepted its arguments in either order and that leniency was removed in PHP 8, so the separator-first form is now the only one. A PHP developer never writes join, even though it exists as an alias.A PHP string is bytes, not characters
This is the most consequential difference in the section, and the one that produces mangled output in production. Python 3 made a string a sequence of Unicode code points in 2008. PHP never did.
text = "café"
print(len(text))
print(text[3])
print(text.upper())<?php
$text = "café";
echo strlen($text), "\n";
echo mb_strlen($text), "\n";
echo mb_substr($text, 3, 1), "\n";
echo mb_strtoupper($text), "\n";A PHP string is a byte array, so
strlen("café") is 5 — the length in UTF-8 bytes — and $text[3] hands back half of the é. The mb_ family (from the multibyte extension) is the Unicode-aware parallel set: mb_strlen, mb_substr, mb_strtoupper, mb_str_split. The rule in any PHP codebase handling user text is to use the mb_ function wherever one exists. Python's equivalent split is str against bytes, but there the default is the safe one, and PHP's default is not.Arrays against list and dict
One array type where Python has three
PHP has exactly one container built into the language, and it does the work of Python's list, dict and tuple at once. It is an ordered map: an insertion-ordered sequence of key/value pairs where the keys may be integers or strings.
numbers = [10, 20, 30]
person = {"name": "Ada", "age": 36}
point = (3, 4)
print(numbers[1], person["age"], point[0])
print(len(numbers), len(person))<?php
$numbers = [10, 20, 30];
$person = ["name" => "Ada", "age" => 36];
$point = [3, 4];
echo $numbers[1], " ", $person["age"], " ", $point[0], "\n";
echo count($numbers), " ", count($person), "\n";A "list" in PHP is just an array whose keys happen to be
0, 1, 2, …; nothing enforces that, and array_is_list() exists precisely because you sometimes need to ask. There is no tuple, so no immutable sequence and no hashable composite key — a PHP array key can only be an int or a string, which rules out {(3, 4): "corner"} entirely. And because it is one type, count() answers for both shapes where Python's len happens to as well.Assigning an array COPIES it
This is the deepest semantic difference on the page and the one that most changes how code is written. In Python every name is a reference to an object; in PHP an array is a value, and assigning or passing it hands over a copy.
original = [1, 2, 3]
other = original
other.append(4)
print(original) # [1, 2, 3, 4] — same object
def add_one(items):
items.append(99)
add_one(original)
print(original) # mutated by the callee<?php
$original = [1, 2, 3];
$other = $original;
$other[] = 4;
print_r($original); // still [1, 2, 3] — a copy was made
function addOne(array $items): void {
$items[] = 99;
}
addOne($original);
print_r($original); // untouchedA whole category of Python bug simply does not exist here: no aliasing surprise, no
list(other) defensive copy, and no mutable-default-argument trap. What you lose is the ability to hand a function a container and let it fill it in — for that PHP needs an explicit &$items reference parameter, which is rare and looks alarming precisely because it is rare. The copy is lazy (copy-on-write) under the hood, so this costs nothing until something is actually modified. Objects, by contrast, are handles and behave the Python way — $b = $a on an object gives two names for one object.map, filter and reduce, with the argument order reversed
The three functions exist under recognizable names, and then
array_map takes the callback first while array_filter takes the array first. That is not a typo in this example.numbers = [1, 2, 3, 4, 5, 6]
doubled = list(map(lambda number: number * 2, numbers))
evens = list(filter(lambda number: number % 2 == 0, numbers))
total = sum(numbers)
print(doubled)
print(evens)
print(total)<?php
$numbers = [1, 2, 3, 4, 5, 6];
$doubled = array_map(fn($number) => $number * 2, $numbers);
$evens = array_filter($numbers, fn($number) => $number % 2 === 0);
$total = array_sum($numbers);
print_r($doubled);
print_r($evens);
echo $total, "\n";The inconsistency is real and permanent, for the same backward-compatibility reason as the needle/haystack problem. The other surprise is in the output:
array_filter preserves the original keys, so filtering [1..6] down to the evens yields keys 1, 3 and 5 rather than 0, 1 and 2 — which matters the moment the result is JSON-encoded, because it serializes as an object rather than an array. Wrap it in array_values() whenever you wanted a fresh list.Merging: + is not what you want
Because PHP has one array type, merging two of them has to answer two different questions at once, and the two operators available answer them in opposite directions.
defaults = {"color": "blue", "size": "M"}
chosen = {"size": "L"}
print(defaults | chosen)
print({**defaults, **chosen})
print([1, 2] + [3, 4])<?php
$defaults = ["color" => "blue", "size" => "M"];
$chosen = ["size" => "L"];
print_r(array_merge($defaults, $chosen));
print_r($defaults + $chosen);
print_r([...[1, 2], ...[3, 4]]);array_merge is the one you want: later values win, exactly like Python's | and {**a, **b}. The + operator is the array union, and it keeps the value from the left operand for any key already present — so $defaults + $chosen ignores the choice and reports size M. There is one more asymmetry: array_merge renumbers integer keys while + preserves them, which is why concatenating two lists with + silently drops elements. The spread syntax [...$a, ...$b] works on both string and integer keys since PHP 8.1 and is the modern spelling.Unpacking and destructuring
PHP destructures with square brackets on the left of an assignment, and can destructure by key as well as by position — which Python cannot do for dictionaries at all.
first, second, *rest = [10, 20, 30, 40]
print(first, second, rest)
person = {"name": "Ada", "age": 36}
for key, value in person.items():
print(key, "=", value)<?php
[$first, $second] = [10, 20, 30, 40];
echo $first, " ", $second, "\n";
$person = ["name" => "Ada", "age" => 36];
foreach ($person as $key => $value) {
echo $key, " = ", $value, "\n";
}
["name" => $name] = $person;
echo $name, "\n";There is no starred rest element, so
[$first, *$rest] has no equivalent and you reach for array_slice. The key-based form ["name" => $name] = $person is genuinely handy and has no Python counterpart short of person["name"] written out. Iterating a PHP array yields values by default and $key => $value when asked, which is the union of Python's for x in items and for k, v in mapping.items() in a single construct.Sorting mutates and returns a boolean
PHP's sort functions have no non-mutating form. Every one of them sorts the array in place through a reference parameter and returns
true, so writing $sorted = sort($words) puts a boolean in $sorted.words = ["pear", "fig", "banana"]
print(sorted(words))
print(sorted(words, key=len))
words.sort()
print(words)<?php
$words = ["pear", "fig", "banana"];
$copy = $words;
sort($copy);
print_r($copy);
usort($words, fn($left, $right) => strlen($left) <=> strlen($right));
print_r($words);That is why the example copies first — the PHP idiom for
sorted(x) is "assign, then sort the copy", which works only because assignment already copies. There is no key= parameter either: usort takes a full comparator returning a negative number, zero or a positive number, and the spaceship operator <=> exists to make writing one bearable. The family is large and its names encode the behavior — sort, rsort, asort (keep keys), ksort (by key), usort, uasort, uksort.Functions & Scope
Defining a function, with defaults
Defaults work the same way and are written in the same place. Two smaller differences are worth noting up front.
def greet(name, greeting="Hello", punctuation="!"):
return f"{greeting}, {name}{punctuation}"
print(greet("Ada"))
print(greet("Ada", "Welcome"))<?php
declare(strict_types=1);
function greet(string $name, string $greeting = "Hello", string $punctuation = "!"): string {
return "$greeting, $name$punctuation";
}
echo greet("Ada"), "\n";
echo greet("Ada", "Welcome"), "\n";PHP function names are case-insensitive —
greet() and GREET() call the same function — a legacy quirk that no style guide condones but the engine still honors. Variable names, by contrast, are case-sensitive. And every PHP function lives in a single global namespace unless the file declares one, so there is no module object to attach it to and no from module import greet; the namespace section covers what replaced that.The mutable default argument, and why PHP has no such bug
Every Python developer has been bitten by this once and has been writing
into=None ever since. The reason it happens is that the default value is evaluated at definition time and is a single object shared by every call.def collect(item, into=[]):
into.append(item)
return into
print(collect("a"))
print(collect("b")) # ['a', 'b'] — the SAME list<?php
function collect(string $item, array $into = []): array {
$into[] = $item;
return $into;
}
print_r(collect("a"));
print_r(collect("b")); // ['b'] — a fresh array each timePHP evaluates a default expression fresh on each call, and even if it did not, the array would be copied on assignment — so the bug is unreachable from two directions. It is a small thing, but it is representative: value semantics remove an entire class of aliasing surprise that Python asks you to hold in your head. The corresponding PHP hazard is the opposite one — code that expects a callee to fill in an array it was handed, which silently does nothing.
A function sees nothing outside itself
This bites a Python reader within the first hour. A PHP function body sees its parameters, its own locals, and nothing else — no enclosing scope, no module-level names, not even by read.
multiplier = 3
def scale(value):
return value * multiplier # reads the enclosing name freely
print(scale(5))
def make_scaler(factor):
def scaler(value):
return value * factor # closes over factor automatically
return scaler
print(make_scaler(10)(5))<?php
$multiplier = 3;
function scale(int $value): int {
// $multiplier is NOT visible here.
global $multiplier;
return $value * $multiplier;
}
echo scale(5), "\n";
function makeScaler(int $factor): callable {
return function (int $value) use ($factor): int {
return $value * $factor; // captured explicitly
};
}
echo makeScaler(10)(5), "\n";Python's rule is that reading an outer name is free and only assigning to one needs
global or nonlocal. PHP's rule is that nothing crosses the boundary in either direction: a top-level $multiplier is invisible inside a function, and global $multiplier; (or $GLOBALS['multiplier']) is required even to read it. Closures capture nothing implicitly either — the use ($factor) clause names what comes along, and it captures by value unless written use (&$factor). Constants and function and class names are the exception; those are global and always visible.Named arguments
PHP gained named arguments in 8.0, borrowing an idea Python has had since the beginning, and the spelling is a colon rather than an equals sign.
def make_tag(name, content="", self_closing=False, indent=0):
space = " " * indent
if self_closing:
return f"{space}<{name} />"
return f"{space}<{name}>{content}</{name}>"
print(make_tag("br", self_closing=True))
print(make_tag("p", content="hi", indent=2))<?php
declare(strict_types=1);
function makeTag(string $name, string $content = "", bool $selfClosing = false, int $indent = 0): string {
$space = str_repeat(" ", $indent);
if ($selfClosing) {
return "$space<$name />";
}
return "$space<$name>$content</$name>";
}
echo makeTag("br", selfClosing: true), "\n";
echo makeTag("p", content: "hi", indent: 2), "\n";The semantics match closely: named arguments may follow positional ones, may be given in any order, and let you skip over defaults you do not care about. Two differences worth knowing. PHP has no
* marker to force an argument to be keyword-only, so every parameter is usable positionally forever. And because the name is now part of the public interface, renaming a parameter in PHP is a breaking change for callers — the same trap Python has, but newer and less internalized.Variadic parameters and spreading
PHP writes the ellipsis before the variable rather than a star, and — unusually for a variadic — it can be type-declared, so every collected argument is checked.
def total(*numbers):
return sum(numbers)
print(total(1, 2, 3))
print(total(*[1, 2, 3]))<?php
declare(strict_types=1);
function total(int ...$numbers): int {
return array_sum($numbers);
}
echo total(1, 2, 3), "\n";
echo total(...[1, 2, 3]), "\n";There is no
**kwargs: a PHP variadic collects positional arguments into a list, and named arguments that do not match a declared parameter land in the same variadic keyed by name. The spread on the calling side is the same ellipsis, so total(...[1, 2, 3]) reads exactly like total(*[1, 2, 3]). Note the declaration int ...$numbers — the type applies to each element, and passing a string among the integers is a TypeError at the call, which *numbers: int in Python does not give you. The one real loss is that a PHP variadic must be the last parameter, so there is no equivalent of Python's keyword-only def total(*numbers, scale=1); an extra option has to move in front of the variadic or into an array.Callables & Comprehensions
There is no comprehension
The comprehension is probably the construct a Python developer misses most, and PHP has no syntax for it in any form. Everything is done with the callback functions from the arrays section.
people = [
{"name": "Ada", "age": 36},
{"name": "Bo", "age": 17},
{"name": "Cy", "age": 44},
]
names = [person["name"].upper() for person in people if person["age"] >= 18]
print(names)<?php
$people = [
["name" => "Ada", "age" => 36],
["name" => "Bo", "age" => 17],
["name" => "Cy", "age" => 44],
];
$adults = array_filter($people, fn(array $person) => $person["age"] >= 18);
$names = array_map(fn(array $person) => strtoupper($person["name"]), $adults);
print_r(array_values($names));Three costs come with the translation. The steps run in the opposite order from how the comprehension reads — filter first, then map, where the comprehension puts the map expression at the front. The argument order flips between the two calls, as always. And
array_filter preserves keys, so the array_values at the end is not optional if you want a clean list out. Many PHP codebases give up and use a foreach loop with an accumulator, which is longer but reads in source order and is genuinely idiomatic here rather than a failure of taste.Arrow functions capture automatically; closures do not
PHP has two anonymous function forms, and the difference between them is exactly the capture rule from the scope section.
tax_rate = 0.2
price_with_tax = lambda price: price * (1 + tax_rate)
print(round(price_with_tax(100), 2))
def describe(price):
return f"{price:.2f} including tax"
print(describe(price_with_tax(100)))<?php
$taxRate = 0.2;
$priceWithTax = fn(float $price): float => $price * (1 + $taxRate);
echo round($priceWithTax(100), 2), "\n";
$describe = function (float $price) use ($taxRate): string {
return number_format($price, 2) . " including tax";
};
echo $describe($priceWithTax(100)), "\n";The
fn() => arrow function (PHP 7.4) captures every outer variable it mentions, by value, automatically — which makes it behave like a Python lambda. The price is that it holds a single expression and cannot contain statements, so anything with a loop or an early return has to be the older function () use (...) form and must list its captures. Unlike a Python lambda, though, both PHP forms can carry parameter and return types, and both are real objects (instances of Closure) with bindTo and call methods for rebinding $this.Passing an existing function as a value
A named PHP function is not a value the way a Python function is —
strlen on its own is an undefined constant, not a reference to the function.words = ["fig", "banana", "pear"]
lengths = list(map(len, words))
print(lengths)
print(sorted(words, key=str.lower))<?php
$words = ["fig", "banana", "pear"];
$lengths = array_map(strlen(...), $words);
print_r($lengths);
$upper = array_map(strtoupper(...), $words);
print_r($upper);For most of PHP's history you passed the name as a string:
array_map('strlen', $words), with a method spelled [$object, 'methodName'] and a static one 'ClassName::method'. All of those still work and you will read them constantly. PHP 8.1 added the first-class callable syntax strlen(...) — a literal three-dot token — which produces a real Closure, is checked at compile time rather than at call time, and can be found by an IDE. Prefer it in new code.Control Flow & Truthiness
foreach, and the for loop you rarely need
PHP has a C-style
for ($i = 0; $i < $n; $i++) loop and you will see it, but foreach is the idiomatic form and covers what Python's for covers.for index, word in enumerate(["a", "b", "c"]):
print(index, word)
for number in range(0, 6, 2):
print(number)<?php
foreach (["a", "b", "c"] as $index => $word) {
echo $index, " ", $word, "\n";
}
foreach (range(0, 5, 2) as $number) {
echo $number, "\n";
}Because a PHP array carries its keys,
foreach ($items as $key => $value) does the work of both enumerate and .items() depending on what the array holds — there is no separate function to reach for. range() exists and differs in two ways worth remembering: the end is inclusive, and it builds the whole array eagerly rather than being lazy like Python 3's. For a lazy sequence PHP uses a generator, exactly as Python does.Truthiness, and the string "0"
Most of these agree. One does not, and it is the one that appears in real code.
for value in [0, 1, "", "0", "false", [], [0], None]:
print(repr(value), bool(value))<?php
foreach ([0, 1, "", "0", "false", [], [0], null] as $value) {
echo var_export($value, true), " ", var_export((bool) $value, true), "\n";
}"0" is falsy in PHP and truthy in Python — a legacy of PHP's form-handling origins, where a checkbox submitting the string "0" meant "off". Any code that reads a numeric string from a form, a query parameter or a database and tests it with if ($value) will treat a legitimate zero as absent. The fix is the same one as everywhere else in PHP: test explicitly, with if ($value !== "") or isset(), rather than leaning on truthiness. Note that "false" and "0.0" are both truthy, which makes the rule feel arbitrary rather than learnable.match: an expression here, a pattern matcher there
Both languages added a keyword called
match within a year of each other, and they are not the same feature. Python's is a structural pattern matcher; PHP's is a switch that is an expression and compares strictly.def describe(status):
match status:
case 200 | 201:
return "ok"
case int() as code if code >= 500:
return f"server error {code}"
case {"error": message}:
return f"failed: {message}"
case _:
return "unknown"
print(describe(201))
print(describe(503))
print(describe({"error": "timeout"}))<?php
function describe(int $status): string {
return match (true) {
$status === 200, $status === 201 => "ok",
$status >= 500 => "server error $status",
default => "unknown",
};
}
echo describe(201), "\n";
echo describe(503), "\n";
echo describe(302), "\n";PHP's
match returns a value, compares with === rather than ==, does not fall through, and throws UnhandledMatchError when nothing matches and there is no default — every one of those a fix for a switch misfeature. What it cannot do is destructure: there is no way to bind part of a value the way case {"error": message} does. The match (true) idiom in this example is how PHP developers get arbitrary conditions into the construct, and it is standard rather than a hack. Comma-separated conditions on one arm are PHP's equivalent of the | alternative.Generators are nearly identical
This is the closest convergence on the page. PHP's generators were modeled directly on Python's and share the keyword, the laziness, and the two-way
send() protocol.def countdown(start):
while start > 0:
yield start
start -= 1
for number in countdown(3):
print(number)
print(list(countdown(3)))<?php
function countdown(int $start): Generator {
while ($start > 0) {
yield $start;
$start--;
}
}
foreach (countdown(3) as $number) {
echo $number, "\n";
}
print_r(iterator_to_array(countdown(3)));The differences are small: the return type is spelled
Generator, list() becomes iterator_to_array(), and yield from is spelled yield from in both. A PHP generator also yields keys — yield $key => $value — which fits the array model and has no Python equivalent. What PHP does not have is a comprehension to build one from, so a generator expression like (x * 2 for x in items) must be written as a generator function.Classes & Objects
A class, and the constructor that declares its properties
PHP 8.0 added constructor property promotion: a parameter marked with a visibility keyword declares a property, assigns it, and types it in one place. It is the closest thing PHP has to
@dataclass.from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
point = Point(3, 4)
print(point.distance_from_origin())
print(point)<?php
declare(strict_types=1);
class Point {
public function __construct(
public readonly float $x,
public readonly float $y,
) {}
public function distanceFromOrigin(): float {
return sqrt($this->x ** 2 + $this->y ** 2);
}
}
$point = new Point(3, 4);
echo $point->distanceFromOrigin(), "\n";
echo $point->x, ",", $point->y, "\n";What promotion does not give you is the rest of what
@dataclass generates. There is no automatic __eq__, so two Point objects with the same coordinates are == (PHP compares properties for that operator) but not ===, which compares identity; and there is no generated repr, so printing an object needs a __toString method of your own. Method names use camelCase by convention, per PSR-12, rather than Python's snake_case — the one style rule that trips up a Python developer in code review.private means private
Python's leading underscore is a message to the reader. PHP's
private is a message to the engine, and the engine acts on it.class Account:
def __init__(self, balance):
self._balance = balance # convention only
def deposit(self, amount):
self._balance += amount
account = Account(100)
account.deposit(50)
print(account._balance) # nothing stops this
account._balance = -999
print(account._balance)<?php
declare(strict_types=1);
class Account {
public function __construct(private int $balance) {}
public function deposit(int $amount): void {
$this->balance += $amount;
}
public function balance(): int {
return $this->balance;
}
}
$account = new Account(100);
$account->deposit(50);
echo $account->balance(), "\n";
try {
echo $account->balance, "\n";
} catch (Error $error) {
echo "Error: the property is private\n";
}Reaching for a private PHP property from outside raises an
Error, and there is no _ClassName__attribute back door as there is for Python's double-underscore name mangling — reflection is the only way in, and it is deliberate and visible. The three keywords are public, protected (this class and subclasses) and private (this class only, and note that a sibling instance of the same class can read another's privates). PHP also has real interfaces and final, so "this cannot be subclassed" and "this must be implemented" are statements the compiler checks rather than docstrings.Inheritance is single, and interfaces fill the gap
PHP allows exactly one parent class. Where Python reaches for multiple inheritance and the method resolution order, PHP splits the job in two: an interface for the contract, and a trait for shared implementation.
class Shape:
def area(self):
raise NotImplementedError
class Named:
def label(self):
return self.__class__.__name__
class Square(Shape, Named): # two base classes
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
square = Square(4)
print(square.area(), square.label())<?php
declare(strict_types=1);
interface Shape {
public function area(): float;
}
trait Named {
public function label(): string {
return static::class;
}
}
class Square implements Shape {
use Named;
public function __construct(private float $side) {}
public function area(): float {
return $this->side ** 2;
}
}
$square = new Square(4);
echo $square->area(), " ", $square->label(), "\n";A trait is a block of methods copied into a class at compile time — Python's mixin, but flattened rather than placed in a lookup chain, so there is no MRO to reason about and a conflict between two traits is a compile error you must resolve explicitly with
insteadof. Interfaces may be implemented in any number and are checked: a class that declares implements Shape without an area() of the right signature does not load. That is the abc.ABC guarantee, obtained without inheriting anything.Magic methods against dunder methods
Both languages hook language-level operations onto specially named methods. PHP's are spelled with a double underscore in front only, and — the important part — most of them are reached through an interface rather than by name alone.
class Money:
def __init__(self, cents):
self.cents = cents
def __str__(self):
return f"£{self.cents / 100:.2f}"
def __len__(self):
return self.cents
def __getitem__(self, key):
return f"no such field: {key}"
money = Money(1999)
print(money)
print(len(money))
print(money["vat"])<?php
declare(strict_types=1);
class Money implements Countable, ArrayAccess {
public function __construct(private int $cents) {}
public function __toString(): string {
return "£" . number_format($this->cents / 100, 2);
}
public function count(): int {
return $this->cents;
}
public function offsetExists(mixed $key): bool { return false; }
public function offsetGet(mixed $key): mixed { return "no such field: $key"; }
public function offsetSet(mixed $key, mixed $value): void {}
public function offsetUnset(mixed $key): void {}
}
$money = new Money(1999);
echo $money, "\n";
echo count($money), "\n";
echo $money["vat"], "\n";Defining
count() on a PHP class does nothing until the class declares implements Countable; the same holds for ArrayAccess, Iterator, IteratorAggregate, Stringable and JsonSerializable. That is more ceremony than Python's pure duck typing and it buys a real check: a function declaring function total(Countable $items) cannot be handed something that merely happens to have a count method. The genuinely magic ones with no interface are __get, __set, __call and __invoke, which map to __getattr__, __setattr__, and __call__.self, static, and late static binding
Python's
@classmethod receives the actual class as cls, so a factory inherited by a subclass builds the subclass. PHP needed a separate feature to get the same behavior, and its name is the giveaway that it was an afterthought.class Model:
@classmethod
def create(cls):
return cls()
def name(self):
return type(self).__name__
class User(Model):
pass
print(User.create().name())<?php
class Model {
public static function create(): static {
return new static();
}
public function name(): string {
return static::class;
}
}
class User extends Model {}
echo User::create()->name(), "\n";self:: is resolved where the code is written, so new self() inside Model always builds a Model even when called as User::create(). static:: is late static binding, added in PHP 5.3, and resolves to the class the call actually started from — which is what cls gives you for free. The rule is to write static in any factory or fluent method you expect to be inherited, and self only when you deliberately mean this exact class.Enums & Readonly
Enums
PHP enums arrived in 8.1 and are a language construct rather than a library class, which makes them noticeably better behaved than Python's.
from enum import Enum
class Status(Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
status = Status.PUBLISHED
print(status)
print(status.value)
print(Status("draft"))
print([member.value for member in Status])<?php
enum Status: string {
case Draft = 'draft';
case Published = 'published';
case Archived = 'archived';
}
$status = Status::Published;
echo $status->name, "\n";
echo $status->value, "\n";
echo Status::from('draft')->name, "\n";
echo implode(",", array_map(fn(Status $case) => $case->value, Status::cases())), "\n";A PHP enum case is a singleton object, so
=== works and a match on one is exhaustive-ish (it throws UnhandledMatchError on a case you forgot). It is also usable as a type: function publish(Status $status) is checked at the call, which is the point. from() throws on an unknown value and tryFrom() returns null, a distinction Python's Status("nope") collapses into an always-raise. What PHP enums cannot do is hold state — no properties, no instance data — though they may declare methods, implement interfaces and use constants.Behavior on an enum case
Both languages let an enum carry methods, and the pairing of an enum with a
match over its own cases is the idiom that makes PHP enums worth reaching for.from enum import Enum
class Priority(Enum):
LOW = 1
HIGH = 3
def label(self):
return "urgent" if self is Priority.HIGH else "whenever"
for member in Priority:
print(member.name, member.label())<?php
enum Priority: int {
case Low = 1;
case High = 3;
public function label(): string {
return match ($this) {
Priority::High => "urgent",
Priority::Low => "whenever",
};
}
}
foreach (Priority::cases() as $case) {
echo $case->name, " ", $case->label(), "\n";
}Because the
match has no default, adding a fourth case to Priority makes label() throw UnhandledMatchError the first time it meets that case — a run-time failure rather than a compile-time one, but a loud and immediate one at the site of the omission. Static analyzers (PHPStan, Psalm) turn the same situation into a build error, which is how PHP teams get exhaustiveness checking in practice.readonly properties against a frozen dataclass
PHP 8.1 added
readonly at the property level rather than the class level, which turns out to be the more flexible half of the pair.from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Config:
host: str
port: int
config = Config("localhost", 8080)
print(config.host, config.port)
try:
config.port = 9090
except FrozenInstanceError:
print("frozen: the field cannot be reassigned")<?php
declare(strict_types=1);
final class Config {
public function __construct(
public readonly string $host,
public readonly int $port,
) {}
}
$config = new Config("localhost", 8080);
echo $config->host, " ", $config->port, "\n";
try {
$config->port = 9090;
} catch (Error $error) {
echo "readonly: the property cannot be reassigned\n";
}A
readonly property may be written exactly once, from inside the declaring class's scope, so it can be set in the constructor or in a factory method and never again. That per-property grain means a class can freeze its identity fields while leaving a cache field mutable, which @dataclass(frozen=True) cannot express. What PHP does not give you is the rest of frozen dataclass behavior: no generated equality, and no hashability — although, since a PHP array key can only be an int or a string, there was nothing to be hashable for.Error Handling
try/except becomes try/catch
The structure maps one for one —
except becomes catch, as error becomes a typed parameter, and finally keeps its name.def divide(numerator, denominator):
try:
return numerator / denominator
except ZeroDivisionError as error:
return f"caught: {error}"
finally:
print("always runs")
print(divide(10, 2))
print(divide(10, 0))<?php
declare(strict_types=1);
function divide(int $numerator, int $denominator): string {
try {
return (string) intdiv($numerator, $denominator);
} catch (DivisionByZeroError $error) {
return "caught: " . $error->getMessage();
} finally {
echo "always runs\n";
}
}
echo divide(10, 2), "\n";
echo divide(10, 0), "\n";The type sits before the variable rather than after the keyword, and multiple types on one arm are separated by a pipe:
catch (TypeError|ValueError $error). A bare catch with no type is a parse error, so PHP has no accidental equivalent of a bare except: — the closest is catch (Throwable $error), and writing it is at least deliberate. Since PHP 8 the variable itself is optional (catch (DivisionByZeroError)) when you only care that it happened.Two hierarchies: Exception and Error
Python has a single tree rooted at
BaseException. PHP has two unrelated trees, Exception and Error, joined only by the Throwable interface — and knowing which is which decides whether your catch fires.for thunk in [lambda: 1 / 0, lambda: int("abc"), lambda: [][0]]:
try:
thunk()
except Exception as error:
print(type(error).__name__, "->", error)<?php
$thunks = [
fn() => intdiv(1, 0),
fn() => new DateTime("not a date"),
fn() => str_repeat("x", -1),
];
foreach ($thunks as $thunk) {
try {
$thunk();
} catch (Throwable $error) {
echo get_class($error), " -> ", $error->getMessage(), "\n";
}
}Exception is for conditions a program is expected to handle (a file is missing, input is invalid); Error is for programmer mistakes the engine detects (a TypeError, a call to an undefined method, division by zero). Before PHP 7 the second category was a fatal error that no try could touch, and making them throwable was one of PHP 7's headline changes. The practical rule: catch (Exception $e) is the everyday catch and will not catch a TypeError; catch (Throwable $e) catches everything and belongs only at a top-level boundary.Raising your own, and chaining the cause
Exception chaining exists in both, and PHP's is a constructor argument rather than a
from clause — which means every custom exception has to remember to pass it along.class ConfigError(Exception):
def __init__(self, key):
super().__init__(f"missing config key: {key}")
self.key = key
try:
try:
raise KeyError("database_url")
except KeyError as error:
raise ConfigError("database_url") from error
except ConfigError as error:
print(error, "| caused by", type(error.__cause__).__name__)<?php
declare(strict_types=1);
class ConfigError extends RuntimeException {
public function __construct(public readonly string $key, ?Throwable $previous = null) {
parent::__construct("missing config key: $key", 0, $previous);
}
}
try {
try {
throw new OutOfBoundsException("database_url");
} catch (OutOfBoundsException $error) {
throw new ConfigError("database_url", $error);
}
} catch (ConfigError $error) {
echo $error->getMessage(), " | caused by ", get_class($error->getPrevious()), "\n";
}The
Exception constructor's signature is (string $message, int $code, ?Throwable $previous), and the middle argument is a numeric code almost nobody uses; that is why the 0 is sitting there. getPrevious() is __cause__. The SPL supplies a hierarchy of ready-made types — RuntimeException, LogicException, InvalidArgumentException, OutOfBoundsException — and the convention is to extend one of them rather than Exception directly, so a caller can distinguish "the world went wrong" from "you called me wrongly".Warnings: failures that do not stop anything
PHP has a category of failure Python does not: the warning, which prints a message to the log or the page and then carries on with a null.
values = {"a": 1}
try:
print(values["b"])
except KeyError:
print("KeyError — execution stopped and was resumed by the handler")
print(values.get("b", "default"))<?php
$values = ["a" => 1];
// Reading a missing key emits a WARNING and evaluates to null.
// Execution continues on the next line either way.
echo $values["b"] ?? "default", "\n";
echo isset($values["b"]) ? "present" : "absent", "\n";
echo array_key_exists("b", $values) ? "present" : "absent", "\n";Reading a missing array key, a missing object property, or an undefined variable is a warning rather than an exception, and the expression evaluates to null. The result is that a typo in a key name produces a page that renders with a blank where a value should be — quietly wrong instead of loudly broken, which is the opposite of Python's
KeyError. Every modern codebase turns warnings into exceptions with a custom error handler, or runs a static analyzer that flags them. The three tools for asking about presence differ subtly: ?? and isset() treat a key holding null as absent, while array_key_exists() is the true membership test and is the exact analogue of "b" in values.Null & Absence
None becomes null, and there are three ways to ask
The three questions Python keeps apart — is the key present, is the value None, what is the value or a default — are worth keeping apart in PHP too, because the obvious tool answers two of them at once.
settings = {"timeout": None, "retries": 3}
print(settings.get("timeout") is None)
print("timeout" in settings)
print(settings.get("missing") is None)
print(settings.get("missing", 10))<?php
$settings = ["timeout" => null, "retries" => 3];
var_dump($settings["timeout"] === null);
var_dump(array_key_exists("timeout", $settings));
var_dump(isset($settings["timeout"]));
echo $settings["missing"] ?? 10, "\n";isset() means "present and not null", which is why it reports false for a key that exists and holds null. array_key_exists() is pure membership, matching in. ?? is the null-coalescing operator and is dict.get(key, default) — crucially it also suppresses the missing-key warning, which is why you see it everywhere in PHP code. Its companion ??= assigns only when the left side is null or absent, the same as settings.setdefault(...).The nullsafe operator has no Python equivalent
PHP 8.0 borrowed optional chaining from JavaScript and C#. Python has repeatedly discussed the idea and repeatedly declined it, so a Python reader has no habit to map onto this one.
class Address:
def __init__(self, city):
self.city = city
class Customer:
def __init__(self, address=None):
self.address = address
customer = Customer()
city = customer.address.city if customer.address else "unknown"
print(city)<?php
class Address {
public function __construct(public ?string $city) {}
}
class Customer {
public function __construct(public ?Address $address = null) {}
}
$customer = new Customer();
echo $customer?->address?->city ?? "unknown", "\n";If the value on the left is null,
?-> short-circuits the whole chain to null rather than raising — including any method call and any argument to it, which are never evaluated. Combined with ?? it collapses the nested-conditional shape that Python has to spell out. The thing to be careful about is that it hides the difference between "the customer has no address" and "the address has no city"; when those mean different things, write the branches.Standard Library
JSON
JSON is built into the language rather than imported, and the decode side has a second argument you should always pass.
import json
order = {"id": 7, "items": ["pen", "ink"], "paid": True}
text = json.dumps(order)
print(text)
restored = json.loads(text)
print(restored["items"][1])<?php
$order = ["id" => 7, "items" => ["pen", "ink"], "paid" => true];
$text = json_encode($order);
echo $text, "\n";
$restored = json_decode($text, true);
echo $restored["items"][1], "\n";Without the
true, json_decode returns a stdClass object and you address it with ->; with it you get an associative array and [] access, which is what a Python reader expects. Encoding has the ordered-map problem in reverse: an array with keys 0, 1, 2 encodes as a JSON array, and the same array with a gap in the keys encodes as an object — which is exactly how a stray array_filter turns a list into an object in an API response. Both functions signal failure by returning false/null unless you pass JSON_THROW_ON_ERROR, which is the flag to reach for.Dates and times
PHP ships two date classes that differ in one crucial respect, and choosing the wrong one is the standard PHP date bug.
from datetime import datetime, timedelta, timezone
moment = datetime(2026, 8, 19, 14, 30, tzinfo=timezone.utc)
print(moment.strftime("%Y-%m-%d %H:%M"))
print((moment + timedelta(days=10)).strftime("%Y-%m-%d"))
print(moment.isoformat())<?php
$moment = new DateTimeImmutable("2026-08-19 14:30", new DateTimeZone("UTC"));
echo $moment->format("Y-m-d H:i"), "\n";
echo $moment->add(new DateInterval("P10D"))->format("Y-m-d"), "\n";
echo $moment->format(DATE_ATOM), "\n";DateTime is mutable: $moment->add(...) changes the object in place and returns it, so handing one to a function that adjusts it corrupts the caller's value. DateTimeImmutable returns a new object instead, matching Python's datetime, and is what every style guide now mandates. The format characters are PHP's own rather than strftime's — Y-m-d H:i instead of %Y-%m-%d %H:%M — and intervals use the ISO 8601 duration spelling P10D. The constructor accepts almost any human date string, which is convenient and occasionally too clever.Regular expressions carry their delimiters
PHP's regular expression functions take the pattern as an ordinary string that must carry its own delimiters — usually slashes — with any flags written after the closing one.
import re
log = "2026-08-19 ERROR disk full"
match = re.search(r"^(\d{4})-(\d{2})-(\d{2}) (\w+)", log)
if match:
print(match.group(1), match.group(4))
print(re.sub(r"\s+", " ", "too many spaces"))
print(re.findall(r"\d+", "a1b22c333"))<?php
$log = "2026-08-19 ERROR disk full";
if (preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\w+)/', $log, $matches)) {
echo $matches[1], " ", $matches[4], "\n";
}
echo preg_replace('/\s+/', " ", "too many spaces"), "\n";
preg_match_all('/\d+/', "a1b22c333", $all);
print_r($all[0]);So Python's
re.IGNORECASE flag argument becomes a trailing i: '/error/i'. Single-quoted strings are the right choice for patterns, since a double-quoted one would try to interpolate $ anchors and would eat backslashes. The other structural difference is that captures come back through an output parameter — preg_match returns 1, 0 or false and fills $matches — rather than returning a match object, so the if and the extraction are one statement. The engine is PCRE, so the syntax itself is close to Python's and rather more capable.Reading and writing a file
PHP's common case is a single function call with no handle to manage, which is shorter than the
with block for the overwhelmingly common "read the whole thing" case.path = "/tmp/python-php-note.txt"
with open(path, "w") as handle:
handle.write("first\nsecond\n")
with open(path) as handle:
for line in handle:
print(line.rstrip())
print(len(open(path).read()), "bytes")
import os
os.remove(path)<?php
$path = "/tmp/python-php-note.txt";
file_put_contents($path, "first\nsecond\n");
foreach (file($path, FILE_IGNORE_NEW_LINES) as $line) {
echo $line, "\n";
}
echo strlen(file_get_contents($path)), " bytes\n";
unlink($path);There is no context manager, so the streaming form —
fopen, fgets in a loop, fclose — has to close the handle itself, and does not do so on an exception. In practice a PHP developer reaches for file_get_contents and file_put_contents for anything that fits in memory, and file() when they want the lines as an array. All three accept a URL as well as a path, so file_get_contents('https://example.com') is an HTTP GET — convenient, occasionally a security problem, and disabled in hardened configurations.Composer, Namespaces & Tooling
Namespaces are not modules
This is the piece of PHP that looks most like something familiar and behaves least like it. A PHP
use statement is not an import: it does not read a file, does not run any code, and has no runtime effect at all.# billing/invoice.py
# class Invoice: ...
#
# main.py
# from billing.invoice import Invoice
# import billing.invoice as invoice_module
#
# The import EXECUTES the file, once, and binds a module object.
print("Python: import runs the file and gives you a module")<?php
// src/Billing/Invoice.php
// namespace App\Billing;
// class Invoice { }
//
// main.php
// use App\Billing\Invoice;
// $invoice = new Invoice();
//
// The use statement binds a NAME. Loading the file is a separate concern.
echo "PHP: use is an alias; the autoloader finds the file\n";use App\Billing\Invoice; is purely a compile-time alias saying "in this file, Invoice means that fully-qualified name". Getting the class defined is the autoloader's job, and it happens lazily at the moment the name is first used. There is also no module object — you cannot write invoice_module.something — and namespaces do not nest as a hierarchy the way packages do; App\Billing and App\Billing\Tax are unrelated names that merely share a prefix. Separators are backslashes, which is a genuinely awkward choice inside double-quoted strings.PSR-4 autoloading against import
PHP has no search path and no module cache. Instead, a class name that is not yet defined triggers a registered callback whose job is to include the file that defines it.
# Python finds a module by walking sys.path
# looking for billing/invoice.py, and CACHES the
# module object in sys.modules so it runs once.
import sys
print("entries on the search path:", len(sys.path) > 0)
print("modules already loaded:", len(sys.modules) > 0)<?php
// composer.json:
// "autoload": { "psr-4": { "App\\": "src/" } }
//
// App\Billing\Invoice -> src/Billing/Invoice.php
//
// require 'vendor/autoload.php'; <- ONE include, at the entry point
echo "One require at the top; every class after it is found by name\n";The PSR-4 standard maps a namespace prefix to a directory, so
App\Billing\Invoice becomes src/Billing/Invoice.php by string substitution; Composer generates that callback for you. The practical consequences are pleasant: one require 'vendor/autoload.php' at the entry point and nothing else is ever included by hand, no circular-import problem, and no import-time side effects — because a class file that does anything other than declare a class is a PSR-1 violation. The cost is that the file layout is load-bearing in a way Python's is not, and a typo in a namespace produces "class not found" rather than "file not found".Composer against pip and a virtualenv
Composer arrived in 2012, long after pip, and had the advantage of watching what the earlier tools got wrong. The result is the part of the PHP ecosystem a Python developer is most likely to envy.
# python -m venv .venv && source .venv/bin/activate
# pip install requests
# pip freeze > requirements.txt
#
# The environment is a DIRECTORY YOU ACTIVATE,
# and it is global to the shell, not to the project.
print("Python: an interpreter, an activated environment, and pins")<?php
// composer require guzzlehttp/guzzle
// composer install <- reproduces composer.lock exactly
// composer update
//
// vendor/ lives IN the project. Nothing to activate.
echo "PHP: one lockfile, one vendor directory, no activation\n";Dependencies install into
vendor/ inside the project, so there is no environment to activate, nothing global to collide, and no way to run the wrong Python by accident. composer.json holds the version ranges you asked for and composer.lock holds the exact resolved versions — the split that requirements.txt conflates and that pyproject.toml plus uv.lock or Poetry have only recently brought to Python. Packagist is PyPI. The one thing Composer does not do is manage the PHP version itself; that is your system's or your container's problem, where pyenv and uv would handle it.