Syntax Error, Line 1: PHP 8.6 Finally Tells You Where Your JSON Broke

PHP 8.6 json_decode error position adds line and column to JSON failures. Wire it into Laravel so a malformed webhook stops being an unreproducible mystery.

Steven Richardson
Steven Richardson
· 8 min read

A partner sends 40KB of JSON to a webhook endpoint. One byte is wrong. Until PHP 8.6, json_last_error_msg() said Syntax error and nothing else, so the only way to find the problem was to dump the raw body into a log store — customer data and all — and bisect it by hand. PHP 8.6 fixes that: every JSON decoding failure now carries the line and column where the parser gave up.

Confirm you are running PHP 8.6#

The error location is a runtime behaviour of the JSON extension, not a userland shim, so nothing can backport it. Check the version before you write code that depends on the new message.

php -v
# PHP 8.6.0 (cli) (built: ...)

php -r 'json_decode("[{"); echo json_last_error_msg(), PHP_EOL;'
# PHP 8.6: Syntax error near location 1:3
# PHP 8.5: Syntax error

If you are still planning the jump, the PHP 8.6 upgrade checklist for Laravel apps covers the deprecations you will hit on the way.

Compare the same broken payload before and after#

The change is additive. PHP appends near location {line}:{column} to the existing message text, and json_last_error() keeps returning the identical integer codes. Nothing in your error handling needs rewriting — the message just stops being useless.

<?php

// Unclosed object
json_decode('[{');
json_last_error();     // 4 (JSON_ERROR_SYNTAX) — same on 8.5 and 8.6
json_last_error_msg(); // 8.6: "Syntax error near location 1:3"

// Raw newline inside a string value
json_decode("{\n\t  \"key1\": \"val1\",\n  \t\"key2\": \"val2\n}");
json_last_error_msg(); // "Control character error, possibly incorrectly encoded near location 3:12"

// Stray closing brace in a long single-line body
json_decode('{"numbers": [1, 2, 3], "strings": ["a", "b", "c"], "booleans": [true, false, true}');
json_last_error_msg(); // "State mismatch (invalid or malformed JSON) near location 1:82"

// Invalid UTF-8
json_decode("\"a\xb0b\"");
json_last_error_msg(); // "Malformed UTF-8 characters, possibly incorrectly encoded near location 1:1"

That third one is the case that pays for the upgrade. Column 82 of a minified single-line payload is a number you can act on; "Syntax error" is not.

Decode with JSON_THROW_ON_ERROR everywhere#

json_decode() without flags returns null on failure, and null is also a perfectly valid decode of the literal null. The location only helps if something goes and fetches it, and in practice nobody does. Pass JSON_THROW_ON_ERROR and the location arrives inside a JsonException that your exception handler already reports.

<?php

declare(strict_types=1);

use JsonException;

try {
    $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    // "Syntax error near location 1:82"
    report($e);
}

I treat the unflagged call as a code smell now. If a decode can fail, it should throw; if it genuinely cannot fail — you encoded the string yourself two lines earlier — the flag costs nothing.

Wrap the failure in an exception that logs the location, not the payload#

A bare JsonException tells Sentry where the parse broke but not which integration produced it. A small custom exception adds the source and a short head of the body for shape, while leaving the payload itself out of your logs. This is the same context() pattern covered in custom Laravel exceptions with render, report and context.

<?php

declare(strict_types=1);

namespace App\Exceptions;

use Illuminate\Support\Str;
use JsonException;
use RuntimeException;

final class JsonDecodeFailed extends RuntimeException
{
    public function __construct(
        private readonly string $source,
        private readonly string $raw,
        JsonException $previous,
    ) {
        parent::__construct(
            "Failed to decode JSON from {$source}: {$previous->getMessage()}",
            previous: $previous,
        );
    }

    /**
     * @return array{source: string, json_error: string, bytes: int, head: string}
     */
    public function context(): array
    {
        return [
            'source' => $this->source,
            // Already contains "near location {line}:{column}" on PHP 8.6
            'json_error' => $this->getPrevious()->getMessage(),
            'bytes' => strlen($this->raw),
            // Shape only — never the full body
            'head' => Str::limit($this->raw, 120),
        ];
    }

    public static function decode(string $source, string $raw): array
    {
        try {
            return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new self($source, $raw, $e);
        }
    }
}

Note what is not happening: I never parse the location out of the message string. The message carries it, Laravel's handler logs it, and that is the whole job. If you also push request-scoped metadata through Laravel's Context facade for logging across jobs, the resulting log line identifies the request, the integration and the exact column without a single byte of customer data.

Make the HTTP client throw on malformed JSON#

Illuminate\Http\Client\Response::json() decodes with no flags by default, so a third-party API returning truncated JSON hands you null and your code fails three frames later with a confusing TypeError. Laravel exposes a static property to change that for every response in the app.

<?php

// app/Providers/AppServiceProvider.php

use Illuminate\Http\Client\Response;

public function boot(): void
{
    Response::$defaultJsonDecodingFlags = JSON_THROW_ON_ERROR;
}

From then on Http::get(...)->json() throws a JsonException with the location instead of silently returning null. For a single call site you can pass the flags inline:

$response = Http::acceptJson()->get('https://api.partner.test/orders');

