Skip to content

02: Modern PHP Syntax for TS Developers

Modern PHP (8.0+) has adopted many syntax features that TypeScript/JavaScript developers will recognize. This chapter explores the syntactic similarities and differences, helping you write idiomatic PHP using familiar patterns.

By the end of this chapter, you’ll be able to:

  • ✅ Use arrow functions in PHP (like TypeScript’s arrow syntax)
  • ✅ Apply null coalescing and nullish coalescing operators
  • ✅ Use spread operators for arrays
  • ✅ Write match expressions (better than switch)
  • ✅ Leverage string interpolation
  • ✅ Use named arguments (like object parameters in TS)
  • ✅ Destructure arrays (PHP’s equivalent to array destructuring)

📁 View Code Examples on GitHub

This chapter includes working examples of modern PHP syntax:

  • Arrow functions and closures
  • Match expressions
  • Spread operators and named arguments
  • Array methods and TypeScript-style helpers

Run the examples:

Terminal window
git clone https://github.com/dalehurley/codewithphp.git
cd codewithphp/code/php-typescript-developers/chapter-02
php arrow-functions.php
// Arrow function syntax
const double = (x: number): number => x * 2;
// Multi-line arrow function
const greet = (name: string): string => {
const message = `Hello, ${name}!`;
return message;
};
// Array methods with arrow functions
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(x => x * 2);
const evens = numbers.filter(x => x % 2 === 0);
<?php
declare(strict_types=1);
// Arrow function (short syntax - single expression only)
$double = fn(int $x): int => $x * 2;
// Multi-line requires traditional closure
$greet = function(string $name): string {
$message = "Hello, {$name}!";
return $message;
};
// Array methods with arrow functions
$numbers = [1, 2, 3, 4, 5];
$doubled = array_map(fn($x) => $x * 2, $numbers);
$evens = array_filter($numbers, fn($x) => $x % 2 === 0);

Key Differences:

FeatureTypeScriptPHP
KeywordNonefn
Single Expression
Multi-line Body❌ (use function)
Implicit Return
Auto-capture Variables

Important: PHP arrow functions (fn) can only have a single expression. For multi-line functions, use traditional closures.

TypeScript:

// Nullish coalescing (??)
const name = user.name ?? "Guest";
const port = config.port ?? 3000;

PHP:

<?php
// Null coalescing (??)
$name = $user['name'] ?? "Guest";
$port = $config['port'] ?? 3000;
// Chain multiple
$value = $a ?? $b ?? $c ?? "default";

Identical behavior! Returns the right operand if the left is null or undefined.

The ??= Operator (Null Coalescing Assignment)

Section titled “The ??= Operator (Null Coalescing Assignment)”

TypeScript:

// Assign only if null/undefined
port ??= 3000;

PHP (7.4+):

<?php
$port ??= 3000; // Only assigns if $port is null or undefined

TypeScript:

// Optional chaining
const city = user?.address?.city;
const firstItem = array?.[0];

PHP (8.0+):

<?php
// Nullsafe operator
$city = $user?->address?->city;
$firstItem = $array[0] ?? null; // No ?. for arrays

PHP Limitation: No optional chaining for arrays—use ?? instead.

// Array spread
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]
// Object spread
const user = { name: "Alice", age: 30 };
const updatedUser = { ...user, age: 31 };
// Function arguments
function sum(...numbers: number[]): number {
return numbers.reduce((a, b) => a + b, 0);
}

PHP (7.4+ for arrays, 8.1+ for named args)

Section titled “PHP (7.4+ for arrays, 8.1+ for named args)”
<?php
// Array spread (7.4+)
$arr1 = [1, 2, 3];
$arr2 = [4, 5, 6];
$combined = [...$arr1, ...$arr2]; // [1, 2, 3, 4, 5, 6]
// Associative array spread (8.1+)
$user = ['name' => 'Alice', 'age' => 30];
$updatedUser = [...$user, 'age' => 31];
// Function arguments (variadic)
function sum(int ...$numbers): int {
return array_sum($numbers);
}
sum(1, 2, 3, 4); // 10

PHP Limitation: Spread in associative arrays requires PHP 8.1+.

