PHP 8 Features Every Developer Should Know
You open a class written for PHP 7 and the first forty lines are property declarations followed by a constructor that copies each argument into $this. Further down there's a switch with a missing break, a pyramid of if ($x !== null) checks, and a call like setcookie('name', 'value', 0, '', '', false, true) where nobody remembers which false is which. PHP 8 (released November 26, 2020) fixes most of that at the language level.
This post walks through the PHP 8 features I actually reach for: the JIT compiler (and why it probably won't speed up your web app), named arguments, attributes, union types, the match expression, constructor promotion, the nullsafe operator and the stricter error handling that tends to bite during upgrades.
- JIT Compiler and its performance implications
- Named arguments for cleaner function calls
- Attributes (PHP's annotation system)
- Union types and mixed type
- Match expression vs switch
- Constructor property promotion
- Nullsafe operator
- New string and array functions
JIT Compilation: Fast, but Mostly for CPU Work
Without JIT, PHP compiles your code to opcodes (cached by OPcache) and then the Zend VM interprets those opcodes on every request. The Just-In-Time (JIT) compiler goes one step further and turns hot code paths into native machine code, so the VM doesn't have to interpret them anymore.
JIT compiles hot code paths to native machine code, which helps most when the CPU is the bottleneck
JIT pays off for CPU-bound work: number crunching, image processing, big data transformations. A typical Laravel request spends most of its time waiting on the database or an HTTP API, so the gain there is usually small. Benchmark your own app before you count on it.
JIT is off by default because opcache.jit_buffer_size defaults to 0. To turn it on, add these settings to your php.ini:
opcache.enable=1
opcache.jit_buffer_size=256M
opcache.jit=1255
Two things that trip people up. First, if you're testing JIT from the command line, you also need opcache.enable_cli=1, otherwise OPcache (and therefore JIT) isn't active for CLI scripts at all. Second, extensions that hook into the executor, Xdebug being the usual suspect, cause PHP to disable JIT and log a warning that it's incompatible with third party extensions overriding zend_execute_ex(). Check opcache_get_status()['jit'] to confirm it's actually running.
Named Arguments: Clarity in Function Calls
Look at the PHP 7 setcookie() call below and try to say what each argument means without opening the docs. Named arguments let you pass values by parameter name instead of position, which matters most for functions with a long tail of optional parameters.
❌ PHP 7 (Positional Only)
// What do these mean?
htmlspecialchars(
$string,
ENT_COMPAT | ENT_HTML5,
'UTF-8',
false
);
// Skipping to a later parameter
setcookie(
'name',
'value',
0, // expires - not used
'', // path - not used
'', // domain - not used
false, // secure - not used
true // httponly - the one we want!
);
✓ PHP 8 (Named Arguments)
// Crystal clear intent
htmlspecialchars(
string: $string,
flags: ENT_COMPAT | ENT_HTML5,
encoding: 'UTF-8',
double_encode: false
);
// Skip right to what matters
setcookie(
name: 'name',
value: 'value',
httponly: true
);
Key Benefits
• Self-documenting code
• Skip optional parameters
• Order-independent arguments
• Combine with positional args
Rules to Remember
• Named args must come after positional
• Can't use same name twice
• Works with variadic functions
• Respects default values
One catch: once callers use named arguments, your parameter names become part of your public API. Rename $userId to $id in a library and every call site using userId: fails with Error: Unknown named parameter $userId. Treat renames like signature changes.
Attributes: Native Annotations
Before PHP 8, Doctrine and Symfony read routing and mapping config out of docblock comments, which meant a typo in @Route was just a broken comment until a parser library choked on it. Attributes give you real syntax for structured metadata on classes, methods, properties and parameters, and you read them back through the Reflection API.
An attribute is just a class marked with #[Attribute]. The flags restrict where it can be applied:
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Route
{
public function __construct(
public string $path,
public array $methods = ['GET'],
public ?string $name = null
) {}
}
#[Attribute(Attribute::TARGET_PROPERTY)]
class Validate
{
public function __construct(
public string $rule,
public ?string $message = null
) {}
}
class UserController
{
#[Route('/users', methods: ['GET'], name: 'user.list')]
public function index(): array
{
return User::all();
}
#[Route('/users/{id}', methods: ['GET', 'POST'])]
public function show(int $id): User
{
return User::findOrFail($id);
}
}
class User
{
#[Validate('email', message: 'Invalid email format')]
public string $email;
#[Validate('min:8', message: 'Password too short')]
public string $password;
}
$reflection = new ReflectionClass(UserController::class);
foreach ($reflection->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
echo "Path: {$route->path}, Methods: " . implode(',', $route->methods);
}
}
Union Types: Flexible Type Declarations
Plenty of PHP 7 code had @param int|float $a in the docblock and no real type at all, because the language couldn't express "one of these". With union types you can declare that a parameter, return value or property accepts several types, and PHP enforces it at runtime.
class Calculator
{
// Accept int or float, return int or float
public function add(int|float $a, int|float $b): int|float
{
return $a + $b;
}
}
class Repository
{
// Return entity or null
public function find(int $id): User|null
{
return User::find($id);
}
// Accept multiple input types
public function search(string|array $criteria): Collection
{
if (is_string($criteria)) {
$criteria = ['name' => $criteria];
}
return User::where($criteria)->get();
}
}
// Properties can have union types too
class ApiResponse
{
public string|array|null $data = null;
public int|string $statusCode = 200;
}
voidcannot be part of a union type?Typecan't be combined with a union: writeint|string|null, not?int|stringfalsecan be used as part of a union (likearray|false), but not on its own until PHP 8.2- Duplicate types (including via inheritance) are not allowed
Match Expression: The Better Switch
Most switch bugs I've seen come from a forgotten break or from loose comparison matching something it shouldn't. match avoids both: it compares strictly, returns a value, and has no fall-through.
❌ PHP 7 Switch
switch ($statusCode) {
case 200:
case 201:
$message = 'Success';
break;
case 400:
$message = 'Bad Request';
break;
case 404:
$message = 'Not Found';
break;
case 500:
$message = 'Server Error';
break;
default:
$message = 'Unknown';
}
echo $message;
✓ PHP 8 Match
$message = match($statusCode) {
200, 201 => 'Success',
400 => 'Bad Request',
404 => 'Not Found',
500 => 'Server Error',
default => 'Unknown',
};
echo $message;
| Feature | switch | match |
|---|---|---|
| Comparison type | Loose (==) | Strict (===) |
| Returns value | No | Yes |
| Fall-through | Yes (needs break) | No |
| Multiple conditions | Multiple case statements | Comma-separated |
| Unmatched value | Continues silently | Throws UnhandledMatchError |
That last row matters when you convert existing switches. If $statusCode is 418 and there's no default arm, you get UnhandledMatchError: Unhandled match case 418 instead of a silently unset variable. Also watch for status codes that arrive as strings from a request or a database: match("200") won't hit the 200 arm.
// Using expressions in conditions
$result = match(true) {
$age < 13 => 'Child',
$age < 20 => 'Teenager',
$age < 65 => 'Adult',
default => 'Senior',
};
// With objects and methods
$action = match($request->getMethod()) {
'GET' => $this->index(),
'POST' => $this->store($request),
'PUT', 'PATCH' => $this->update($request),
'DELETE' => $this->destroy($request),
default => throw new MethodNotAllowedException(),
};
Constructor Property Promotion
In the PHP 7 version below, every property name appears four times: declaration, parameter, and both sides of the assignment. Constructor property promotion lets you declare and assign properties right in the constructor signature.
❌ PHP 7 (Verbose)
class User
{
private string $name;
private string $email;
private int $age;
private bool $active;
public function __construct(
string $name,
string $email,
int $age,
bool $active = true
) {
$this->name = $name;
$this->email = $email;
$this->age = $age;
$this->active = $active;
}
}
✓ PHP 8 (Promoted)
class User
{
public function __construct(
private string $name,
private string $email,
private int $age,
private bool $active = true,
) {}
}
// That's it! Properties are declared
// and assigned automatically.
Promoted parameters can carry attributes, which is handy for validation and serialization in DTOs. Note that the readonly keyword in this example needs PHP 8.1; drop it if you're on 8.0.
class CreateUserDTO
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50)]
public readonly string $name,
#[Assert\Email]
public readonly string $email,
#[Assert\PositiveOrZero]
public readonly int $age = 0,
) {}
}
Nullsafe Operator: Chain with Confidence
With the nullsafe operator (?->) you can chain method calls and property reads through values that might be null. If any link in the chain is null, the rest is skipped and the whole expression evaluates to null.
❌ PHP 7 (Null Checks)
// Tedious null checking
$country = null;
if ($user !== null) {
$address = $user->getAddress();
if ($address !== null) {
$country = $address->getCountry();
if ($country !== null) {
$country = $country->getName();
}
}
}
// Or with ternaries (still ugly)
$country = $user
? ($user->getAddress()
? ($user->getAddress()->getCountry()
? $user->getAddress()->getCountry()->getName()
: null)
: null)
: null;
✓ PHP 8 (Nullsafe)
// Clean and elegant
$country = $user
?->getAddress()
?->getCountry()
?->getName();
// If any part is null,
// the whole expression is null.
// No errors, no exceptions.
// API response handling
$userName = $response?->data?->user?->name ?? 'Guest';
// Config access
$timeout = $config?->getDatabase()?->getTimeout() ?? 30;
// Collection operations
$firstUserEmail = $users->first()?->email;
// Combined with null coalescing
$avatar = $user?->profile?->avatar ?? '/default-avatar.png';
New String and Array Functions
The strpos() !== false idiom has caused more bugs than it deserves, because a match at position 0 is falsy. PHP 8 adds functions that just say what you mean. (array_is_list() came a bit later, in 8.1, but it fits here.)
str_contains(), str_starts_with(), str_ends_with()
// Before PHP 8
if (strpos($haystack, $needle) !== false) { ... }
if (strpos($haystack, $needle) === 0) { ... }
if (substr($haystack, -strlen($needle)) === $needle) { ... }
// PHP 8 - Clean and readable
if (str_contains($haystack, $needle)) { ... }
if (str_starts_with($url, 'https://')) { ... }
if (str_ends_with($filename, '.php')) { ... }
// Real-world examples
$isSecure = str_starts_with($url, 'https://');
$isImage = str_ends_with($file, '.jpg') || str_ends_with($file, '.png');
$hasKeyword = str_contains($content, 'php8');
array_is_list() (PHP 8.1)
// Check if array is a list (sequential integer keys starting from 0)
$list = [1, 2, 3, 4, 5];
$assoc = ['a' => 1, 'b' => 2];
$mixed = [0 => 'a', 2 => 'b']; // Gap in keys
array_is_list($list); // true
array_is_list($assoc); // false
array_is_list($mixed); // false
array_is_list([]); // true (empty array is a list)
// Useful for JSON encoding decisions
if (array_is_list($data)) {
// Will encode as JSON array: [1, 2, 3]
} else {
// Will encode as JSON object: {"a": 1, "b": 2}
}
Throw Expression
In PHP 7 you couldn't write fn() => throw ... or $x ?? throw ..., because throw was a statement. In PHP 8 it's an expression, so it works anywhere a value is expected: arrow functions, null coalescing, ternaries and match arms.
// In arrow functions
$fn = fn() => throw new Exception('Error!');
// With null coalescing
$value = $array['key'] ?? throw new InvalidArgumentException('Key required');
// In ternary operators
$result = $condition
? $this->process()
: throw new LogicException('Condition not met');
// Short-circuit evaluation
$user = $this->findUser($id) ?? throw new UserNotFoundException();
// In match expressions
$message = match($code) {
200 => 'OK',
404 => 'Not Found',
default => throw new UnexpectedValueException("Unknown code: $code"),
};
Improved Error Handling
This is the part of PHP 8 most likely to break an upgrade. A lot of code that used to emit a warning and keep going now throws, and if nothing catches it, the request dies:
TypeError for Internal Functions
Internal functions now throw TypeError on invalid argument types instead of emitting warnings.
ValueError
New ValueError exception for valid types but invalid values (e.g., negative array size).
Consistent Type Errors
Extension functions now behave like user-defined functions with strict typing.
Better Error Messages
Error messages now include more context: expected type, given type, and parameter name.
// PHP 7: Warning, returns null
strlen([]); // Warning: strlen() expects string, array given
// PHP 8: TypeError exception
strlen([]); // TypeError: strlen(): Argument #1 ($string) must be of type string, array given
// New ValueError
array_fill(-1, 5, 'x'); // ValueError: array_fill(): Argument #1 ($start_index) must be >= 0
// Better error messages
function greet(string $name): void {}
greet(123);
// PHP 8: TypeError: greet(): Argument #1 ($name) must be of type string, int given
Other Notable Features
Trailing Comma in Parameter Lists
You can now add trailing commas to function parameters and closure use lists:
function longFunctionName(
string $param1,
int $param2,
array $param3, // Trailing comma allowed!
) { }
$closure = function($a, $b,) use ($c, $d,) {
// ...
};
This makes version control diffs cleaner and rearranging parameters easier.
::class on Objects
You can now use ::class on objects instead of get_class():
$user = new User();
// PHP 7
$className = get_class($user);
// PHP 8
$className = $user::class;
Mixed Type
The mixed type represents any value and is equivalent to array|bool|callable|int|float|null|object|resource|string:
function processData(mixed $data): mixed
{
// Can accept and return any type
return $data;
}
// Useful for:
// - Legacy code migration
// - Truly polymorphic functions
// - External API responses
WeakMap
WeakMap allows you to store data associated with objects without preventing garbage collection:
$cache = new WeakMap();
$user = new User();
$cache[$user] = computeExpensiveData($user);
// When $user goes out of scope and has no other references,
// both $user AND its cached data are garbage collected.
unset($user);
// Cache entry is automatically removed!
Good for per-object caches, memoization and attaching metadata to objects you don't own, especially in long-running workers where a plain array cache would leak memory.
Migration Checklist
Before Upgrading to PHP 8
- Run your test suite on PHP 8 and fix any failures
- Check for deprecated features removed in PHP 8
- Update third-party dependencies to PHP 8-compatible versions
- Review loose
==comparisons between numbers and strings:0 == "foo"is nowfalse - Test internal function calls that might now throw TypeError or ValueError
- Update any code relying on specific warning/error behavior
- Check for
@error suppression on code that now throws exceptions (@doesn't stop exceptions) - Replace docblock annotations with attributes only once your framework version supports them
What I'd Adopt First (and What I'd Leave Alone)
Constructor promotion, match, the nullsafe operator and the str_* functions are low risk. They're local changes, easy to review, and they remove real bugs. I'd use them in any new code without a second thought.
Union types are worth adding at boundaries (repository methods, service inputs), but if a function ends up as string|array|int|null, that's usually a sign it's doing too much. Split it rather than widening the type. Same goes for mixed: it's honest for genuinely untyped data, but it also switches off most of what static analysis can tell you.
I'd be careful with named arguments on public APIs you maintain, for the rename reason above, and I wouldn't turn on JIT just because it exists. For a normal CRUD app the difference is usually hard to measure, and it adds another variable when you're debugging a segfault or a profiler that refuses to cooperate. Turn it on when a profile shows CPU-bound PHP code is the bottleneck.
PHP 8.0 itself is out of security support, so if you're upgrading now, aim for a current 8.x release. Everything here still applies, and you also get 8.1 and 8.2 additions like enums, readonly properties, fibers and the new random extension.