json_validate() vs try/catch json_decode(): Checking JSON Without Building It

PHP json_validate checks a payload without decoding it. Where that genuinely saves memory, why calling it before json_decode is slower, and the Laravel angle.

Steven Richardson
Steven Richardson
· 9 min read

When PHP 8.3 shipped json_validate(), most of the posts about it landed on the same example: check whether a string is valid JSON, then decode it. That example is the one case where the function costs you rather than saves you — you have now parsed the payload twice. The places where it earns its keep are less obvious, and nobody writes about them.

What json_validate() actually does differently#

The signature is small:

json_validate(string $json, int $depth = 512, int $flags = 0): bool
  • $json — must be UTF-8. There is no stream or resource overload; the whole string sits in memory.
  • $depth — maximum nesting depth. Must be greater than 0 and no more than 2147483647. Out of range throws a ValueError.
  • $flags — a bitmask. JSON_INVALID_UTF8_IGNORE is the only flag currently accepted. Anything else throws a ValueError.

It uses the same parser as json_decode(). The difference is what happens to the output: json_decode() allocates a PHP value for every object, array, key and string it encounters, and json_validate() allocates none of it. That is the whole feature. It is the same shape of addition as PHP 8.4's DOM\HTMLDocument parser — a primitive that is obviously useful once you know which job it is for, and easy to misuse until then.

json_validate('[1, 2, 3]');  // true
json_validate('{1, 2, 3]');  // false
json_validate('null');       // true — null is valid JSON
json_validate('5');          // true — a bare scalar is valid JSON
json_validate('');           // false — an empty string is not

Those last three catch people out. An empty request body is not valid JSON, and null is.

The anti-pattern: validating before decoding#

This is the version that spread everywhere:

// Don't. Two full parses of the same string.
if (json_validate($payload)) {
    $data = json_decode($payload, associative: true);
}

json_decode() already tells you whether the input was valid. Ask it:

try {
    $data = json_decode($payload, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    report($e);

    return response()->json(['message' => 'Malformed JSON body.'], 422);
}

One parse, an exception carrying the reason, and no branch that silently leaves $data undefined. The PHP manual is explicit about this: json_validate() uses less memory than json_decode() only when the decoded payload is not used.

I am deliberately not publishing a benchmark table here. The numbers swing wildly with payload shape, string-to-structure ratio, PHP build and available memory, and a table from my laptop would be quoted as if it were universal. Run it on the payloads you actually receive:

<?php
// bench.php — one mode per process, because memory_get_peak_usage() cannot be reset.
// php -d memory_limit=2G bench.php validate payload.json
// php -d memory_limit=2G bench.php decode   payload.json
[$mode, $path] = [$argv[1], $argv[2]];

$json = file_get_contents($path);
$baseline = memory_get_peak_usage(true);

$start = hrtime(true);

$mode === 'validate'
    ? json_validate($json)
    : json_decode($json, associative: true);

printf(
    "%-8s size %.1f MB | %.2f ms | peak +%.1f MB\n",
    $mode,
    strlen($json) / 1048576,
    (hrtime(true) - $start) / 1e6,
    (memory_get_peak_usage(true) - $baseline) / 1048576
);

Two things you will see consistently: on a few hundred bytes the difference is measurement noise, and on a payload large enough to matter the memory gap is the interesting column, not the time.

Rejecting a payload you never intend to read#

Here is the first honest use case. An endpoint receives JSON from a third party, and a malformed body is a 422 you never look at again:

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

    if (! json_validate($body, depth: 64)) {
        Log::warning('Rejected malformed JSON body', [
            'bytes' => strlen($body),
            'error' => json_last_error_msg(),
            'source' => $request->ip(),
        ]);

        return response()->json(['message' => 'Malformed JSON body.'], 422);
    }

    ProcessInboundPayload::dispatch($body);

    return response()->json(status: 202);
}

The job decodes it later, on a worker, once. The web process never builds the structure. If you are wiring this up for a real provider, do the signature check before the JSON check — verifying Stripe webhook signatures in Laravel covers why order matters there.

Validating JSONL line by line without decoding#