function getStatus(code: number): string {
switch (code) {
case 200:
return "OK";
case 404:
return "Not Found";
case 500:
return "Server Error";
default:
return "Unknown";
}
}
<?php
declare(strict_types=1);
function getStatus(int $code): string {
return match($code) {
200 => "OK",
404 => "Not Found",
500 => "Server Error",
default => "Unknown"
};
}

Benefits of Match over Switch:

  • ✅ Returns a value (expression, not statement)
  • ✅ Strict comparison (=== by default)
  • ✅ No fall-through (no break needed)
  • ✅ Exhaustiveness checking

Multiple conditions:

<?php
$result = match($status) {
200, 201, 202 => "Success",
400, 404 => "Client Error",
500, 502, 503 => "Server Error",
default => "Unknown"
};
const name = "Alice";
const age = 30;
// Template literals
const message = `Hello, ${name}! You are ${age} years old.`;
const multiline = `
Line 1
Line 2
Line 3
`;
<?php
$name = "Alice";
$age = 30;
// Double quotes with variable interpolation
$message = "Hello, {$name}! You are {$age} years old.";
// Heredoc (multiline)
$multiline = <<<EOT
Line 1
Line 2
Line 3
EOT;
// Nowdoc (no interpolation, like single quotes)
$nowdoc = <<<'EOT'
Variables like {$name} are not parsed here.
EOT;

Key Differences:

FeatureTypeScriptPHP
Syntax`${var}`"{$var}" or "$var"
QuotesBackticksDouble quotes
MultilineNative in backticksHeredoc (<<<EOT)
Expression`${1 + 1}`Not directly (use concatenation)

Simple variables can omit braces in PHP:

<?php
$name = "Alice";
echo "Hello, $name!"; // Works
echo "Hello, {$name}!"; // More explicit (recommended)
function createUser({
name,
email,
age = 18,
isActive = true
}: {
name: string;
email: string;
age?: number;
isActive?: boolean;
}): User {
return { name, email, age, isActive };
}
// Call with named parameters
const user = createUser({
name: "Alice",
email: "alice@example.com",
age: 30
});
<?php
declare(strict_types=1);
function createUser(
string $name,
string $email,
int $age = 18,
bool $isActive = true
): array {
return compact('name', 'email', 'age', 'isActive');
}
// Call with named arguments
$user = createUser(
name: "Alice",
email: "alice@example.com",
age: 30
);
// Skip optional parameters
$user2 = createUser(
name: "Bob",
email: "bob@example.com",
isActive: false // Skip $age, use default
);

PHP Named Arguments Benefits:

  • ✅ Skip optional parameters
  • ✅ Reorder arguments
  • ✅ Self-documenting code
// Array destructuring
const [a, b, c] = [1, 2, 3];
// Object destructuring
const { name, age } = { name: "Alice", age: 30 };
// Nested destructuring
const { user: { name } } = data;
<?php
// Array destructuring
[$a, $b, $c] = [1, 2, 3];
// Associative array destructuring
['name' => $name, 'age' => $age] = ['name' => 'Alice', 'age' => 30];
// Nested destructuring
['user' => ['name' => $name]] = $data;
// Skip elements
[, , $third] = [1, 2, 3]; // $third = 3

PHP Limitation: No object destructuring—only arrays.

Property Promotion (Constructor Shorthand)

Section titled “Property Promotion (Constructor Shorthand)”
class User {
constructor(
public name: string,
public email: string,
private age: number
) {}
}
const user = new User("Alice", "alice@example.com", 30);
<?php
declare(strict_types=1);
class User {
public function __construct(
public string $name,
public string $email,
private int $age
) {}
}
$user = new User("Alice", "alice@example.com", 30);
echo $user->name; // "Alice"

Identical concept! Properties are automatically declared and assigned.

const arr = [
1,
2,
3, // ✅ Trailing comma allowed
];
function foo(
a: number,
b: number, // ✅ Trailing comma allowed
) {}

PHP (7.3+ for arrays, 8.0+ for parameters)

