PHP 8.4 New Features with Real-World Code Examples

March 29, 2026  ·  PHP  ·  12 min read

PHP 8.4 new features :Property hooks alone made me go back and rewrite three classes in a live project the same week. That rarely happens with a minor version bump.

This guide covers the features I actually use — with examples pulled from real PHP applications I’ve built. Not toy examples. Real code that solves real problems.


PHP 8.4 new features

1. Property Hooks

Okay, this is the big one. Every PHP developer reading this has written a class that looks like this:

<?php
class BlogPost {
    private string $slug;
    private string $title;
    public function getSlug(): string {
        return $this->slug;
    }
    public function setTitle(string $title): void {
        $this->title = $title;
        $this->slug  = strtolower(str_replace(' ', '-', $title));
    }
    public function getTitle(): string {
        return $this->title;
    }
}

Nothing wrong with it. It works. But we’ve all written this pattern hundreds of times. PHP 8.4 lets you do this instead:

<?php
class BlogPost {
    public string $slug {
        get => strtolower(str_replace(' ', '-', $this->title));
    }
    public function __construct(
        public string $title
    ) {}
}
$post = new BlogPost('PHP 8.4 Features');
echo $post->slug;  // php-8.4-features
echo $post->title; // PHP 8.4 Features

The slug property has a get hook — it runs whenever you read $post->slug. No stored value, no separate method, no extra code. The getter lives on the property itself.

You can also hook into writing a property. Here’s a common case — an email field that should always be lowercase:

<?php
class User {
    public string $email {
        set(string $value) {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException("Not a valid email: $value");
            }
            $this->email = strtolower($value);
        }
    }
    public function __construct(string $email) {
        $this->email = $email; // goes through the set hook
    }
}
$user = new User('VIJAY@TECHNOLILA.COM');
echo $user->email; // vijay@technolila.com
$user->email = 'ADMIN@EXAMPLE.COM';
echo $user->email; // admin@example.com
$user->email = 'not-an-email'; // throws InvalidArgumentException

You can combine both. Here’s a price field that validates on write and formats on read:

<?php
class Product {
    public float $price {
        set(float $value) {
            if ($value < 0) {
                throw new RangeException('Price cannot be negative.');
            }
            $this->price = round($value, 2);
        }
        get => number_format($this->price, 2);
    }
}
$p = new Product();
$p->price = 999.9999;
echo $p->price; // 1,000.00
$p->price = -50; // throws RangeException

One thing to know: hooked properties cannot be passed by reference and cannot be unset(). That’s a deliberate design choice. In practice it almost never matters.


2. Asymmetric Visibility

How many times have you written a private property with a public getter just so outside code could read it but not change it? PHP 8.4 has a solution:

<?php
class Order {
    // Anyone can READ this. Only this class can WRITE it.
    public private(set) string $status = 'pending';
    public private(set) int    $id;
    public function __construct(int $id) {
        $this->id = $id;
    }
    public function confirm(): void {
        $this->status = 'confirmed'; // fine — inside the class
    }
    public function cancel(): void {
        $this->status = 'cancelled';
    }
}
$order = new Order(42);
echo $order->status; // pending  ✅ reading is allowed
echo $order->id;     // 42       ✅
$order->confirm();
echo $order->status; // confirmed ✅
$order->status = 'refunded'; // Fatal error — writing from outside is blocked ❌

The syntax is public private(set). The first keyword controls reading, the keyword in parentheses controls writing. You can also use public protected(set) if you want child classes to be able to update the property:

<?php
class BaseModel {
    public protected(set) int $id;
    public protected(set) DateTime $updatedAt;
    public function __construct(int $id) {
        $this->id        = $id;
        $this->updatedAt = new DateTime();
    }
}
class Article extends BaseModel {
    public function touch(): void {
        $this->updatedAt = new DateTime(); // allowed — protected(set) ✅
    }
}
$article = new Article(1);
$article->touch();
echo $article->updatedAt->format('Y-m-d H:i:s'); // current time

Works great with constructor promotion too — no need to write out the full property declaration separately:

<?php
class Customer {
    public function __construct(
        public private(set) int    $id,
        public private(set) string $name,
        public private(set) string $email
    ) {}
}
$c = new Customer(1, 'Vijay Kumar', 'vijay@technolila.com');
echo $c->name; // Vijay Kumar  ✅
$c->name = 'Someone Else'; // Fatal error ❌

3. new Without Extra Parentheses

Small change. Satisfying fix. Before 8.4, calling a method on a freshly instantiated object needed wrapping parentheses:

<?php
// PHP 8.3 — had to wrap in parentheses
$name = (new ReflectionClass($obj))->getShortName();
// PHP 8.4 — no wrapping needed
$name = new ReflectionClass($obj)->getShortName();
// Works for method chaining too
$result = new QueryBuilder('products')
    ->where('active', 1)
    ->orderBy('price', 'ASC')
    ->limit(20)
    ->get();