The second use case. A 2GB JSONL export, and you want a count of bad lines and their line numbers before you commit to importing any of it:

use Illuminate\Support\LazyCollection;

$invalid = LazyCollection::make(function () use ($path) {
    $handle = fopen($path, 'rb');

    while (($line = fgets($handle)) !== false) {
        yield $line;
    }

    fclose($handle);
})
    ->map(fn (string $line, int $i) => [$i + 1, trim($line)])
    ->reject(fn (array $row) => $row[1] === '')          // skip blank lines
    ->reject(fn (array $row) => json_validate($row[1]))  // keep only the failures
    ->take(50)
    ->all();

Nothing decoded, nothing accumulated, and it stops after fifty failures because at that point the file is the problem, not the rows. The lazy collection patterns for streaming large files apply here unchanged — this is the same generator plumbing with a cheaper predicate.

Auditing a JSON column across millions of rows#

The third. A legacy text column full of hand-written JSON, and you need to know which rows will explode when someone finally adds a json cast:

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class AuditJsonColumn extends Command
{
    protected $signature = 'audit:json-column {table} {column}';

    public function handle(): int
    {
        $bad = [];

        DB::table($this->argument('table'))
            ->select('id', $this->argument('column'))
            ->orderBy('id')
            ->chunkById(1000, function ($rows) use (&$bad) {
                foreach ($rows as $row) {
                    $value = $row->{$this->argument('column')};

                    // NULL is a legitimate empty column; '' is not valid JSON.
                    if ($value !== null && ! json_validate($value)) {
                        $bad[] = $row->id;
                    }
                }
            });

        $this->table(['invalid row ids'], array_map(fn ($id) => [$id], $bad));

        return $bad === [] ? self::SUCCESS : self::FAILURE;
    }
}

chunkById() keeps the query side bounded and json_validate() keeps the PHP side bounded. Two million rows of stored JSON never become two million decoded arrays.

Using $depth as a guard, honestly#

$depth is not unique to json_validate()json_decode() takes it too, and both default to 512. So the claim "use json_validate() to defend against nesting attacks" is half true. The accurate version: both functions stop at the limit, but only one of them has been allocating structures on the way up to it.

$deep = str_repeat('[', 20_000).str_repeat(']', 20_000);

json_validate($deep);            // false — depth 512 exceeded, nothing built
json_last_error_msg();           // "Maximum stack depth exceeded"

json_validate($deep, depth: 32); // false, and it gives up 480 levels sooner

If you know your contract is never nested more than a handful of levels, pass a $depth that says so. It is a one-argument tightening on an endpoint you do not control.

The caveat that matters: a true return guarantees json_decode() will succeed with the same $depth and $flags. Validate at depth: 32 and then decode at the default 512 and you have verified the wrong thing.

Reading the error when json_validate() fails#

json_validate() returns a bare false, but the reason is still available through the same two functions json_decode() has always used:

if (! json_validate($payload)) {
    throw new JsonException(json_last_error_msg(), json_last_error());
}

json_last_error() returns 4 / "Syntax error" for a malformed body and JSON_ERROR_DEPTH for a nesting failure — so "invalid JSON" in your logs can be specific for free. Note that json_validate() writes that global error state, exactly like json_encode() and json_decode(), so it is not a pure function.

Where this fits with Laravel's json validation rule#

Laravel's built-in json rule and a rule built on json_validate() are not interchangeable. The framework rule rejects a value that is already an array, casts everything else to a string, and delegates to Str::isJson(). The practical consequences: '5' and 'null' pass the json rule, because they are valid JSON documents, and an actual decoded array fails it.

That is usually not what an API contract means by "this field contains JSON". If you want an object or array specifically, and a depth ceiling, write the rule:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class JsonObject implements ValidationRule
{
    public function __construct(private int $depth = 32) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || ! str_starts_with(ltrim($value), '{')) {
            $fail('The :attribute must be a JSON object.');

            return;
        }

        if (! json_validate($value, $this->depth)) {
            $fail('The :attribute is not valid JSON: '.json_last_error_msg().'.');
        }
    }
}

