Modern PHP Features Every Senior PHP Developer Should Know

PHP has changed a lot since the PHP 5 days.

Modern PHP is strongly typed, object-oriented, expressive, and has many features that help us write code that is easier to understand and harder to break.

If you are preparing for a Senior PHP Developer role, you don’t need to memorize every PHP feature.

But you should understand the important ones, know why they exist, and recognize when they make your code better.

This article explains some of the most important modern PHP features in very simple English.

Examples below assume modern PHP 8.x. Some features require PHP 8.1, 8.2, 8.3, 8.4, or 8.5.


1. Named Arguments

Introduced in PHP 8.0

Normally, when calling a function, you pass arguments based on their position.

For example:

function createUser(
    string $name,
    int $age,
    bool $active
) {
    // ...
}

createUser('John', 30, true);

Here:

'John' → $name
30     → $age
true   → $active

The problem is that when a function has many parameters, it can become difficult to understand what each value means.

Named arguments solve this

You can specify the parameter name:

createUser(
    name: 'John',
    age: 30,
    active: true
);

Now the code explains itself.

You can also skip optional parameters:

function connect(
    string $host,
    int $port = 3306,
    bool $ssl = false
) {
    // ...
}

connect(
    host: 'localhost',
    ssl: true
);

You don’t need to write:

connect('localhost', 3306, true);

Why is this useful for a senior developer?

Because senior developers care about readability.

Compare:

sendEmail(
    $user,
    true,
    false,
    5
);

with:

sendEmail(
    user: $user,
    sendCopy: true,
    trackEmail: false,
    retryCount: 5
);

The second version is much easier to understand.

Important warning

Named arguments depend on parameter names.

This:

function test(string $username)

is different from:

function test(string $userName)

Changing parameter names can therefore affect callers using named arguments.

Senior developer takeaway: use named arguments when they make an API call clearer, especially when several parameters have the same basic type or many optional parameters exist.

PHP introduced named arguments in PHP 8.0.


2. Union Types

Introduced in PHP 8.0

A union type means:

“This value can be one of several types.”

For example:

function findUser(int|string $id): User
{
    // ...
}

This means $id can be:

findUser(10);

or:

findUser('abc-123');

Both are valid.

But this is not:

findUser([]);

because an array is neither int nor string.

Before PHP 8

Developers often used PHPDoc:

/**
 * @param int|string $id
 */
function findUser($id)
{
}

PHP itself didn’t enforce that documentation.

Now PHP can understand and enforce it:

function findUser(int|string $id)
{
}

Another example

function formatValue(string|int|float $value): string
{
    return (string) $value;
}

The value can be:

formatValue('hello');
formatValue(100);
formatValue(10.5);

Why does this matter?

It makes your function’s contract very clear.

When I see:

function process(Order|Invoice $document)

I immediately know:

This function accepts either an Order or an Invoice.

That’s much better than:

function process($document)

Senior developer takeaway: use native types whenever possible instead of relying on PHPDoc for information PHP itself can enforce.

Union types were introduced in PHP 8.0.


3. Attributes #[...]

Introduced in PHP 8.0

This is one of the most important modern PHP concepts.

You may have seen code like:

#[Route('/users')]
public function users()
{
}

What is #[...]?

It is an attribute.

An attribute is basically:

Extra information attached to a class, method, property, parameter, etc.

Before PHP 8

Frameworks commonly used PHPDoc annotations:

/**
 * @Route("/users")
 */
public function users()
{
}

Modern PHP can use:

#[Route('/users')]
public function users()
{
}

Attributes are structured language metadata rather than specially formatted comments.

Why are attributes useful?

Frameworks can inspect them using Reflection.

For example, imagine:

#[Cache(ttl: 3600)]
public function getProducts()
{
}

A framework could inspect the method and say:

“This method has the Cache attribute, so cache its result for 3600 seconds.”

Real-world examples

You will see attributes in frameworks and libraries for things such as:

#[Route('/users')]
#[ORM\Entity]
#[ORM\Column(type: 'string')]
#[IsGranted('ROLE_ADMIN')]
#[SensitiveParameter]

The exact attributes depend on the framework/library.

Important point

Attributes do not mean PHPDoc is dead.

PHPDoc is still useful for things such as:

/**
 * @var list<User>
 */
private array $users;

or generic/static-analysis information that PHP’s native type system cannot express.

