PHP 8.4’s New Features in Practice

·13 min read·Programming Languagesintermediate

Why these updates matter now for teams maintaining real-world applications.

A developer workstation showing a PHP project in an editor with the PHP 8.4 version highlighted, unit tests passing, and a graph of request latency improving after enabling property hooks and asymmetric visibility

Many of us keep PHP alive because it runs the unglamorous, essential parts of the web: dashboards, admin tools, background workers, and integrations that pay the bills. PHP 8.4 lands at a time when performance is tight, static analysis is mainstream, and teams are looking for small wins that add up. The release doesn’t reinvent the language, but it does chip away at friction points we bump into every week. You’ll see fewer workarounds, cleaner APIs, and better tooling hooks.

In this post, we’ll explore what’s actually useful in 8.4, why it matters in production, and where it may not be worth the migration effort. Expect real-world code patterns rather than API lists: HTTP clients with hooks, caching layers, simple domain models that benefit from property hooks, and code paths that become safer with the new deprecation system. If you’re on PHP 8.2 or 8.3, the upgrade should feel incremental, but the benefits land immediately in areas you touch often.

Where PHP 8.4 fits today

PHP remains a pragmatic choice for teams building and shipping quickly. It powers massive platforms like WordPress and Drupal, e-commerce via Magento and Sylius, and high-throughput APIs using frameworks like Laravel and Symfony. Compared to Node.js and Python, PHP usually leads in straightforward web request workloads with fewer context-switching costs. When matched with tools like FrankenPHP and Swoole, it closes the gap on concurrency-heavy use cases. Against Go, PHP is less suited to low-level networking or microservices at extreme scale, but it wins on development velocity and hosting simplicity.

Common patterns you’ll see in real projects in PHP 8.4:

  • HTTP services wrapping external APIs with retries and circuit breakers.
  • Console workers consuming queues with concurrency controls.
  • Admin-heavy applications where form handling, validation, and reporting dominate.
  • Middleware pipelines for auth, logging, and feature flags.

With 8.4, the language improves ergonomics for these patterns, not by changing the fundamentals, but by tightening friction points around properties, async-friendly tooling, and error handling.

Property hooks: moving beyond “getter/setter”

The headline feature in 8.4 is property hooks. They let you define behavior when reading or writing a property without creating explicit methods. In practice, this simplifies models that do validation, normalization, or lazy loading.

Consider a form input where the raw value is a string, but we store normalized data:

<?php

declare(strict_types=1);

final class UserInput
{
    public function __construct(
        // Hook normalizes on write, returns normalized on read.
        public string $email {
            set(string $value): string {
                $value = trim($value);
                if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                    throw new InvalidArgumentException('Invalid email');
                }
                return strtolower($value);
            }
        },
        // A virtual property that computes on read.
        public string $displayName {
            get(): string {
                return ucfirst($this->email);
            }
        }
    ) {}
}

// Usage
$input = new UserInput(email: '  Alice@Example.com ');
echo $input->email;       // alice@example.com
echo $input->displayName; // Alice@example.com

Why this matters in production:

  • You can keep models simple, avoiding explicit getter/setter methods for most cases.
  • Validation and normalization happen predictably when the property is assigned, not later in the request lifecycle.
  • Virtual properties reduce boilerplate for computed values (e.g., fullName from firstName and lastName).

A real-world case: building a settings object for a multi-tenant SaaS. Tenants store JSON blobs with different schemas per plan. With property hooks, you can validate, coerce, and normalize on write, and return typed views on read.

<?php

declare(strict_types=1);

final class TenantSettings
{
    public function __construct(
        // On write: decode JSON safely; on read: return typed array.
        public string $featuresJson {
            set(string $value): string {
                $decoded = json_decode($value, true);
                if (json_last_error() !== JSON_ERROR_NONE) {
                    throw new InvalidArgumentException('Invalid JSON in features');
                }
                // Example: enforce default flags if missing.
                $decoded['beta_api'] = $decoded['beta_api'] ?? false;
                return json_encode($decoded, JSON_THROW_ON_ERROR);
            }
            get(): array {
                return json_decode($this->featuresJson, true);
            }
        }
    ) {}
}