// Works for reading properties too
$version = new AppConfig()->version;
// And constants
$default = new HttpClient()->DEFAULT_TIMEOUT;

You’ll notice this most in framework code and test setup where you’re chaining a lot of builder calls. Not life-changing, but once you get used to it, you’ll miss it in older versions.


4. Four New Array Functions

These four have replaced a lot of foreach + break patterns in my day-to-day code. If you’ve used JavaScript’s Array.find() or Array.some(), you already know what these do.

array_find()

Returns the first element where the callback returns true. Returns null if nothing matches.

<?php
$users = [
    ['id' => 1, 'name' => 'Vijay',  'role' => 'admin'],
    ['id' => 2, 'name' => 'Rahul',  'role' => 'editor'],
    ['id' => 3, 'name' => 'Priya',  'role' => 'admin'],
];
// Old way
$admin = null;
foreach ($users as $user) {
    if ($user['role'] === 'admin') {
        $admin = $user;
        break;
    }
}
// PHP 8.4
$admin = array_find($users, fn($u) => $u['role'] === 'admin');
// ['id' => 1, 'name' => 'Vijay', 'role' => 'admin']
// Returns null if not found
$superAdmin = array_find($users, fn($u) => $u['role'] === 'superadmin');
var_dump($superAdmin); // NULL

array_find_key()

Same idea, but returns the key instead of the value. Useful when you need to update the original array by reference.

<?php
$inventory = [
    'laptop'    => 5,
    'mouse'     => 0,
    'keyboard'  => 12,
    'monitor'   => 0,
];
$firstOutOfStock = array_find_key($inventory, fn($qty) => $qty === 0);
echo $firstOutOfStock; // mouse
// Useful when you need the key to update the original array
$key = array_find_key($inventory, fn($qty) => $qty === 0);
if ($key !== null) {
    $inventory[$key] = 10; // restock it
}

array_any()

Returns true if at least one element matches. Short-circuits as soon as a match is found.

<?php
$orders = [
    ['id' => 101, 'paid' => true,  'amount' => 1200],
    ['id' => 102, 'paid' => false, 'amount' => 850],
    ['id' => 103, 'paid' => true,  'amount' => 400],
];
$hasUnpaid = array_any($orders, fn($o) => !$o['paid']);
var_dump($hasUnpaid); // bool(true)
// In a controller — check before sending invoice batch
if (array_any($orders, fn($o) => !$o['paid'])) {
    echo 'Some orders still need payment. Run reminders first.';
}

array_all()

Returns true only if every element matches. Stops as soon as one fails.

<?php
$cartItems = [
    ['product' => 'SSD',     'in_stock' => true,  'qty' => 1],
    ['product' => 'RAM',     'in_stock' => true,  'qty' => 2],
    ['product' => 'CPU Fan', 'in_stock' => false, 'qty' => 1],
];
$canCheckout = array_all($cartItems, fn($item) => $item['in_stock']);
if (!$canCheckout) {
    echo 'One or more items in your cart are out of stock.';
}
// Another real one — validating form field values before DB insert
$fields = ['name' => 'Vijay', 'email' => 'vijay@technolila.com', 'phone' => '9876543210'];
$allFilled = array_all($fields, fn($v) => trim($v) !== '');
if (!$allFilled) {
    echo 'Please fill in all required fields.';
}

5. Lazy Objects

If you’ve used Doctrine, Symfony, or Laravel’s service container, you’ve been using lazy objects for years — the frameworks were building them manually. PHP 8.4 makes this a native language feature.

The idea: create an object, but don’t run its constructor until the object is actually accessed. Useful when you have a service class that opens a database connection, reads config from disk, or makes an HTTP call on startup — and you’re not sure if that code path will even be reached.

<?php
class ReportGenerator {
    public function __construct(
        private string $connectionString
    ) {
        // Imagine this takes half a second — heavy DB connection setup
        echo "Connecting to DB...
";
    }
    public function generate(string $type): array {
        return ['report' => $type, 'rows' => 500];
    }
}
$reflector = new ReflectionClass(ReportGenerator::class);
// The object is created but the constructor hasn't run yet
$report = $reflector->newLazyGhost(function(ReportGenerator $obj) {
    $obj->__construct('host=localhost;dbname=reports');
});
echo "Object created
";
// Nothing printed from constructor yet
// Constructor runs NOW — first time we actually use the object
$data = $report->generate('monthly');
// "Connecting to DB..." prints here
print_r($data);

There’s also newLazyProxy() which wraps a separately constructed object rather than initializing the object in-place. Use newLazyGhost() when you want to lazily initialize the object itself. Use newLazyProxy() when the real object is created by a factory and you want to wrap it.


6. New HTML5 DOM API