So don’t think:

“Attributes completely replace PHPDoc.”

Think:

“Attributes are the native PHP mechanism for structured metadata.”

Senior developer takeaway: you should be comfortable reading and creating custom attributes and understanding that frameworks can inspect them using Reflection.


4. Constructor Property Promotion

Introduced in PHP 8.0

This feature removes a lot of boring code.

Suppose you have:

class User
{
    private string $name;
    private string $email;
    private int $age;

    public function __construct(
        string $name,
        string $email,
        int $age
    ) {
        $this->name = $name;
        $this->email = $email;
        $this->age = $age;
    }
}

There is a lot of repetition.

PHP lets us write:

class User
{
    public function __construct(
        private string $name,
        private string $email,
        private int $age
    ) {
    }
}

That’s it.

PHP understands:

private string $name;
private string $email;
private int $age;

and automatically assigns the constructor arguments to those properties.

You can also combine it with readonly

class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name
    ) {
    }
}

This is extremely common in DTOs and value objects.

Why senior developers should know it

Because modern PHP codebases use constructor promotion heavily.

Instead of:

private LoggerInterface $logger;
private UserRepository $repository;
private MailerInterface $mailer;

public function __construct(
    LoggerInterface $logger,
    UserRepository $repository,
    MailerInterface $mailer
) {
    $this->logger = $logger;
    $this->repository = $repository;
    $this->mailer = $mailer;
}

you can write:

public function __construct(
    private LoggerInterface $logger,
    private UserRepository $repository,
    private MailerInterface $mailer
) {
}

Much cleaner.


5. Enums

Introduced in PHP 8.1

Enums are extremely important for modern PHP.

Imagine a user’s status:

pending
active
blocked

A beginner might write:

class User
{
    public const STATUS_PENDING = 'pending';
    public const STATUS_ACTIVE = 'active';
    public const STATUS_BLOCKED = 'blocked';
}

Then:

function updateStatus(string $status)
{
}

The problem?

This is also allowed:

updateStatus('banana');

PHP doesn’t know that banana is not a valid status.

Enum solves this

enum UserStatus
{
    case Pending;
    case Active;
    case Blocked;
}

Now:

function updateStatus(UserStatus $status): void
{
}

You call:

updateStatus(UserStatus::Active);

But:

updateStatus('banana');

is invalid.

That’s a huge advantage.

PHP’s own documentation describes enums as a way to get validation through the type system instead of simply using a set of constants.

Backed enums

Often your database stores:

pending
active
blocked

You can create a backed enum:

enum UserStatus: string
{
    case Pending = 'pending';
    case Active = 'active';
    case Blocked = 'blocked';
}

Now:

$status = UserStatus::Active;

echo $status->value;

Output:

active

You can also convert a database value into an enum:

$status = UserStatus::from('active');

Or safely:

$status = UserStatus::tryFrom('something');

from() throws when there is no matching case, while tryFrom() returns null.

Enums can contain methods

For example:

enum UserStatus: string
{
    case Pending = 'pending';
    case Active = 'active';
    case Blocked = 'blocked';

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Waiting',
            self::Active => 'Active User',
            self::Blocked => 'Blocked User',
        };
    }
}

Then:

echo UserStatus::Active->label();

Enums can contain methods and implement interfaces.

Senior developer takeaway

Whenever you have a fixed set of valid states, ask yourself:

“Should this be an enum?”

Examples:

OrderStatus
PaymentStatus
UserRole
Environment
Currency
NotificationType
SubscriptionPlan

Enums are one of the features I would consider must-know for senior PHP development.


6. Readonly Properties

Introduced in PHP 8.1

Readonly means:

Once this property gets a value, you cannot change it.

Example:

class User
{
    public readonly int $id;

    public function __construct(int $id)
    {
        $this->id = $id;
    }
}

This works:

$user = new User(10);

echo $user->id;

But this doesn’t:

$user->id = 20;

PHP throws an error.

Readonly properties can only be initialized once.

Why is this useful?

Imagine a Money object:

class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency
    ) {
    }
}

Once created:

$money = new Money(100, 'USD');

you don’t want:

$money->amount = 999999;

That could create unexpected behavior.

Readonly makes the object’s state stable.

Important detail

Readonly doesn’t mean the object inside the property is automatically immutable.

For example:

class User
{
    public function __construct(
        public readonly Address $address
    ) {
    }
}