Worth checking your vendor copy of Str::isJson() rather than trusting a blog post about it — the implementation has changed across majors, and whether it reaches for json_validate() or decodes depends on the version you have pinned. If you are validating request payloads interactively rather than at submit time, Precognition's live form validation runs the same rules without duplicating them in JavaScript.

Supporting PHP 8.2 without the memory win#

If you still support 8.2, the fallback decodes — which means it has none of the benefit:

if (! function_exists('json_validate')) {
    function json_validate(string $json, int $depth = 512, int $flags = 0): bool
    {
        if ($depth <= 0) {
            throw new ValueError('json_validate(): Argument #2 ($depth) must be greater than 0');
        }

        if ($flags !== 0 && $flags !== JSON_INVALID_UTF8_IGNORE) {
            throw new ValueError('json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE)');
        }

        json_decode($json, null, $depth, $flags);

        return json_last_error() === JSON_ERROR_NONE;
    }
}

Ship it for API compatibility, not for performance, and do not build a memory-sensitive import on top of it while 8.2 is still in your matrix. If you are working out what else moves when you drop an old minor, the PHP 8.5 deprecations cheat sheet is the same exercise one version further along.

Gotchas and Edge Cases#

  • It validates syntax, not shape. {"a":1} is perfectly valid JSON and completely wrong for your endpoint. Schema validation is a separate problem — json_validate() gets you to the point where schema validation is possible, and no further.
  • No streams. The string has to be fully in memory, which caps the payload size this helps with. A 4GB file still needs line-by-line or chunked handling.
  • json_validate('') is false. Treat an empty body as its own case, with its own error message, before you get near the parser.
  • It mutates global error state. Fine in a normal request lifecycle; something to be aware of on runtimes that share a thread across concurrent requests.
  • An invalid flag throws, it does not return false. json_validate($s, flags: JSON_BIGINT_AS_STRING) raises a ValueError.

Wrapping Up#

The rule is one line: if you need the decoded value, decode it with JSON_THROW_ON_ERROR and catch JsonException. If you need the answer and not the value — reject, screen, audit — reach for json_validate(), and pass a $depth that matches your contract.

If you are on the receiving end of third-party JSON regularly, the next thing worth tightening is the shape of what you hand back out: structuring JSON API resources in Laravel 13 covers the response side. And if the reason you are reading about memory is that a long-running process keeps growing, WeakMap for memory-safe object caching is a more likely culprit than your JSON parsing.

FAQ#

What does json_validate() do in PHP?

json_validate() returns true if a string is syntactically valid JSON and false otherwise. It runs the same parser as json_decode() but never builds the resulting PHP array or object, so it uses less memory when you do not need the decoded value. It was added in PHP 8.3.

Is json_validate() faster than json_decode()?

For validation alone it is usually faster and always uses less memory, because it skips allocating the decoded structure. But if you then decode the same string, the pair is slower than a single json_decode() call — you have parsed the payload twice. On small payloads the difference either way is noise.

Should I call json_validate() before json_decode()?

No. json_decode() already reports invalid input, so validating first duplicates the parse for nothing. Use json_decode($json, flags: JSON_THROW_ON_ERROR) inside a try/catch (JsonException) block instead, which gives you the failure reason as an exception.

How do I check if a string is valid JSON in PHP?

On PHP 8.3 and later, call json_validate($string). On older versions, call json_decode($string, flags: JSON_THROW_ON_ERROR) and catch JsonException, or check json_last_error() === JSON_ERROR_NONE after decoding. Remember that an empty string is not valid JSON, while 'null', '5' and '"text"' all are.

What flags does json_validate() accept?

Only JSON_INVALID_UTF8_IGNORE is currently accepted. Passing any other JSON constant, such as JSON_BIGINT_AS_STRING, throws a ValueError rather than returning false. The $depth argument must also be greater than 0 and no more than 2147483647, or it throws as well.

How does Laravel's json validation rule differ from json_validate()?

Laravel's json rule rejects values that are already arrays, casts everything else to a string, and delegates to Str::isJson(). That means bare JSON scalars such as '5' or 'null' pass the rule, which is often not what an API contract intends. If you need an object specifically, or a depth ceiling, write a custom ValidationRule around json_validate().

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.