Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Jsonlint Laravel Package

seld/jsonlint

A PHP JSON parser and linter that validates JSON with clear error messages, supports parsing, duplicate-key handling, comments, associative arrays, and UTF-8 validation.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and orient. One Composer command, no service provider, no config file — this is a framework-agnostic utility library, not a Laravel package:

composer require seld/jsonlint

Three entry points matter on day one, in order of cost:

  1. The CLI binary — zero application code, immediate CI value:
    vendor/bin/jsonlint composer.json package.json
    vendor/bin/jsonlint -q resources/lang/*.json   # quiet mode, exits non-zero on failure
    
    Wire this into composer.json scripts or your CI job before writing a single line of PHP.
  2. JsonParser::lint() — returns null on valid JSON or a ParsingException. This is the method you'll call most.
  3. JsonParser::parse() — throws on failure, returns decoded data. Treat this as a debugging/fallback tool, not your normal decode path.

Read the source of truth first. The README is short and complete; the flags list is the whole API surface. Then read how Composer itself uses the library (Composer\Json\JsonFile::parseJson()), because that pattern — json_decode first, jsonlint only on failure — is the intended usage and the one you should copy.

First use case: a FormRequest that explains why the JSON is invalid. This is the highest-value, lowest-risk place to start, because the user is already waiting on a validation response:

use Seld\JsonLint\JsonParser;
use Seld\JsonLint\ParsingException;

class JsonPayloadRequest extends FormRequest
{
    public function rules(): array
    {
        return ['payload' => ['required', 'string']];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            $raw = (string) $this->input('payload');

            json_decode($raw);
            if (json_last_error() === JSON_ERROR_NONE) {
                return; // fast path — never touch jsonlint
            }

            $e = (new JsonParser)->lint($raw);

            if ($e instanceof ParsingException) {
                $validator->errors()->add('payload', $this->friendlyMessage($e));
            }
        });
    }
}

$e->getMessage() gives you the caret-annotated block:

Parse error on line 2:
... "key": "value"    "numbers": [1, 2, 3]
----------------------^
Expected one of: 'EOF', '}', ':', ',', ']'

Keep getDetails() (line, column, expected tokens, duplicate key name) for logging — not for the user.

Implementation Patterns

The native-first, jsonlint-on-failure rule

This is the single most important pattern, and it should be enforced in code review:

$data = json_decode($raw, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    // Only now pay for the parser
    $data = (new JsonParser)->parse($raw, JsonParser::PARSE_TO_ASSOC);
}

json_decode is dramatically faster. jsonlint exists to produce good error messages, not to be your decoder. If you find JsonParser on a hot path (request middleware for every request, list endpoints, queue workers processing thousands of messages), that's a defect.

A thin wrapper service — worth the one hour

Bind a singleton so flags are configured in one place and the "fallback only" rule is expressed in the API:

// app/Services/Json/JsonInspector.php
namespace App\Services\Json;

use Seld\JsonLint\JsonParser;
use Seld\JsonLint\ParsingException;

class JsonInspector
{
    public function __construct(private JsonParser $parser) {}

    /** @throws ParsingException */
    public function decode(string $raw, int $flags = 0): mixed
    {
        $decoded = json_decode($raw, (bool) ($flags & JsonParser::PARSE_TO_ASSOC));

        if (json_last_error() === JSON_ERROR_NONE) {
            return $decoded;
        }

        return $this->parser->parse($raw, $flags); // throws with a real message
    }

    public function lint(string $raw, int $flags = 0): ?ParsingException
    {
        return $this->parser->lint($raw, $flags);
    }

    public function isValid(string $raw, int $flags = 0): bool
    {
        return $this->lint($raw, $flags) === null;
    }
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(JsonParser::class, fn () => new JsonParser);
$this->app->singleton(JsonInspector::class);

Now the flags live in config, not scattered across twenty call sites:

// config/json.php
return [
    // Applied to user-uploaded JSON: reject ambiguous documents.
    'upload_flags' => JsonParser::DETECT_KEY_CONFLICTS | JsonParser::VALIDATE_UTF8_ENCODING,

    // Applied to developer-editable files: tolerant, assoc output.
    'config_flags' => JsonParser::ALLOW_COMMENTS
        | JsonParser::PARSE_TO_ASSOC
        | JsonParser::VALIDATE_UTF8_ENCODING,
];

Custom Validation Rule

Package it as a reusable rule so controllers stay clean:

class ValidJson implements ValidationRule
{
    public function __construct(
        private int $flags = 0,
        private bool $requireObject = false,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value)) {
            $fail("The {$attribute} must be a JSON string.");
            return;
        }

        $error = app(JsonParser::class)->lint($value, $this->flags);

        if ($error !== null) {
            $details = $error->getDetails();
            $fail("The {$attribute} is not valid JSON (line {$details['line']}).");
        }
    }
}

Note that the user-facing message is deliberately paraphrased, not the raw getMessage(). See the gotchas below.

Duplicate-key handling as a product decision

DETECT_KEY_CONFLICTS throws DuplicateKeyException; ALLOW_DUPLICATE_KEYS renames the second foo to foo.2; ALLOW_DUPLICATE_KEYS_TO_ARRAY collects all values under __duplicates__. These are three different product behaviours, so pick one per surface and document it:

use Seld\JsonLint\DuplicateKeyException;

try {
    $data = $inspector->decode($raw, JsonParser::DETECT_KEY_CONFLICTS);
} catch (DuplicateKeyException $e) {
    $d = $e->getDetails();
    Log::warning('Duplicate JSON key rejected', [
        'key'  => $d['key'],
        'line' => $d['line'],
        'file' => $source,
    ]);
    throw ValidationException::withMessages([
        'payload' => "Duplicate key \"{$d['key']}\" at line {$d['line']}.",
    ]);
}

For user-supplied webhook/config payloads, DETECT_KEY_CONFLICTS is the safer default — silently keeping the last value is how people get bitten. For developer-editable files where you want to be forgiving, ALLOW_DUPLICATE_KEYS_TO_ARRAY preserves everything.

Artisan command for JSON files in the repo

Translation files, fixtures, and seeded config are the most common source of "works locally, breaks in prod" JSON errors — usually a trailing comma or a BOM. A cheap guard:

class LintJsonCommand extends Command
{
    protected $signature = 'json:lint {paths* : Files or directories} {--q|quiet-ok}';

    public function handle(JsonParser $parser): int
    {
        $failures = 0;

        foreach ($this->resolveFiles($this->argument('paths')) as $path) {
            $error = $parser->lint(
                file_get_contents($path),
                JsonParser::VALIDATE_UTF8_ENCODING | JsonParser::ALLOW_COMMENTS
            );

            if ($error !== null) {
                $failures++;
                $this->error($path . ': ' . $this->firstLine($error));
            }
        }

        return $failures === 0 ? self::SUCCESS : self::FAILURE;
    }
}

Then php artisan json:lint lang resources/data becomes a pre-commit hook or a CI step. Pair it with vendor/bin/jsonlint for third-party files you don't control.

Comment-tolerant config files