You cannot replace:

$user->address = new Address(...);

But the Address object itself may still be mutable.

This distinction is important for senior developers.


7. Readonly Classes

Introduced in PHP 8.2

Instead of doing this:

class User
{
    public readonly int $id;
    public readonly string $name;
    public readonly string $email;
}

you can make the whole class readonly:

readonly class User
{
    public int $id;
    public string $name;
    public string $email;
}

All instance properties become readonly automatically. A readonly class also prevents dynamic properties.

Very useful for DTOs

For example:

readonly class UserData
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email
    ) {
    }
}

Now this object represents data that should not change after creation.

Think of it like this

Normal class:

Object
 ├── name       → can change
 ├── email      → can change
 └── age        → can change

Readonly class:

Object
 ├── name       → cannot change
 ├── email      → cannot change
 └── age        → cannot change

This is particularly useful for:

  • DTOs
  • Value Objects
  • configuration objects
  • immutable data structures

8. true, false, and null as Standalone Types

Introduced in PHP 8.2

Normally we write:

function isEnabled(): bool

But sometimes you know that a function always returns false.

Now you can write:

function featureAvailable(): false
{
    return false;
}

Similarly:

function getNothing(): null
{
    return null;
}

And:

function alwaysWorks(): true
{
    return true;
}

PHP 8.2 added null, false, and true as standalone types.

Why would you use this?

Mostly in advanced APIs and precise return types.

For example:

function authenticationFailed(): false
{
    return false;
}

It tells static analysis and other developers exactly what the function does.

You may also see combinations such as:

function findSomething(): User|null
{
    // ...
}

or:

function parse(): User|false
{
    // ...
}

Senior developer takeaway

You don’t need to use true/false/null everywhere.

But you should understand them when reading modern PHP code.


9. Typed Class Constants

Introduced in PHP 8.3

Before PHP 8.3:

class Config
{
    public const VERSION = '1.0';
}

There was no type declaration.

PHP 8.3 lets you write:

class Config
{
    public const string VERSION = '1.0';
    public const int MAX_USERS = 100;
}

Now PHP knows what type the constants must have.

For example:

class Config
{
    public const string VERSION = '1.0';
}

This is invalid:

class Config
{
    public const string VERSION = [];
}

Why is this useful?

It makes class contracts stronger.

Consider:

interface ApiConfig
{
    public const string VERSION = 'v1';
}

Implementing classes cannot suddenly change that constant into an array or integer.

This becomes especially useful in large codebases and inheritance hierarchies.


10. #[Override]

Introduced in PHP 8.3

This is a small feature but very useful.

Suppose you have:

class Animal
{
    public function speak(): string
    {
        return '...';
    }
}

And:

class Dog extends Animal
{
    public function speak(): string
    {
        return 'Woof';
    }
}

The second speak() is intended to override the parent method.

You can now write:

class Dog extends Animal
{
    #[Override]
    public function speak(): string
    {
        return 'Woof';
    }
}

PHP checks that the parent class or implemented interface actually has a matching method. If it doesn’t, PHP reports an error.

Why is this surprisingly useful?

Imagine this:

class UserService
{
    protected function validateUser(): void
    {
    }
}

You create:

class AdminService extends UserService
{
    protected function validateUsr(): void
    {
    }
}

Oops.

You typed:

validateUsr

instead of:

validateUser

Without #[Override], you might accidentally create a completely new method.

With:

#[Override]
protected function validateUsr(): void
{
}

PHP tells you:

There is no parent method with this name.

This is exactly the kind of feature that helps prevent subtle bugs during refactoring.

PHP 8.5 update

In PHP 8.5, #[Override] can also be used on properties.


11. Property Hooks

Introduced in PHP 8.4

This is one of the more advanced modern PHP features.

Before property hooks, developers often used getters and setters:

class User
{
    private string $name;

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $this->name = strtolower($name);
    }
}

Then:

$user->setName('JOHN');

echo $user->getName();

PHP 8.4 introduced property hooks, allowing behavior directly on the property.

For example:

class User
{
    public string $name {
        set(string $value) {
            $this->name = strtolower($value);
        }
    }
}

Now:

$user->name = 'JOHN';

echo $user->name;

The setter logic runs automatically.

The result is:

john

Getter example

class Product
{
    public function __construct(
        private float $price
    ) {
    }