try {
    $orders = $response->json(flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    throw new JsonDecodeFailed('partner.orders', $response->body(), $e);
}

Gate large webhook bodies with json_validate() first#

json_validate() (PHP 8.3+) answers yes/no without building the decoded structure in memory, which matters when a partner can post megabytes at you. Use it as the cheap rejection gate, then decode for real once you know the body is sane — the trade-offs are worked through in checking JSON payloads without decoding them.

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Exceptions\JsonDecodeFailed;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final class PartnerWebhookController
{
    public function __invoke(Request $request): Response
    {
        $raw = $request->getContent();

        if (! json_validate($raw)) {
            // Record why, then 422 so the partner retries with a fix
            report(new \RuntimeException(
                'Invalid webhook JSON: '.json_last_error_msg() // includes the location
            ));

            return response('Malformed JSON', 422);
        }

        $payload = JsonDecodeFailed::decode('partner.webhook', $raw);

        // ... dispatch a job with $payload

        return response('', 202);
    }
}

One ordering note: validate the signature before you validate the JSON. A body that fails the HMAC check should never reach your parser at all — see verifying Stripe webhook signatures in Laravel for the pattern.

Test the behaviour without asserting the message text#

The message wording is not a stable API. Assert that the right exception type is thrown and that the context carries a location-shaped string, and leave the exact phrasing alone so a future PHP patch release does not turn your suite red.

<?php

use App\Exceptions\JsonDecodeFailed;

it('wraps malformed JSON with the parse location', function () {
    $raw = '{"numbers": [1, 2, 3], "booleans": [true, false, true}';

    expect(fn () => JsonDecodeFailed::decode('partner.webhook', $raw))
        ->toThrow(JsonDecodeFailed::class);

    try {
        JsonDecodeFailed::decode('partner.webhook', $raw);
    } catch (JsonDecodeFailed $e) {
        expect($e->context())
            ->toHaveKeys(['source', 'json_error', 'bytes', 'head'])
            // Shape, not wording: "<something> near location 1:54"
            ->and($e->context()['json_error'])->toMatch('/near location \d+:\d+$/');
    }
});

it('leaves json_last_error codes untouched', function () {
    json_decode('[{');

    expect(json_last_error())->toBe(JSON_ERROR_SYNTAX);
});

Watch out for these edge cases#

The location is where the parser gave up, not where a human would say the mistake is. An unclosed brace at the top of a 40KB document is reported at the end of the input, because that is the point at which the parser ran out of road. Expect to read the column as "everything up to here was fine" rather than "the bad character is here".

A few more worth knowing:

  • The column counts the raw input, not the pretty-printed version you are looking at. Minified payloads report 1:{offset}, which means you want substr($raw, $column - 40, 80) and not a text editor.
  • Escape sequences shift the column. A ÿ in the source is six bytes of input but one character of output; the reported column follows the input.
  • Do not regex the message in production code. It is a diagnostic string, not a contract. Log it, alert on it, never branch on it.
  • json_encode() is unchanged. PHP 8.6 improves encoding performance, but encode failures still report depth, recursion or invalid-UTF-8 without a location — there is no input string to point into.
  • Depth errors get a location too. json_decode('[[[[[[[[[[42]]]]]]]]]]', depth: 6) reports Maximum stack depth exceeded near location 1:6, which tells you exactly which nesting level blew the limit.

Wrapping Up#

Turn on JSON_THROW_ON_ERROR globally for the HTTP client, wrap decodes behind a small exception that logs the location rather than the body, and resist the urge to parse the message. That is a thirty-minute change that converts your least reproducible class of bug into a one-line log entry. PHP 8.6 carries the same treatment into other extensions — stream errors now raise a typed StreamException — and the rest of the upgrade path is mapped out in the Laravel PHP 8.6 upgrade checklist.

FAQ#

What changed with json_decode error messages in PHP 8.6?

PHP 8.6 appends the location of the failure to every JSON decoding error message, in the form near location {line}:{column}. The same text appears in json_last_error_msg() and in the message of a JsonException thrown via JSON_THROW_ON_ERROR. The error type and description that came before it are unchanged, so the new text is purely additive.

Does json_last_error() return different codes in PHP 8.6?

No. json_last_error() returns the identical integer constants it always has — JSON_ERROR_SYNTAX, JSON_ERROR_DEPTH, JSON_ERROR_UTF8 and the rest. Only the human-readable message gained the location, which means any existing test or branch that checks the error code keeps working unchanged after the upgrade.

How do I find out where my JSON is malformed in PHP?

On PHP 8.6 you read it straight out of the error message: decode the string and call json_last_error_msg(), or decode with JSON_THROW_ON_ERROR and read the exception message. Both give you a line and column. On PHP 8.5 and earlier there is no equivalent — the parser never exposed the offset, so you were limited to bisecting the payload by hand.

How do I log JSON parse failures in Laravel without logging the whole payload?

Catch the JsonException, wrap it in your own exception type, and have context() return the exception message plus metadata like the byte length and the integration name. The message already contains the line and column, so you get a precise diagnostic without storing the body. If you need a sample of the content, truncate it hard with Str::limit() rather than logging it whole.

Should I use json_validate() or json_decode() to check a payload?

Use json_validate() when you only need a boolean answer and the body might be large — it skips building the decoded structure, so it is cheaper on memory. Use json_decode() with JSON_THROW_ON_ERROR when you need the data anyway, or when you need to know why a body failed. Validating and then decoding parses the string twice, so only do both when the cheap rejection is worth it.

Does Laravel's HTTP client surface the new JSON error message?

Yes, as long as you ask it to. Illuminate\Http\Client\Response::json() decodes with no flags by default and returns null on failure. Set Response::$defaultJsonDecodingFlags = JSON_THROW_ON_ERROR; in a service provider, or pass flags: JSON_THROW_ON_ERROR at the call site, and a malformed response throws a JsonException carrying the location.

Steven Richardson
Steven Richardson

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