The original DOMDocument has been in PHP since version 4. It’s based on libxml2 and it has always had quirks with real-world HTML — dropped attributes, mangled tags, incorrect encoding. If you’ve ever tried to parse a modern webpage with DOMDocument::loadHTML() you know the pain.

PHP 8.4 ships a new parser under the Dom namespace. It uses a proper HTML5 parsing algorithm and supports querySelector() natively.

<?php
$html = '<!DOCTYPE html>
<html>
  <body>
    <nav>
      <a href="/home" class="nav-link active">Home</a>
      <a href="/blog" class="nav-link">Blog</a>
      <a href="/contact" class="nav-link">Contact</a>
    </nav>
    <main>
      <article id="post-1" class="featured">
        <h1>PHP 8.4 is out</h1>
        <p class="meta">By Vijay · March 29, 2026</p>
      </article>
      <article id="post-2">
        <h1>Laravel 11 Guide</h1>
        <p class="meta">By Vijay · March 22, 2026</p>
      </article>
    </main>
  </body>
</html>';
$dom = DomHTMLDocument::createFromString($html, LIBXML_NOERROR);
// querySelector — just like JavaScript
$featured = $dom->querySelector('article.featured h1');
echo $featured->textContent; // PHP 8.4 is out
// querySelectorAll
$allMeta = $dom->querySelectorAll('p.meta');
foreach ($allMeta as $meta) {
    echo $meta->textContent . "
";
    // By Vijay · March 29, 2026
    // By Vijay · March 22, 2026
}
// Active nav link
$activeLink = $dom->querySelector('.nav-link.active');
echo $activeLink->getAttribute('href'); // /home
// Check classList
$firstArticle = $dom->querySelector('article');
var_dump($firstArticle->classList->contains('featured')); // bool(true)

You can also create from a file or a URL stream. The old DOMDocument still exists and hasn’t been deprecated — so existing code is fine. But for any new HTML parsing work, use DomHTMLDocument.


7. The #[Deprecated] Attribute

Before 8.4, @deprecated in a docblock was just a comment. IDEs would show a strikethrough, static analysis tools would flag it, but PHP itself didn’t care at runtime. You could call a deprecated function and nothing would happen unless you manually added a trigger_error() call inside it.

<?php
// PHP 8.4 — proper native deprecation
class DB {
    #[Deprecated(
        message: 'Use query() with named parameters instead.',
        since:   'Technolila\DB:2.0'
    )]
    public function rawQuery(string $sql): array {
        // old unsafe implementation
        return [];
    }
    public function query(string $sql, array $params = []): array {
        // new safe implementation
        return [];
    }
}
$db = new DB();
$db->rawQuery('SELECT * FROM users');
// PHP Deprecated: DB::rawQuery() is deprecated since TechnolilaDB:2.0,
//                use query() with named parameters instead.

The notice shows up in your PHP error log, your Xdebug output, your Sentry/Bugsnag reports — anywhere deprecation notices are tracked. If you maintain a package that others use, this is now the right way to signal “stop using this function.”

Works on functions, methods, class constants, and enum cases. Does not work on properties (that’s coming in a future version).


8. BCMath Gets an Object API

If you work on anything that handles money — invoices, taxes, order totals, currency conversion — you know to never use float for financial math. You’ve probably used bcadd(), bcmul(), and friends. They work, but reading deeply nested bcmath calls is painful.

<?php
// Old way — hard to read at a glance
$total = bcadd(
    bcmul($unitPrice, $quantity, 4),
    bcdiv(bcmul($unitPrice, $quantity, 4), '100', 4), // add 1% service charge
    2
);
// PHP 8.4 — BCMathNumber with actual operators
use BCMathNumber;
$unitPrice      = new Number('450.00');
$quantity       = new Number('3');
$serviceCharge  = new Number('0.01'); // 1%
$subtotal = $unitPrice * $quantity;          // 1350.00
$charge   = $subtotal * $serviceCharge;      // 13.50
$total    = $subtotal + $charge;             // 1363.50
echo $total; // 1363.50
// Comparison operators work too
$minOrderAmount = new Number('500.00');
if ($subtotal > $minOrderAmount) {
    echo 'Eligible for free shipping.';
}

The precision is still configurable — BCMathNumber respects the bcmath.scale ini setting. And there’s a new Number::round() method with PHP 8.4’s new rounding modes (PHP_ROUND_CEILING, PHP_ROUND_FLOOR, etc.).


9. PDO Driver Subclasses

PHP 8.4 introduces typed PDO subclasses: PdoMysql, PdoPgsql, PdoSqlite, PdoDblib.

The practical benefit: you can now type-hint for a specific database driver in function signatures. Before this, all PDO connections were the same type — your function couldn’t distinguish between a MySQL connection and a PostgreSQL one without hacks.