If you want annotated JSON (a config/*.json a human edits, a settings blob with // notes), ALLOW_COMMENTS is the flag. Be explicit that this is not strict JSON:

$parser->parse($contents, JsonParser::ALLOW_COMMENTS | JsonParser::PARSE_TO_ASSOC);

Document it as a deliberate deviation with an owner — otherwise someone will POST comment-laden JSON to an endpoint that uses json_decode and get a confusing failure.

Standalone UTF-8 validation

Utf8Validator is useful outside JSON entirely — CSV imports, XML-ish blobs, log lines, anything where the DB or a downstream service chokes on malformed bytes:

use Seld\JsonLint\Utf8Validator;
use Seld\JsonLint\InvalidEncodingException;

try {
    Utf8Validator::validate($raw); // void on success
} catch (InvalidEncodingException $e) {
    Log::warning('Malformed UTF-8 upload', $e->getDetails());
    // reject with a clear message rather than letting the DB throw later
}

This is often a better first integration than JSON linting, because encoding failures typically surface as cryptic downstream errors far from the upload site.

Parsing composer.json in CI

The CLI binary's native habitat. Add to your pipeline:

- run: composer validate --strict
- run: vendor/bin/jsonlint -q composer.json package.json

-q suppresses output and relies on the exit code, which is exactly what CI wants.

Gotchas and Tips

lint() returns null for valid JSON, not true. A truthiness check (if ($parser->lint($json))) will invert your logic. Always use !== null or a dedicated isValid() helper.

This is not a decoder replacement. Restating because it's the most likely production mistake: the README says outright it cannot be as fast as json_decode. On a busy endpoint, a full parse per request is measurable latency and CPU. Mitigate with a review rule and, ideally, a static-analysis rule banning JsonParser outside the wrapper service and known fallback sites. The failure mode is invisible — no exception, just slower responses.

parse() output differs from json_decode() in one important respect: it throws. That means you need try/catch around it in places where json_decode returned null and you carried on. Retrofitting can turn previously-silent failures into 500s. Wrap at the boundary and convert to a validation error, not an unhandled exception.

PARSE_TO_ASSOC must be passed explicitly — it does not inherit from json_decode($json, true). If your code expects arrays and you forget the flag, you get an stdClass and a fatal "Cannot use object as array" a few frames later. Put the flag in one place (the wrapper/config) rather than repeating it.

Error messages contain fragments of the input. A past release (1.10.2) specifically reduced data leakage when the input clearly isn't JSON — only the first character is shown now — but messages still include surrounding context. Treat ParsingException::getMessage() as untrusted output: never echo it raw to a browser, cap its length, and prefer a paraphrased message plus getDetails() for structured fields. For anything sensitive (API keys in a config upload, PII in a webhook body), log the line/column only, or redact before logging. Note getDetails() also carries the payload context internally — sanitize before it hits a log aggregator.

ALLOW_COMMENTS produces JSON that json_decode will reject. If you lint with the flag but decode with json_decode, you'll pass validation and fail at decode time. Either use parse() consistently for those files, or strip comments in a preprocessing step. Don't mix.

The caret pointer line assumes a display. In Slack, email, or a <pre> with wrapping, the caret alignment breaks and the message looks wrong. For anything but a developer console, render line and column from getDetails() as separate values instead of the ASCII block.

getDetails() is an array with varying keys by exception type. DuplicateKeyException gives you key and line; parse errors give line/column/expected tokens; InvalidEncodingException gives byte position. Don't assume a uniform shape — guard with ??.

The README's "PHP 5.3+" claim is documentation lag. Check the actual composer.json constraint before pinning a support matrix, especially if you're maintaining a legacy Laravel version.

"Dependents: 0" is a metadata artifact. Composer itself uses this library — that's the strongest production reference available for a package of this size. Don't let the number drive a build-vs-buy call.

Single-maintainer project with low churn. Long track record, few regressions, but slow response if an edge case bites you. The wrapper service above is your insurance: if you ever need to swap the parser, one file changes. That's cheap insulation and worth doing on the first integration rather than retrofitting.

Duplicate keys are legal JSON per the spec but almost never intended. Most parsers (including json_decode) silently keep the last value. If you only add jsonlint for DETECT_KEY_CONFLICTS, you've added real correctness value beyond prettier errors — lead with that framing when justifying the dependency.

Large payloads and pathological strings. Older releases fixed backtracking issues on long strings with many escapes and a segfault on large inputs, so pin a reasonably current version (^1.12 gives you VALIDATE_UTF8_ENCODING, Utf8Validator, and the CLI flag support). Also set a payload-size ceiling before you lint anything user-supplied; there's no point parsing a 50 MB body to tell someone about a missing brace.

Extension points are limited by design. No service provider, no config, no events. Your extensibility comes from wrapping — a service, a validation rule, an Artisan command. Resist the urge to build a package around a package; the wrapper plus config array is the right size.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table