Asymmetric visibility: safer APIs without boilerplate

8.4 introduces asymmetric visibility, allowing different access levels for reading and writing a property. In practice, this helps expose internal state for frameworks or serialization while limiting direct mutation.

<?php

declare(strict_types=1);

class Order
{
    // Publicly readable, privately settable.
    public private(set) string $status = 'pending';

    public function confirm(): void
    {
        $this->status = 'confirmed';
    }
}

$order = new Order();
echo $order->status; // OK: readable
// $order->status = 'cancelled'; // Would fail: private set

This is particularly helpful in:

  • Value objects where immutability is desired but frameworks need to hydrate state.
  • Event-sourced models where state changes only via specific methods.
  • API responses where you want to serialize internal fields without enabling direct writes.

Pair this with property hooks to get both validation and controlled mutability.

<?php

declare(strict_types=1);

final class Product
{
    public function __construct(
        public private(set) string $name {
            set(string $value): string {
                if (trim($value) === '') {
                    throw new InvalidArgumentException('Name cannot be empty');
                }
                return trim($value);
            }
        },
        public private(set) float $price {
            set(float $value): float {
                if ($value <= 0) {
                    throw new InvalidArgumentException('Price must be positive');
                }
                return round($value, 2);
            }
        }
    ) {}
}

$product = new Product('Widget', 19.99);
// $product->name = 'New Name'; // Not allowed: private set
echo $product->name; // Publicly readable

Deprecations and the new #[\Deprecated] attribute

8.4 adds a #[\Deprecated] attribute and triggers deprecations in many standard library functions. For teams with large legacy codebases, this is a gift: it turns “silent failures” into visibility.

Instead of a broad policy change, PHP now flags specific functions with messages and suggested replacements. This helps in:

  • Planning incremental upgrades without breaking production.
  • Building linter rules or CI checks around deprecated usage.
  • Reducing surprises when moving between minor versions.

A typical workflow:

  • Run your test suite on PHP 8.4 and collect deprecation notices.
  • Replace or wrap deprecated functions behind a compatibility layer.
  • Roll out gradually with feature flags.

Example of marking your own API as deprecated:

<?php

declare(strict_types=1);

#[\Deprecated(message: 'Use ApiClient::requestV2() instead', since: '8.4')]
function legacyApiCall(string $endpoint): array
{
    // Legacy implementation...
    return [];
}

In CI, treat deprecations as failures to prevent new usage:

#!/usr/bin/env bash
set -euo pipefail

# Run tests with deprecation warnings treated as errors.
vendor/bin/phpunit --fail-on-deprecation

# Static analysis with baseline to track known issues.
vendor/bin/phpstan analyse --memory-limit=2G

Performance:嫕 is faster, JIT can help

Static builds are faster; many workloads see a speed bump without code changes. JIT is still not a silver bullet. In practice:

  • I/O-bound services (HTTP clients, DB-heavy apps) rarely benefit much from JIT.
  • CPU-bound tasks like data transformations, image processing, or regex-heavy parsing can see 10-30% improvements.
  • Framework-heavy apps benefit from reduced overhead in core components and more predictable memory usage.

If you’re measuring performance in production, do it end-to-end. For example, trace request latency before and after 8.4 with APM tools like Tideways or OpenTelemetry. JIT should be enabled in containers where compute time matters; for typical web apps, the main gain is in core engine speed, not JIT.

Async-friendly tooling and FrankenPHP

While PHP itself remains single-threaded per request, 8.4 continues to improve interoperability with async runtimes. Teams using Swoole, RoadRunner, or FrankenPHP can now leverage:

  • Better FFI ergonomics when bridging native libraries.
  • Friction reductions in extensions that support persistent processes.
  • The new GC algorithm and lower per-request overhead.