<?php
// PHP 8.4 — typed PDO subclasses
$mysql = new PdoMysql(
    'mysql:host=127.0.0.1;dbname=technolila;charset=utf8mb4',
    'db_user',
    'db_pass',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$mssql = new PdoDblib(
    'dblib:host=WIN-SERVERSQLEXPRESS;dbname=TechnolilaDB',
    'sa',
    'SqlPass123!'
);
// Now your functions can accept ONLY the driver they expect
function getMysqlUserCount(PdoMysql $pdo): int {
    return (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
function getMssqlUserCount(PdoDblib $pdo): int {
    return (int) $pdo->query('SELECT COUNT(*) FROM Users')->fetchColumn();
}
// Passing the wrong type gives a TypeError at call time — caught early
echo getMysqlUserCount($mysql);  // works ✅
echo getMysqlUserCount($mssql);  // TypeError ❌ — caught immediately

PdoMysql also adds a getWarningCount() method which returns MySQL warning count from the last statement — something you previously had to run a separate SHOW WARNINGS query for.


10. How to Upgrade Your Server to PHP 8.4

Before you touch your server, run a compatibility check on your codebase. This tool scans for known incompatibilities:

composer require --dev phpcompatibility/php-compatibility
./vendor/bin/phpcs --standard=PHPCompatibility --runtime-set testVersion 8.4 ./src

The most common thing it will flag is implicitly nullable parameters. This was technically wrong in older PHP too, but 8.4 is stricter about it:

<?php
// This causes a deprecation notice in PHP 8.4
function sendEmail(string $to, string $subject = null): void {}
// Fix it — be explicit about null being allowed
function sendEmail(string $to, ?string $subject = null): void {}

Once your code is clean, upgrade the server:

# Ubuntu/Debian — ondrej PPA
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.4 php8.4-fpm php8.4-mysql php8.4-mbstring php8.4-xml php8.4-curl php8.4-zip
# Check it installed
php8.4 -v
# Switch CLI default
sudo update-alternatives --set php /usr/bin/php8.4
# Apache — swap the module
sudo a2dismod php8.3
sudo a2enmod php8.4
sudo systemctl restart apache2
# Nginx + PHP-FPM — update your server block
# Change: fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
# To:     fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
sudo systemctl restart nginx php8.4-fpm

For shared hosting (cPanel/Plesk), look for the PHP version selector in your control panel. Most providers added 8.4 within weeks of release.

PHP 8.4 new features

Frequently Asked Questions

Does PHP 8.4 work with Laravel?

Laravel 11 and above officially support PHP 8.4. Laravel 10 runs on 8.4 with no major issues in practice, though it’s not officially listed. If you’re on Laravel 9 or below, test on a staging server before upgrading PHP.

Does PHP 8.4 work with CodeIgniter?

CodeIgniter 4.4+ supports PHP 8.4. CodeIgniter 3 is no longer officially maintained — it works on 8.4 for most things but you may hit deprecation warnings.

Is PHP 8.1 end of life?

PHP 8.1 reached end of life on December 31, 2025 — no more security patches. If you’re still on 8.1, upgrade now. PHP 8.4 is the version to be on: supported until end of 2028.

Will my WordPress plugins break on PHP 8.4?

WordPress itself officially supports PHP 8.4 as of WP 6.7. Most popular plugins have been updated. Check the PHP compatibility of your installed plugins at Tools → Site Health in your WordPress dashboard before upgrading. Any plugin that hasn’t been updated since 2022 might have the implicitly nullable parameter issue — but that’s a deprecation notice, not a fatal error.

How do I enable property hooks in an existing class?

Just upgrade to PHP 8.4 — that’s it. There’s no extension to install or flag to enable. Property hooks are part of the language syntax from PHP 8.4 onward. If you run the code on PHP 8.3 it will throw a parse error, so make sure your deployment environment is on 8.4 before shipping code that uses hooks.


My Take

PHP 8.4 is a good release. Not PHP 8.0-level revolutionary, but solid. Property hooks and asymmetric visibility fix real annoyances that PHP developers have worked around forever. The four new array functions are things I reach for in almost every project. The HTML5 DOM parser was long overdue.

If you’re on PHP 8.3, upgrade during your next maintenance window. If you’re on anything below 8.2, this is overdue — PHP 8.1 is already end of life.

The one thing I want in PHP 8.5 or 9: #[Deprecated] on properties. That’s the last piece missing.

Questions? Drop them in the comments. I read and reply to every one. And if there’s a feature I missed that you’re already using, let me know — I’ll update this post.

About the author

Vijay Kumar Anand — Full-stack developer with 10+ years building web applications. Works daily with ASP.NET Core, Next.js, PHP, TypeScript, MSSQL, and MySQL. Runs Technolila to share the things he actually learns on the job, not textbook theory.

Written By

vijay1983

Leave a Reply