    public string $formattedPrice {
        get => '$' . number_format($this->price, 2);
    }
}

Now:

$product->formattedPrice;

might return:

$19.99

There isn’t necessarily a real stored $formattedPrice value.

It is a computed property.

Another example

class User
{
    public string $email {
        set(string $value) {
            $this->email = strtolower(trim($value));
        }
    }
}

Then:

$user->email = '  JOHN@EXAMPLE.COM  ';

automatically becomes:

john@example.com

Is this just replacing __get() and __set()?

Not exactly.

Property hooks are attached to declared properties and give normal property syntax with custom get/set behavior.

Magic methods such as __get() and __set() are used for inaccessible/undefined properties and have different semantics.

So don’t think:

“Property hooks are simply a new name for __get() and __set().”

They solve related problems in a much more explicit and type-aware way.

Also important: property hooks are not compatible with readonly properties. If you need controlled read/write access, asymmetric visibility may be a better fit.

Senior developer takeaway: understand property hooks well, but don’t use them merely because they’re new. Use them when a property genuinely has behavior such as normalization, validation, or calculated values.


12. Asymmetric Property Visibility

Introduced in PHP 8.4

This feature solves a very common problem:

“Everyone should be able to read this property, but only this class should be able to change it.”

Before PHP 8.4, you might write:

class User
{
    private string $name;

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $this->name = $name;
    }
}

PHP 8.4 lets you express the intention directly:

class User
{
    public private(set) string $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }
}

Now:

echo $user->name;

works.

But:

$user->name = 'John';

from outside the class does not work.

Only the class itself can set it.

PHP calls this asymmetric visibility because reading and writing have different visibility rules.

Very useful example

class BankAccount
{
    public private(set) float $balance;

    public function __construct(float $balance)
    {
        $this->balance = $balance;
    }

    public function deposit(float $amount): void
    {
        $this->balance += $amount;
    }
}

Outside:

echo $account->balance;

Allowed.

But:

$account->balance = 999999;

Not allowed.

Inside:

public function deposit(float $amount): void
{
    $this->balance += $amount;
}

Allowed.

readonly vs asymmetric visibility

This distinction is important.

Readonly

public readonly string $name;

Means:

Set once. Never change again.

Asymmetric visibility

public private(set) string $name;

Means:

Everyone can read it, but only the class can change it.

So:

readonly
    ↓
write once

private(set)
    ↓
write only from allowed scope

Asymmetric visibility is therefore useful when an object has controlled mutability.


13. Stringable Objects

Introduced in PHP 8.0

Suppose you have:

class User
{
    public function __toString(): string
    {
        return 'John';
    }
}

You can do:

$user = new User();

echo $user;

PHP automatically treats the object as something that can be converted to a string.

PHP 8 introduced the Stringable interface.

The interesting part is:

If a class has __toString(), PHP automatically considers it Stringable.

You don’t have to explicitly write:

implements Stringable

although explicitly declaring it can make the intention clearer.

This is especially useful with union types:

function printValue(string|Stringable $value): void
{
    echo $value;
}

Now this works:

printValue('hello');

and:

printValue(new User());

Senior developer takeaway

Know what Stringable means.

It is particularly useful when designing APIs that accept either:

string

or:

object that can become a string

14. throw as an Expression

Introduced in PHP 8.0

Before PHP 8, throw was treated as a statement.

Modern PHP allows throw where an expression is expected.

This makes code like this possible:

$name = $user->name
    ?? throw new RuntimeException('Name is required');

Think of it as:

If $user->name exists:
    use it

Otherwise:
    throw an exception

Another example:

$id = $request->get('id')
    ?? throw new InvalidArgumentException('ID is required');

You can also use it with short arrow functions:

$findUser = fn (int $id) =>
    $id > 0
        ? findUser($id)
        : throw new InvalidArgumentException('Invalid ID');

PHP 8 changed throw from a statement to an expression, allowing it in places where an expression is required.

Don’t overuse it

This:

$name = $user->name
    ?? throw new RuntimeException('Name missing');

is readable.

But don’t turn every piece of code into clever one-liners.

Senior development is not about using the maximum number of language features.

It is about choosing the clearest solution.


15. #[SensitiveParameter]

Introduced in PHP 8.2

This is a very practical security feature.

Imagine:

function login(string $username, string $password)
{
    throw new Exception('Login failed');
}