Section titled “PHP (7.3+ for arrays, 8.0+ for parameters)”
<?php
$arr = [
1,
2,
3, // ✅ Trailing comma allowed
];
function foo(
int $a,
int $b, // ✅ Trailing comma allowed (PHP 8.0+)
): void {}
// Optional chaining
const city = user?.profile?.address?.city;
<?php
// Nullsafe operator
$city = $user?->profile?->address?->city;

Identical behavior! Returns null if any property in the chain is null.

class Calculator {
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
const addFn = calc.add.bind(calc);
const result = addFn(5, 10); // 15
// Or with arrow function
const addFn2 = (a: number, b: number) => calc.add(a, b);
<?php
declare(strict_types=1);
class Calculator {
public function add(int $a, int $b): int {
return $a + $b;
}
}
$calc = new Calculator();
// OLD way (verbose)
$addFn = fn($a, $b) => $calc->add($a, $b);
// NEW way (PHP 8.1+): First-class callable
$addFn = $calc->add(...);
$result = $addFn(5, 10); // 15
// Also works with built-in functions
$upperFn = strtoupper(...);
echo $upperFn('hello'); // "HELLO"
// Static methods
$jsonDecodeFn = json_decode(...);

Benefits:

  • ✅ Cleaner syntax
  • ✅ Works with methods, functions, and static methods
  • ✅ Proper type checking

TypeScript developers coming to PHP often miss familiar array methods. Here’s how they translate:

const numbers = [1, 2, 3, 4, 5];
// map
const doubled = numbers.map(x => x * 2);
// filter
const evens = numbers.filter(x => x % 2 === 0);
// reduce
const sum = numbers.reduce((acc, x) => acc + x, 0);
// find
const firstEven = numbers.find(x => x % 2 === 0);
// some
const hasEven = numbers.some(x => x % 2 === 0);
// every
const allPositive = numbers.every(x => x > 0);
// includes
const hasThree = numbers.includes(3);
// forEach
numbers.forEach(x => console.log(x));
<?php
$numbers = [1, 2, 3, 4, 5];
// map
$doubled = array_map(fn($x) => $x * 2, $numbers);
// filter
$evens = array_filter($numbers, fn($x) => $x % 2 === 0);
// reduce
$sum = array_reduce($numbers, fn($acc, $x) => $acc + $x, 0);
// find (first match) - no direct equivalent, use loop or array_filter
$firstEven = current(array_filter($numbers, fn($x) => $x % 2 === 0)) ?: null;
// some - no direct equivalent, use loop
$hasEven = count(array_filter($numbers, fn($x) => $x % 2 === 0)) > 0;
// every - no direct equivalent
$allPositive = count(array_filter($numbers, fn($x) => $x > 0)) === count($numbers);
// includes
$hasThree = in_array(3, $numbers, true); // Third param = strict comparison
// forEach
array_walk($numbers, fn($x) => print($x . "\n"));
// Or use regular foreach loop (more idiomatic)
foreach ($numbers as $x) {
echo $x . "\n";
}

Important Differences:

  • PHP’s array_map has parameters reversed: array_map(callback, array)
  • Use in_array() for checking existence (with strict: true for type-safe)
  • No direct find(), some(), every() equivalents—use array_filter() with additional logic
  • PHP prefers foreach loops over forEach() method

You can create TypeScript-like helpers:

<?php
declare(strict_types=1);
class Arr {
/**
* @template T
* @param array<T> $array
* @param callable(T): bool $callback
* @return T|null
*/
public static function find(array $array, callable $callback): mixed {
foreach ($array as $item) {
if ($callback($item)) {
return $item;
}
}
return null;
}
/**
* @template T
* @param array<T> $array
* @param callable(T): bool $callback
*/
public static function some(array $array, callable $callback): bool {
foreach ($array as $item) {
if ($callback($item)) {
return true;
}
}
return false;
}
/**
* @template T
* @param array<T> $array
* @param callable(T): bool $callback
*/
public static function every(array $array, callable $callback): bool {
foreach ($array as $item) {
if (!$callback($item)) {
return false;
}
}
return true;
}
}
// Usage
$numbers = [1, 2, 3, 4, 5];
$firstEven = Arr::find($numbers, fn($x) => $x % 2 === 0); // 2
$hasEven = Arr::some($numbers, fn($x) => $x % 2 === 0); // true
$allPositive = Arr::every($numbers, fn($x) => $x > 0); // true
// TypeScript decorators (experimental)
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Calling ${propertyKey} with`, args);
return original.apply(this, args);
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
<?php
declare(strict_types=1);
// Define attribute
#[Attribute]
class Route {
public function __construct(
public string $path,
public string $method = 'GET'
) {}
}
// Use attribute
class UserController {
#[Route('/users', 'GET')]
public function index(): array {
return ['users' => []];
}
#[Route('/users', 'POST')]
public function store(): array {
return ['created' => true];
}
}
// Read attributes via reflection
$reflection = new ReflectionClass(UserController::class);
foreach ($reflection->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
echo "{$route->method} {$route->path} -> {$method->getName()}\n";
}
}

Differences:

  • TypeScript decorators modify behavior at runtime (when supported)
  • PHP attributes are metadata only—they don’t change behavior automatically
  • PHP requires reflection to read attributes
  • Common PHP use cases: routing, validation, ORM mapping
const items = ['a', 'b', 'c'];
// for...of (values)
for (const item of items) {
console.log(item);
}
// for...in (keys)
for (const key in items) {
console.log(key); // "0", "1", "2"
}
// forEach
items.forEach((item, index) => {
console.log(index, item);
});
// Classic for
for (let i = 0; i < items.length; i++) {
console.log(items[i]);
}
<?php
$items = ['a', 'b', 'c'];
// foreach (most common)
foreach ($items as $item) {
echo $item . "\n";
}
// foreach with index
foreach ($items as $index => $item) {
echo "{$index}: {$item}\n";
}
// Classic for
for ($i = 0; $i < count($items); $i++) {
echo $items[$i] . "\n";
}
// while
$i = 0;
while ($i < count($items)) {
echo $items[$i] . "\n";
$i++;
}

Associative Arrays (like Objects):

// TypeScript
const user = { name: "Alice", age: 30 };
for (const [key, value] of Object.entries(user)) {
console.log(`${key}: ${value}`);
}
<?php
// PHP
$user = ['name' => 'Alice', 'age' => 30];
foreach ($user as $key => $value) {
echo "{$key}: {$value}\n";
}
function format(value: string | number | boolean): string {
if (typeof value === "string") {
return value.toUpperCase();
}
if (typeof value === "number") {
return value.toFixed(2);
}
return value ? "Yes" : "No";
}
<?php
declare(strict_types=1);
function format(string|int|float|bool $value): string {
return match(true) {
is_string($value) => strtoupper($value),
is_int($value) || is_float($value) => number_format($value, 2),
is_bool($value) => $value ? "Yes" : "No",
};
}
// Or with if/elseif
function formatAlt(string|int|float|bool $value): string {
if (is_string($value)) {
return strtoupper($value);
}
if (is_int($value) || is_float($value)) {
return number_format($value, 2);
}
return $value ? "Yes" : "No";
}

Match with Truthiness:

  • match(true) allows conditional expressions as cases
  • More elegant than long if/elseif chains
  • Exhaustive by default (must handle all cases)

Let’s build a simple user validator in both languages:

interface User {
name: string;
email: string;
age?: number;
isActive?: boolean;
}
const validateUser = (user: User): string[] => {
const errors: string[] = [];
if (!user.name?.trim()) {
errors.push("Name is required");
}
if (!user.email?.includes("@")) {
errors.push("Invalid email");
}
if ((user.age ?? 0) < 18) {
errors.push("Must be 18 or older");
}
return errors;
};
// Usage
const user = { name: "Alice", email: "alice@example.com", age: 30 };
const errors = validateUser(user);
<?php
declare(strict_types=1);
class User {
public function __construct(
public string $name,
public string $email,
public ?int $age = null,
public bool $isActive = true
) {}
}
/**
* @return array<string>
*/
function validateUser(User $user): array {
$errors = [];
if (empty(trim($user->name))) {
$errors[] = "Name is required";
}
if (!str_contains($user->email, "@")) {
$errors[] = "Invalid email";
}
if (($user->age ?? 0) < 18) {
$errors[] = "Must be 18 or older";
}
return $errors;
}
// Usage
$user = new User("Alice", "alice@example.com", 30);
$errors = validateUser($user);

Given this old-style PHP code, refactor it using modern syntax:

<?php
// Old style
function calculateDiscount($price, $discountPercent = 10, $isMember = false) {
if (!isset($price)) {
return 0;
}
$discount = $isMember ? $discountPercent * 1.5 : $discountPercent;
switch ($discount) {
case 10:
$label = "Standard";
break;
case 15:
$label = "Member";
break;
default:
$label = "Custom";
break;
}
return array(
'original' => $price,
'discount' => $discount,
'final' => $price - ($price * $discount / 100),
'label' => $label
);
}
Solution
<?php
declare(strict_types=1);
function calculateDiscount(
?float $price,
float $discountPercent = 10,
bool $isMember = false
): array {
$price ??= 0; // Null coalescing assignment
// Ternary with clear intent
$discount = $isMember ? $discountPercent * 1.5 : $discountPercent;
// Match expression (better than switch)
$label = match($discount) {
10 => "Standard",
15 => "Member",
default => "Custom"
};
// Named array keys
return [
'original' => $price,
'discount' => $discount,
'final' => $price - ($price * $discount / 100),
'label' => $label
];
}

Modern features used:

  • Type declarations
  • Null coalescing assignment (??=)
  • Match expression
  • Short array syntax []

Convert this TypeScript function to modern PHP:

const processItems = (items: number[]): { sum: number; avg: number } => {
const sum = items.reduce((acc, val) => acc + val, 0);
const avg = sum / items.length;
return { sum, avg };
};
const numbers = [1, 2, 3, 4, 5];
const result = processItems(numbers);
console.log(`Sum: ${result.sum}, Avg: ${result.avg}`);
Solution
<?php
declare(strict_types=1);
/**
* @param array<int> $items
*/
function processItems(array $items): array {
$sum = array_reduce(
$items,
fn($acc, $val) => $acc + $val,
0
);
$avg = $sum / count($items);
return compact('sum', 'avg');
// Or: return ['sum' => $sum, 'avg' => $avg];
}
$numbers = [1, 2, 3, 4, 5];
$result = processItems($numbers);
echo "Sum: {$result['sum']}, Avg: {$result['avg']}" . PHP_EOL;

Key translations:

  • Arrow function → fn($acc, $val) => $acc + $val
  • reduce()array_reduce()
  • lengthcount()
  • Template literal → String interpolation with {}
  1. Arrow functions (fn) support only single expressions; use traditional closures for multi-line
  2. Null coalescing (??, ??=) works identically to TypeScript’s nullish coalescing
  3. Nullsafe operator (?->) is PHP’s version of optional chaining (objects only, not arrays)
  4. Match expressions are superior to switch: return values, strict comparison, no fall-through
  5. Named arguments (PHP 8.0+) allow skipping optional parameters and reordering
  6. Property promotion (PHP 8.0+) makes constructors as concise as TypeScript
  7. String interpolation uses double quotes "{$var}" instead of backticks `${var}`
  8. Spread operator works for arrays; associative array spread requires PHP 8.1+
  9. First-class callables (PHP 8.1+): Use fn(...) syntax for cleaner function references
  10. Array functions differ: array_map(callback, array) has reversed parameter order vs JS
  11. PHP lacks array.find(), array.some(), array.every()—use array_filter() or custom helpers
  12. Attributes (PHP 8.0+) are metadata-only, unlike TypeScript decorators that modify behavior
  13. foreach is idiomatic in PHP; prefer it over array_walk() for most iterations
  14. Union types work great with match(true) for elegant type-based logic
FeatureTypeScriptPHP
Arrow function(x) => x * 2fn($x) => $x * 2
Null coalescinga ?? b$a ?? $b
Optional chainobj?.prop$obj?->prop
Spread...arr...$arr
Match/Switchswitch (x) {}match($x) {}
String template`Hello ${x}`"Hello {$x}"
Named argsfoo({ x: 1 })foo(x: 1)
Destructure[a, b] = arr[$a, $b] = $arr

Now that you’re familiar with modern PHP syntax, let’s dive deeper into functions and closures.

Next Chapter: 03: Functions & Closures: From JS to PHP


Questions or feedback? Open an issue on GitHub