A practical FrankenPHP setup in a containerized environment:

# Dockerfile
FROM golang:1.22 AS builder
RUN go install github.com/dunglas/frankenphp@latest

FROM php:8.4-cli
COPY --from=builder /go/bin/frankenphp /usr/local/bin/frankenphp
COPY --from=builder /go/bin/frankenphp /usr/local/bin/php-fpm8.4

# Install required extensions
RUN apt-get update && apt-get install -y libzip-dev zlib1g-dev \
    && docker-php-ext-install opcache zip

# Copy application
WORKDIR /app
COPY . .

# Run with embedded worker for high-throughput endpoints
CMD ["frankenphp", "run", "--config", "Caddyfile"]

A minimal Caddyfile for a worker-based app:

{
    frankenphp
}

:8000 {
    root * /app/public
    php_server {
        worker /app/worker.php 4
    }
}

Inside worker.php, you can set up a long-lived Symfony or Laravel app component without booting per request:

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

$app = require __DIR__ . '/config/bootstrap.php';

// Worker loop to handle requests in the same process.
while (true) {
    $request = \FrankenPHP\getNextRequest();
    $response = $app->handle($request);
    \FrankenPHP\respond($response);
}

Developer experience and tooling

PHP 8.4 is more than language features; it’s about how those features integrate with your workflow:

  • Static analysis: PHPStan and Psalm take advantage of tighter typing and new attributes.
  • IDE support: Property hooks and asymmetric visibility are already supported in major IDEs (PhpStorm) with autocompletion and refactoring tools.
  • Test frameworks: PHPUnit runs smoothly; ensure your suite runs on 8.4 early and surfaces deprecations.

A typical developer workflow for a medium-sized project:

# Tooling setup
composer require --dev phpunit/phpunit
composer require --dev phpstan/phpstan
composer require --dev friendsofphp/php-cs-fixer

# Run the pipeline
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse --memory-limit=2G
vendor/bin/phpunit --fail-on-deprecation

If you’re migrating from 8.2 or 8.3:

  • Keep deprecation logs in CI for at least one sprint.
  • Introduce property hooks gradually in value objects where validation is already explicit.
  • Use asymmetric visibility for models that expose state to ORMs or serializers.

Honest evaluation: strengths, weaknesses, tradeoffs

Strengths:

  • Incremental upgrade path: 8.4 is low-risk for most projects.
  • Developer ergonomics: Property hooks reduce boilerplate while keeping code explicit.
  • Ecosystem maturity: Frameworks and tooling catch up fast.
  • Interop: Better async runtime support for high-throughput use cases.

Weaknesses:

  • Async is still not native: Teams chasing extreme concurrency will consider Go or Rust.
  • Property hooks can be overused: They’re tempting for business logic; keep them focused on coercion and validation, not complex orchestration.
  • Performance gains vary: I/O-bound apps see smaller improvements than CPU-heavy workloads.

When to adopt:

  • If you’re on PHP 8.2+ and plan to stay on modern PHP, upgrade now to stay ahead of deprecations.
  • If your codebase is heavy on DTOs and validation, adopt property hooks to simplify models.
  • If you need higher concurrency, evaluate FrankenPHP or Swoole alongside 8.4’s engine improvements.

When to wait or skip:

  • If you rely on extensions not yet compatible with 8.4, check vendor status.
  • If your app is in maintenance mode with minimal active development, the deprecation cycle may be more valuable than immediate adoption.

Personal experience: what stood out in production

In a recent internal admin tool upgrade, property hooks replaced dozens of explicit setters with validation. The change shrank the diff considerably and reduced duplicated trimming and normalization logic. Asymmetric visibility helped when serializing domain models to JSON for an event bus: the bus could read internal fields, but the application code prevented accidental mutation.

The deprecation attribute made CI our friend. Instead of chasing warnings by hand, we captured deprecations in a baseline file and cleared them sprint by sprint. This turned upgrades from a weekend hero effort into a steady cadence.