When PHP creates a stack trace, sensitive function arguments can potentially appear in it.

You don’t want a password appearing in logs.

You can mark it:

function login(
    string $username,
    #[SensitiveParameter] string $password
) {
    throw new Exception('Login failed');
}

PHP will redact the sensitive parameter from the stack trace.

Where should you use it?

Think about:

password
API key
access token
secret key
private credential
credit-card-related sensitive data

For example:

function connectToApi(
    string $url,
    #[SensitiveParameter] string $apiKey
) {
    // ...
}

This is a small feature, but it demonstrates something important:

Senior developers think about security even in debugging and error handling.


16. json_validate()

Introduced in PHP 8.3

Before PHP 8.3, developers sometimes did something like:

json_decode($json);

if (json_last_error() !== JSON_ERROR_NONE) {
    // Invalid JSON
}

PHP 8.3 introduced:

json_validate($json);

Example:

$json = '{"name":"John"}';

if (json_validate($json)) {
    echo 'Valid JSON';
}

Returns:

true

Invalid JSON:

$json = '{"name":}';

if (!json_validate($json)) {
    echo 'Invalid JSON';
}

It is useful when you only need to know:

“Is this valid JSON?”

and don’t actually need the decoded data.

The important performance detail is that json_validate() avoids building the decoded array/object structure, so it can use less memory when validation is all you need. But don’t do this unnecessarily:

json_validate($json);
json_decode($json);

because the JSON gets parsed twice.

Senior developer takeaway: use it when validation is the only goal. If you immediately need the decoded value, just decode it.


17. never Return Type

Introduced in PHP 8.1

This one sounds strange at first.

Normally:

function getName(): string
{
    return 'John';
}

The function returns a string.

With:

function stopApplication(): never
{
    exit;
}

the function never returns.

It either:

  • throws an exception
  • calls exit()
  • calls die()
  • otherwise terminates execution

PHP introduced never specifically to describe functions that do not return.

Example

function redirect(string $url): never
{
    header("Location: $url");
    exit;
}

Then:

function checkAuthentication(): never
{
    redirect('/login');
}

Why is this useful?

It gives static-analysis tools more information.

Consider:

function fail(string $message): never
{
    throw new RuntimeException($message);
}

Then:

function getUserName(?User $user): string
{
    if ($user === null) {
        fail('User not found');
    }

    return $user->name;
}

A static analyzer can understand:

After fail(), execution doesn’t continue.

That’s very useful in large applications.


18. #[Deprecated]

Introduced in PHP 8.4

When you have an old method that you don’t want people to use anymore, you can mark it as deprecated.

Example:

#[\Deprecated(
    message: 'Use getUserById() instead',
    since: '2.0'
)]
function getUser($id)
{
    // old implementation
}

Calling it produces a deprecation notice.

Why is this useful?

Imagine you have a large application.

You have:

getUser()

but you’ve created:

getUserById()

You don’t want to immediately delete the old function because hundreds of places may still use it.

You can mark it:

#[\Deprecated(
    message: 'Use getUserById() instead',
    since: '2.0'
)]

Now developers get a warning when they use it.

This makes gradual migrations easier.

Senior developer use case

This is particularly useful when maintaining:

function redirect(string $url): never
{
    header("Location: $url");
    exit;
}

Then:

function checkAuthentication(): never
{
    redirect('/login');
}

Why is this useful?

It gives static-analysis tools more information.

function fail(string $message): never
{
    throw new RuntimeException($message);
}
  • large applications
  • shared libraries
  • packages
  • public APIs
  • framework components

It’s basically PHP saying:

“This still works, but please stop using it.”

As of PHP 8.5, #[Deprecated] can also target traits and compile-time constants outside classes.


19. Fibers

Introduced in PHP 8.1

This is probably the most advanced topic in your list.

Don’t worry if it doesn’t immediately make sense.

The simple explanation is:

A Fiber lets PHP pause a running function and continue it later.

Imagine:

Function starts
     ↓
Do some work
     ↓
PAUSE
     ↓
Other work can happen
     ↓
RESUME
     ↓
Continue where we stopped

Example:

$fiber = new Fiber(function (): void {

    echo "Start\n";

    $value = Fiber::suspend('paused');

    echo "Received: $value\n";
});

$result = $fiber->start();

echo "Fiber returned: $result\n";

$fiber->resume('Hello');

Conceptually, the output is:

Start
Fiber returned: paused
Received: Hello

The important thing is that:

Fiber::suspend();

pauses the Fiber.

Later:

$fiber->resume();

continues it.

PHP describes Fibers as full-stack interruptible functions. Unlike generators, a Fiber has its own call stack and can be suspended from deep inside nested function calls.

Why were Fibers added?

They are useful for building things such as:

  • asynchronous systems
  • event loops
  • non-blocking I/O abstractions
  • concurrency libraries
  • async frameworks

For example, libraries such as async/event-loop systems can use Fibers so application code can look more synchronous while the underlying system handles concurrency.

Are Fibers mandatory for every senior PHP developer?

You should understand the concept.

But you probably won’t write raw Fibers every day in a normal Laravel/Symfony CRUD application.

If you work on:

Laravel application
Symfony API
WordPress plugin
normal SaaS backend
REST API

you are much more likely to use:

Enums
Readonly
Attributes
Dependency Injection
Union Types
Property Hooks
Asymmetric Visibility

than manually creating Fibers.

But if you work on:

high-performance systems
async PHP
event loops
network servers
concurrent applications

Fibers become much more important.


Putting Everything Together

The really interesting part is that these features work very well together.

Consider a modern API DTO.

Instead of old-style PHP:

class User
{
    private int $id;
    private string $name;
    private string $email;

    public function __construct(
        int $id,
        string $name,
        string $email
    ) {
        $this->id = $id;
        $this->name = $name;
        $this->email = $email;
    }

    public function getName(): string
    {
        return $this->name;
    }
}

Modern PHP could look like:

readonly class User
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public UserStatus $status,
    ) {
    }
}

And:

enum UserStatus: string
{
    case Active = 'active';
    case Blocked = 'blocked';
}

Now we have:

  • readonly class → object cannot be modified after initialization
  • constructor property promotion → less boilerplate
  • typed properties → clear contracts
  • enum → valid statuses are enforced
  • modern PHP syntax → less code, but more meaning

That’s the direction modern PHP has taken.


A Realistic Senior-Level Example

Don’t worry if it doesn’t immediately make sense.

A Fiber lets PHP pause a running function and continue it later.

Function starts
     ↓
Do some work
     ↓
PAUSE
     ↓
Other work can happen
     ↓
RESUME
     ↓
Continue where we stopped

Example:

$fiber = new Fiber(function (): void {

    echo "Start\n";

    $value = Fiber::suspend('paused');

    echo "Received: $value\n";
});

$result = $fiber->start();

echo "Fiber returned: $result\n";

$fiber->resume('Hello');

Conceptually, the output is:

Start
Fiber returned: paused
Received: Hello

The important thing is that:

Fiber::suspend();

pauses the Fiber.

Later:

$fiber->resume();

continues it.

PHP describes Fibers as full-stack interruptible functions. Unlike generators, a Fiber has its own call stack and can be suspended from deep inside nested function calls.

Why were Fibers added?

They are useful for building things such as:

  • asynchronous systems
  • event loops
  • non-blocking I/O abstractions
  • concurrency libraries
  • async frameworks

For example, libraries such as async/event-loop systems can use Fibers so application code can look more synchronous while the underlying system handles concurrency.

Are Fibers mandatory for every senior PHP developer?

If you work on:

Let’s combine several features:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';
}

Then:

readonly class Order
{
    public function __construct(
        public int $id,
        public float $amount,
        public OrderStatus $status,
    ) {
    }
}

Service:

class OrderService
{
    public function findOrder(int|string $id): Order
    {
        // ...
    }
}

And:

class PaymentService
{
    public function __construct(
        private OrderService $orders
    ) {
    }

    public function pay(int|string $orderId): void
    {
        $order = $this->orders->findOrder(
            id: $orderId
        );

        if ($order->status !== OrderStatus::Pending) {
            throw new RuntimeException(
                'Order cannot be paid.'
            );
        }

        // ...
    }
}

Look at how much information is visible directly in the code.

Order
 ├── id       → int
 ├── amount   → float
 └── status   → OrderStatus

findOrder()
 └── accepts int OR string

PaymentService
 └── receives OrderService through constructor

OrderStatus
 └── only Pending / Paid / Cancelled

This is what modern PHP’s type system is trying to give you:

Make invalid states difficult to represent.


What Should a Senior PHP Developer Actually Remember?

