PHP 8.3 Typed Class Constants and json_validate: What You Need to Know

PHP 8.3, released in November 2023, is a focused release. It does not introduce a sweeping new feature like fibers or enums, but it adds several small improvements that remove friction from everyday PHP code. The two most immediately useful additions are typed class constants and the json_validate() function. This article covers both in depth, along with the other 8.3 additions worth knowing about.

Typed Class Constants

Before PHP 8.3, class constants had no declared type. The value could be anything, and there was no way to enforce that a subclass changed a constant to an incompatible type:

// PHP 8.2 — no type enforcement on constants
class Config {
    const VERSION = '1.0.0';  // could be overridden with an int in a subclass
    const TIMEOUT = 30;
}

PHP 8.3 adds type declarations for class, interface, enum, and trait constants:

class Config {
    const string VERSION = '1.0.0';
    const int    TIMEOUT = 30;
    const bool   DEBUG   = false;
    const float  RATIO   = 1.618;
    const array  DRIVERS = ['mysql', 'pgsql', 'sqlite'];
}

All PHP type declarations are supported: string, int, float, bool, array, object, nullable types (?string), union types (string|int), and never (with some restrictions). Intersection types are not supported on constants.

Type Inheritance Rules

When a child class overrides a constant, the type must be compatible with the parent's declared type. "Compatible" follows Liskov Substitution Principle: a child may narrow the type but not widen it:

class Base {
    const string|int IDENTIFIER = 'base';
}

class Child extends Base {
    // Valid: narrows string|int to string
    const string IDENTIFIER = 'child';
}

class Invalid extends Base {
    // TypeError: cannot widen string|int to string|int|float
    const string|int|float IDENTIFIER = 'invalid';
}

Interface constants with types follow the same rules — implementing classes must maintain or narrow the type:

interface HasVersion {
    const string VERSION = '0.0.0';
}

class MyService implements HasVersion {
    const string VERSION = '2.1.0'; // valid — same type
}

Are Constants Readonly?

A question that comes up: can constants be readonly? The answer is no — and it makes sense, because constants are already immutable by definition. You cannot write const readonly string VERSION = '1.0'. Constants are always "readonly" in the sense that they cannot be changed at runtime. The readonly keyword only applies to class properties.

Practical Use: Drupal Service with Typed Constants

<?php

namespace Drupal\mymodule\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Http\ClientFactory;

class ApiClient {

    const string BASE_URL      = 'https://api.example.com/v2';
    const int    TIMEOUT       = 15;
    const int    MAX_RETRIES   = 3;
    const string CONTENT_TYPE  = 'application/json';
    const array  DEFAULT_HEADERS = [
        'Accept' => 'application/json',
    ];

    public function __construct(
        private readonly ConfigFactoryInterface $configFactory,
        private readonly ClientFactory $httpClientFactory,
    ) {}

    public function fetch(string $path): array {
        $url = self::BASE_URL . '/' . ltrim($path, '/');
        $client = $this->httpClientFactory->fromOptions([
            'timeout' => self::TIMEOUT,
            'headers' => self::DEFAULT_HEADERS,
        ]);

        $response = $client->get($url);
        return json_decode($response->getBody()->getContents(), true);
    }
}

The typed constants make the intent of each constant explicit. A developer reading this class immediately knows TIMEOUT is an integer (seconds), BASE_URL is a string, and DEFAULT_HEADERS is an array.

json_validate()

Before PHP 8.3, the standard way to check if a string was valid JSON was to decode it and check for errors:

// PHP 8.2 — wasteful for large payloads
function isValidJson(string $json): bool {
    json_decode($json);
    return json_last_error() === JSON_ERROR_NONE;
}

This has a significant problem: json_decode() allocates PHP objects and arrays in memory to represent the parsed structure. For a 10 MB JSON payload, this means allocating 10 MB+ just to check validity. If the JSON is invalid, that allocation was wasted. If you are validating untrusted input before processing it, this is also a denial-of-service vector.

PHP 8.3 adds json_validate(), which checks JSON validity without allocating any parsed structure:

// PHP 8.3
$valid = json_validate('{"key": "value"}');  // true
$valid = json_validate('not json');           // false
$valid = json_validate('{"key": undefined}'); // false

// Works with large strings efficiently
$largejson = file_get_contents('/path/to/large.json');
if (json_validate($largejson)) {
    $data = json_decode($largejson, true);
    // process...
}

The depth Parameter

json_validate() accepts a depth parameter to limit nesting depth, and a flags parameter:

// Reject deeply nested JSON (default depth is 512)
$valid = json_validate($input, depth: 10);

// Allow comments (using JSON5-style flags — currently no flags are defined,
// but the parameter is reserved for future use)
$valid = json_validate($input, depth: 512, flags: 0);

The depth limit is important for security. Deeply nested JSON can cause stack overflows or excessive memory use during decoding. Validating with a strict depth limit before decoding is a sensible defence:

class JsonHelper {
    const int MAX_JSON_DEPTH = 16;

    public static function safeDecode(string $input): mixed {
        if (!json_validate($input, depth: self::MAX_JSON_DEPTH)) {
            throw new \InvalidArgumentException('Invalid JSON payload');
        }
        return json_decode($input, true, self::MAX_JSON_DEPTH, JSON_THROW_ON_ERROR);
    }
}

Practical API Validation in Drupal

<?php

namespace Drupal\mymodule\Controller;

use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

class WebhookController extends ControllerBase {

    const string WEBHOOK_SECRET = 'sha256';
    const int    MAX_PAYLOAD_DEPTH = 10;

    public function receive(Request $request): JsonResponse {
        $body = $request->getContent();

        // Validate JSON structure before any processing
        if (!json_validate($body, depth: self::MAX_PAYLOAD_DEPTH)) {
            return new JsonResponse(['error' => 'Invalid JSON'], 400);
        }

        // Now safe to decode — we know it's valid and not too deeply nested
        $data = json_decode($body, true, self::MAX_PAYLOAD_DEPTH, JSON_THROW_ON_ERROR);

        // Process webhook...
        return new JsonResponse(['status' => 'ok']);
    }
}

The #[Override] Attribute

PHP 8.3 adds the #[\Override] attribute, which declares that a method is intended to override a parent method. If no parent method with that name exists, a fatal error is thrown:

class Base {
    public function process(): void {
        // base implementation
    }
}

class Child extends Base {
    #[\Override]
    public function process(): void {
        // This is fine — process() exists in Base
    }

    #[\Override]
    public function processTypo(): void {
        // Fatal error: processTypo() does not exist in Base
        // This catches a common refactoring bug
    }
}

This is particularly valuable during refactoring. If you rename a method in a parent class and forget to update a subclass, the #[\Override] attribute on the subclass method will throw immediately rather than silently introducing a bug where both methods exist and the old one is never called.

PHP 8.3 array_find() Note

array_find() and array_find_key() are PHP 8.4 additions, not 8.3. In PHP 8.3, the idiomatic approach is still array_filter. Shown here for completeness:

// PHP 8.4
$users = [
    ['id' => 1, 'name' => 'Alice'],
    ['id' => 2, 'name' => 'Bob'],
    ['id' => 3, 'name' => 'Charlie'],
];

$user = array_find($users, fn($u) => $u['id'] === 2);
// ['id' => 2, 'name' => 'Bob']

$key = array_find_key($users, fn($u) => $u['name'] === 'Charlie');
// 2

In PHP 8.3, the idiomatic approach is:

// PHP 8.3 equivalent
$found = array_values(array_filter($users, fn($u) => $u['id'] === 2))[0] ?? null;

Randomizer Additions

PHP 8.3 extends the Random\Randomizer class introduced in PHP 8.2 with new methods:

$rng = new \Random\Randomizer();

// Get a random float between 0.0 and 1.0
$float = $rng->getFloat(0.0, 1.0);

// Get a random float in a range
$price = $rng->getFloat(9.99, 99.99);

// Pick random bytes as a string
$bytes = $rng->getBytes(32);

// Shuffle a string (new in 8.3)
$shuffled = $rng->shuffleBytes('hello world');

Dynamic Class Constant Fetch

PHP 8.3 allows class constants to be accessed with a variable class name and a variable constant name:

class Colors {
    const string RED   = '#ff0000';
    const string GREEN = '#00ff00';
    const string BLUE  = '#0000ff';
}

$class    = 'Colors';
$constant = 'RED';

echo $class::$constant;  // PHP 8.3: '#ff0000'
// Previously required constant() function: constant("$class::$constant")

This is syntactic convenience but removes the need for the constant() function in most dynamic constant lookup scenarios.

Upgrade Considerations

PHP 8.3 is a minor version with few breaking changes. For Drupal sites, the main check is ensuring your Drupal version supports 8.3. Drupal 10.1+ supports PHP 8.3, and Drupal 11 requires PHP 8.3 as a minimum. Run composer require php:^8.3 (or update your platform.php in composer.json) and run composer update to ensure all dependencies are compatible.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.