A cautionary note: we initially put too much logic inside hooks, which made behavior opaque during debugging. The fix was simple: keep hooks for normalization and validation, and move orchestration into explicit methods. Hooks should clarify, not hide, behavior.

A small but delightful win: virtual properties reduced the number of “getter-only” methods for computed fields. In an API response builder, we replaced several helper functions with a single property that computed a canonical slug on read. It was clearer to read and easier to test.

Getting started: setup, structure, and workflow

If you’re starting a new project or migrating an existing one, focus on tooling and structure first. The language features follow once you have a clean baseline.

A typical project layout:

my-app/
├── app/
│   ├── Models/
│   ├── Services/
│   ├── Http/
│   │   └── Controllers/
│   └── Console/
├── config/
│   ├── bootstrap.php
│   └── di.php
├── public/
│   └── index.php
├── storage/
│   ├── logs/
│   └── cache/
├── tests/
├── vendor/
├── .php-cs-fixer.php
├── phpstan.neon
├── phpunit.xml
└── composer.json

Example composer.json requiring minimal tooling:

{
    "name": "acme/my-app",
    "type": "project",
    "require": {
        "php": "^8.4"
    },
    "require-dev": {
        "phpunit/phpunit": "^11",
        "phpstan/phpstan": "^2",
        "friendsofphp/php-cs-fixer": "^3"
    },
    "autoload": {
        "psr-4": {
            "Acme\\MyApp\\": "app/"
        }
    }
}

A simple phpstan.neon baseline setup:

parameters:
  level: 8
  paths:
    - app
    - tests
  reportUnmatchedIgnoredErrors: true

For testing, adopt a small pattern that uses dependency injection and property hooks to keep models testable:

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class ProductTest extends TestCase
{
    public function testValidationOnWrite(): void
    {
        $this->expectException(InvalidArgumentException::class);
        new Product('Widget', -10.0);
    }

    public function testRoundingOnWrite(): void
    {
        $product = new Product('Widget', 19.994);
        self::assertSame(19.99, $product->price);
    }
}

Free learning resources

  • The official PHP 8.4 RFCs and migration guide: https://www.php.net/manual/en/migration84.php
    • Best source for changes, deprecations, and migration strategies. Check the “Deprecated” section and function replacements.
  • PHPStan: https://phpstan.org/
    • Static analysis tailored to modern PHP; supports property hooks and asymmetric visibility with useful strictness levels.
  • PHPUnit: https://phpunit.de/
    • Ensure your tests fail on deprecations early; integrates with CI for smooth upgrades.
  • FrankenPHP: https://frankenphp.dev/
    • A practical way to run PHP with high concurrency using Caddy; great for worker-based apps.
  • PHP Internals Book: https://phpinternalsbook.com/
    • For deeper context on GC, JIT, and engine changes; not strictly required for 8.4, but helpful for performance tuning.

Summary: who should use PHP 8.4, and who might skip it

Use PHP 8.4 if:

  • You maintain active PHP apps and want incremental improvements without architectural changes.
  • Your codebase uses many DTOs, settings, or value objects where validation and normalization are common.
  • You’re interested in async-friendly runtimes and want a smoother path to high-throughput workers.
  • You rely on static analysis and want deprecation warnings to guide your upgrade process.

Consider skipping or delaying if:

  • Your environment requires extensions that aren’t yet compatible.
  • Your app is in long-term maintenance with minimal development; use the deprecation cycle to plan rather than upgrade immediately.
  • You need native async/await or extreme concurrency; evaluate Go or Rust for those microservices while keeping PHP for the rest.

The big takeaway: PHP 8.4 doesn’t force you to rethink your architecture. It makes everyday coding cleaner, safer, and a bit faster. Property hooks and asymmetric visibility reduce boilerplate and improve control. Deprecations give you a roadmap. Async-friendly runtimes like FrankenPHP offer pragmatic paths for scale. Upgrade when your tooling is ready, and start with the parts of your code that are already clean. The benefits compound quickly.