You don’t need to memorize every syntax detail.

Instead, remember the problem each feature solves.

FeatureRemember it as
Named arguments“I can call parameters by name.”
Union types“This value can be A OR B.”
Attributes“Metadata directly attached to PHP code.”
Constructor promotion“Declare + initialize properties in the constructor.”
Enums“A fixed set of valid values.”
Readonly property“Set once, then never replace.”
Readonly class“The whole object is readonly.”
true / false / null types“Extremely precise return/value types.”
Typed constants“Constants can have a type.”
#[Override]“PHP checks that I really override something.”
Property hooks“Run logic when a property is read/written.”
Asymmetric visibility“Public read, restricted write.”
Stringable“An object that can behave like a string.”
throw expression“I can throw inside an expression.”
#[SensitiveParameter]“Hide secrets from stack traces.”
json_validate()“Check JSON without decoding it.”
never“This function does not come back.”
#[Deprecated]“This API still works, but don’t use it.”
Fibers“Pause and resume execution.”

My Recommended Learning Priority

If your goal is a Senior PHP Developer interview, I would study them in this order:

Level 1 — Absolutely know these

1. Union types

int|string

2. Named arguments

sendEmail(
    to: $email,
    subject: $subject
);

3. Constructor property promotion

public function __construct(
    private UserRepository $repository
) {}

4. Attributes

#[Route('/users')]

5. Enums

enum Status: string
{
    case Active = 'active';
}

6. Readonly

public readonly string $id;

and:

readonly class User {}

These are fundamental modern PHP concepts. PHP 8.0 introduced named arguments, union types, attributes, and constructor property promotion; PHP 8.1 added enums and readonly properties; PHP 8.2 added readonly classes.


Level 2 — Very Important for Modern PHP

Learn these well:

#[Override]
public private(set) string $name;
public string $name {
    set(string $value) {
        $this->name = trim($value);
    }
}

These features are particularly relevant if your target codebase is running PHP 8.4+, because property hooks and asymmetric visibility were introduced in PHP 8.4.


Level 3 — Know and Recognize

Understand these:

#[SensitiveParameter]
json_validate($json);
function fail(): never
#[Deprecated]
public const string VERSION = '1.0';

These aren’t necessarily things you’ll use every day, but a senior developer should be comfortable reading and explaining them.

Typed class constants and json_validate() arrived in PHP 8.3, while #[Deprecated] arrived in PHP 8.4.


Level 4 — Understand the Concept

Finally:

Fiber

You should understand:

What is a Fiber?
Why would an async library use it?
How is it different from a Generator?
When would I need it?

You don’t necessarily need to memorize the entire Fiber API unless you’re working on asynchronous/concurrent PHP systems.

Fibers have been available since PHP 8.1.


The Bigger Senior Developer Lesson

The most important thing here isn’t the syntax.

A senior developer should look at these features and understand the design philosophy behind them.

Modern PHP is increasingly encouraging developers to make their intentions explicit.

Instead of:

function process($data)

you can write:

function process(Order|Invoice $data): void

Instead of:

class User
{
    private string $name;
}

with lots of getter/setter boilerplate, you can use:

readonly class User
{
    public function __construct(
        public string $name
    ) {}
}

Instead of:

$status = 'active';

you can have:

$status = UserStatus::Active;

Instead of relying on comments to say:

"This method overrides a parent method"

you can tell PHP:

#[Override]

Instead of:

"Everybody can read this, but please don't modify it."

you can express the rule:

public private(set) string $name;

That is the real evolution of modern PHP:

Put more of your application’s rules into the language and type system, instead of leaving them as comments, conventions, or developer assumptions.

And that is exactly the mindset I would expect from a senior PHP developer.

Final Cheat Sheet

If you remember nothing else, remember this:

PHP 8.0
├── Named Arguments
├── Union Types
├── Attributes
└── Constructor Property Promotion

PHP 8.1
├── Enums
├── Readonly Properties
├── never
└── Fibers

PHP 8.2
├── Readonly Classes
└── true / false / null standalone types

PHP 8.3
├── Typed Class Constants
├── #[Override]
└── json_validate()

PHP 8.4
├── Property Hooks
├── Asymmetric Visibility
└── #[Deprecated]

PHP 8.5
└── More improvements to existing features
   (including #[Override] support for properties)

The version progression is confirmed by the official PHP release documentation.