owlcorp/cli-json-lint
A command-line JSON linter for PHP and Laravel projects that validates JSON files, catches syntax errors, and reports clear, actionable output. Add it to Composer scripts, local workflows, or CI pipelines to keep configuration files reliable and consistently checked.
This is a CLI binary, not a Laravel package. There is no service provider, no config file to publish, no facade, and no container binding. Don't spend time looking for config/json-lint.php or an Artisan command — you invoke it as a process, the same way you'd invoke phpunit or php-cs-fixer.
Minimal setup:
composer require --dev owlcorp/cli-json-lint
Then find the binary Composer actually installed. The bin entry is the source of truth, not the README:
composer show owlcorp/cli-json-lint # note the version/tag you got
ls vendor/bin | grep -i json # confirm the executable name
vendor/bin/<binary> --help # read the real flags, output format, exit codes
Read --help first, the README second — on a low-star package the help output is usually the most current documentation, and the README tends to lag the flags.
First use case: lint the two JSON surfaces that actually break a Laravel app.
lang/*.json — JSON translation files are the classic footgun. They get edited by translators, by export tooling, or by hand, and a single trailing comma or smart quote makes __('key') throw or silently fall back at runtime. Nothing in Laravel validates these for you.composer.json — a broken one breaks installs and CI for the whole team.vendor/bin/<binary> lang composer.json
echo $? # confirm it's non-zero on failure before you wire it into anything
That's the whole smoke test. If the exit code behaviour is right, everything else is plumbing.
Pattern 1 — Composer script, with a guard. Convenient, but see the --no-dev gotcha below. Keep it out of install lifecycle hooks; make it an explicit target:
"scripts": {
"lint:json": "vendor/bin/<binary> lang composer.json"
}
For a real file list, a shell script beats a Composer script — Composer scripts handle argument passing and globs badly.
Pattern 2 — Makefile / Taskfile target. This is the pattern I'd actually recommend, because it gives you the swap-in-one-line escape hatch:
lint-json:
@git ls-files -z '*.json' | grep -zv -E '^(vendor|node_modules)/' | xargs -0 vendor/bin/<binary>
lint-json-staged:
@git diff --cached --name-only --diff-filter=ACM -z -- '*.json' \
| grep -zv -E '^(vendor|node_modules)/' \
| xargs -0 -r vendor/bin/<binary>
Using git ls-files / git diff --cached rather than a directory walk is the important part: it gets you the ignore rules for free and dodges the vendor/node_modules problem entirely.
Pattern 3 — Pre-commit hook. Fast, local, catches the mistake before review:
#!/bin/sh
set -e
vendor/bin/<binary> $(git diff --cached --name-only --diff-filter=ACM -- '*.json')
Note the unquoted expansion — fine for paths without spaces, broken for paths with them. Use the -z/xargs -0 variant above if your repo has any. If you already run CaptainHook or GrumPHP (common in Laravel codebases), register this as a task there rather than hand-rolling .git/hooks/, so the hook is versioned and shareable.
Pattern 4 — CI gate. One step, non-zero exit does the work:
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/<binary> lang composer.json
For monorepos or large translation sets, lint only changed files on PRs and the full set on main:
- run: |
files=$(git diff --name-only origin/main... -- '*.json' | grep -v -E '^(vendor|node_modules)/' || true)
[ -z "$files" ] || vendor/bin/<binary> $files
Pattern 5 — the build-it-yourself alternative (worth knowing before you adopt). In a Laravel app you can cover the highest-value case in a Pest test with zero dependencies:
it('ships valid JSON translation files', function () {
foreach (File::glob(lang_path('*.json')) as $file) {
expect(fn () => json_decode(File::get($file), true, 512, JSON_THROW_ON_ERROR))
->not->toThrow(JsonException::class);
}
});
If that's all you need, this package doesn't earn its place. It earns it when you want a shell-level tool that also covers composer.json, seeders, fixtures, Postman collections, and OpenAPI specs, with batch traversal and clean error output, without writing per-directory tests.
Where it fits vs. where it doesn't:
| Good fit | Wrong tool |
|---|---|
Pre-commit/CI syntax gate on *.json |
Schema/semantic validation (use composer validate, an OpenAPI validator) |
lang/*.json regression guard |
JSONC files (.vscode/, tsconfig.json) |
| Fixture/seeder JSON in tests | Anything you'd need to call from PHP at runtime |
composer install --no-dev deletes the binary. Never wire this into post-install-cmd or post-autoload-dump — production and CI-with---no-dev will hard-fail. Explicit composer lint:json only..vscode/settings.json, tsconfig.json, devcontainer.json, and some .eslintrc.json files legitimately allow comments and trailing commas. Whitelist the directories you care about (lang, database, tests/Fixtures) instead of linting "all JSON in the repo" — otherwise you'll get noise you can't fix and the check gets disabled within a week.git ls-files or an explicit path list.vendor/bin/<binary> lang/*.json with no matches passes the literal string lang/*.json to the binary, producing a confusing "file not found" instead of a clean skip. Use nullglob, or better, git ls-files -z.xargs swallows the exit code. It returns 123 if any child invocation fails, which is non-zero but not the tool's own code. If your CI logic inspects specific exit codes, call the binary directly rather than through xargs.lang/en.json fails but looks fine in your editor, check with xxd file.json | head -1 for efbbbf.composer.json constraint is invalid, or an OpenAPI document is structurally wrong. Don't let it become the reason nobody runs the tools that do.} or ], an unescaped " inside a translated string, a smart quote (" ") pasted from Word or a doc, and a merge conflict marker left in the file. All four are one-line fixes — run the tool on the single failing file to get a line number rather than re-linting the batch.require-dev, let composer.lock do its job, and keep the invocation behind one named target so swapping to jq empty, php -r 'json_decode(...)', or the Pest test above is a one-line change rather than a hunt through YAML.How can I help you explore Laravel packages today?