The payload that pushed me into this was 24.7 MiB of JSON. A partner drops a nightly manifest on an endpoint of ours, we check it is well formed, we store the raw body, and a worker picks it apart later. For years the check was json_decode() followed by json_last_error(), and the decoded array was thrown away one line later. On the PHP 8.3 CLI, with that file, the two approaches measure like this:
$json = file_get_contents('/var/www/app/var/manifest.json'); // 24.7 MiB
// the old way: peak memory 238.8 MiB, 126 ms
json_decode($json, true);
$ok = json_last_error() === JSON_ERROR_NONE;
// 8.3: peak memory 26.7 MiB, 101 ms
$ok = json_validate($json);
Those are my numbers from memory_get_peak_usage(true), not a published benchmark. The interesting figure is not the 25 ms. It is that decoding a 24.7 MiB document allocated about 212 MiB on top of the string, roughly nine times the payload, to build an array we immediately discarded. A 2.75 MiB payload showed the same shape: 28.75 MiB peak decoded, 4.77 MiB validated.
What json_validate actually buys
The signature is json_validate(string $json, int $depth = 512, int $flags = 0): bool, and the guarantee is the one you want: if it returns true, json_decode() will successfully decode the same string with the same depth and flags. It uses the parser that json_decode() already uses, so there is no second definition of valid JSON to keep in sync. That was the whole argument in the RFC: userland validators call json_decode() and pay for a zval tree nobody reads.
flags currently accepts only JSON_INVALID_UTF8_IGNORE. When it returns false, the reason comes from json_last_error_msg(), which is how you find out you hit the nesting limit rather than a syntax error:
var_dump(json_validate('[[[1]]]', 2)); // bool(false)
var_dump(json_last_error_msg()); // "Maximum stack depth exceeded"
One thing that caught me in review. PHP implements a superset of the original JSON specification, so a bare scalar is valid: json_validate('123') and json_validate('null') both return true. If your endpoint expects an object, validity is not the check you think it is. We still assert the top-level shape after validating, we just do it on the decoded value in the worker instead of in the request.
Where it buys you nothing
The manual is unusually direct about this, and I am glad it is. Calling json_validate() immediately before json_decode() parses the string twice, because decoding validates as it goes. If you need the decoded value, the old idiom was never wasteful, and 8.3 does not change that. Use json_decode() with JSON_THROW_ON_ERROR and be done.
So the function pays off in exactly one situation: you need a yes or no now and the decoded value later or never. Gatekeeping an upload. Rejecting a webhook body before it goes on a queue. Scanning a directory of files to find the corrupt one. In our case the request handler validates and writes the body to disk, and the decode happens in a worker with its own memory limit, which is what let us drop that pool’s memory_limit from 512M back to 256M. Everywhere else in the codebase, json_decode() stayed.
Typed class constants
This is the change I have used most, and unlike the JSON one it costs nothing to adopt. Constants in an interface were a promise with no enforcement. Here is the actual pair from a partner integration, before and after:
// before: nothing stops an implementor returning an array
interface HasChannel
{
const CHANNEL = 'web';
}
// 8.3
interface HasChannel
{
const string CHANNEL = 'web';
}
class Partner implements HasChannel
{
const string CHANNEL = ['api'];
}
// Fatal error: Cannot use array as value for class constant
// Partner::CHANNEL of type string
Class, interface, trait and enum constants all take types, per the RFC. Three details worth knowing before you start annotating. Types are checked in strict mode regardless of declare(strict_types=1), so public const int RETRIES = '3' is a fatal error, not a coerced 3. Constants are covariant, meaning a child may narrow the type but not widen it: redeclaring an int constant as string gives you Type of Child::RETRIES must be compatible with Base::RETRIES of type int. And void, callable and never are not supported. Reflection gained ReflectionClassConstant::getType() and hasType(), which is how a static analyser sees any of this.
I annotated 140-odd constants across two applications over a couple of afternoons. It found two real bugs, both the same shape: a constant documented as a string in the interface and overridden as an array in one implementation, exactly the case in the snippet above.
The rest of it
Four months of running 8.3 and the rest of the feature list has been quieter. The #[Override] attribute makes PHP verify that a method exists in a parent or an implemented interface, which turns a mistyped tearDown() from a silent no-op into a fatal error at compile time; the release announcement uses exactly that example, which suggests I am not the only one who has done it. Readonly properties may now be reinitialised inside __clone(), which finally makes deep cloning of readonly objects possible; we had two value objects working around that with a static factory. Dynamic class constant fetch replaces constant(Foo::class . "::$name") with Foo::{$name}. The Randomizer added getBytesFromString(), getFloat() and nextFloat(), and the first of those deleted a hand-rolled random_int() loop we used for tracking references.
The deprecations cost us one afternoon. Calling get_class() and get_parent_class() with no arguments is deprecated, which is the one that shows up everywhere in older code. assert_options() and the assert.* INI settings are deprecated. Incrementing an empty or non-numeric string with ++ now emits a notice, with str_increment() as the replacement. ldap_connect() with a separate hostname and port is deprecated, and so is ReflectionProperty::setValue() with a single argument.
Next on the list is the support calendar: 8.2 leaves active support at the end of this year, and 8.3 is fine until the end of 2025. The remaining holdout is one client dashboard still on 8.1, which goes out of active support in December. That is the migration I should be planning instead of annotating constants.
Sources
- PHP supported versions: 8.3 had its initial release on 23 November 2023, with active support to the end of 2025.
- json_validate(): the signature, the guarantee that a true result decodes, the accepted flag, and the caution against calling it right before
json_decode(). - RFC: json_validate: why decoding to validate wastes memory, and that the function reuses the core JSON parser.
- PHP 8.3 release announcement: the
#[Override]failure message, readonly deep cloning, and theRandomizeradditions. - RFC: Typed class constants: supported types, strict checking regardless of
strict_types, covariance rules, and the Reflection methods. - PHP 8.3 new features: typed constants, readonly amendments, dynamic class constant fetch and the
#[Override]attribute. - PHP 8.3 deprecated features:
get_class()without arguments,assert_options(), string increment,ldap_connect()andReflectionProperty::setValue().