nahid/jsonq
JsonQ is a PHP library for querying JSON data with a fluent, jq-like API. Traverse nested objects and arrays, filter with where, sort, select, and extract values without manual array handling. Ideal for APIs, config files, and dynamic JSON.
Install with Composer:
composer require nahid/jsonq
Point it at a JSON file and query it like a read-only table:
use Nahid\JsonQ\Jsonq;
$jsonq = new Jsonq(storage_path('app/exports/catalog.json'));
$products = $jsonq->from('products') // dot-free node path inside the document
->where('price', '<=', 100)
->where('stock.available', true) // dot notation for nested keys
->orderBy('price', 'asc')
->take(5)
->get(); // array of matching rows
Where to look first
where, orWhere, whereIn, whereBetween, whereNull, whereStartsWith, whereContains, whereMatch, groupBy, orderBy, pluck, sum, avg, chunk, first, find your installed version actually ships.src/Jsonq.php — the operator switch inside the comparison method is the authoritative list; README and code drift on alias operators (eq, neq, gt, gte, lt, lte, notin, …).tests/ — fixtures plus assertions show the exact return shapes (array vs Collection) for get(), first(), and aggregate calls.First real use case: reference data you don't want to model in a table — country/currency lists, a vendor's exported price list, seed data for a demo, or an API dump you're exploring before designing migrations. It's also handy in one-off Artisan commands and data-migration scripts where json_decode + manual foreach would otherwise sprawl.
Wrap it in a repository, bind it once. The query object is stateful and the file decode is expensive, so keep one per request behind a small class:
final class CatalogRepository
{
private ?Jsonq $jsonq = null;
public function __construct(private string $path) {}
private function q(): Jsonq
{
return $this->jsonq ??= new Jsonq($this->path);
}
public function cheapProducts(int $max = 100): array
{
return $this->q()->from('products')
->where('price', '<=', $max)
->get();
}
}
Bind it in a service provider with storage_path('app/exports/catalog.json') so every caller uses an absolute path.
Whitelist request-driven filters. This is the pattern that shows up in admin screens over JSON exports:
$allowed = ['price', 'category', 'stock.available'];
$q = (new Jsonq($path))->from('products');
foreach ($request->only($allowed) as $field => $value) {
$q->where($field, $value);
}
return response()->json($q->take(50)->get());
Aggregate instead of hydrating. If you only need a number, don't get() and loop — ->sum('total'), ->avg('price'), ->max('created_at'), ->count() keep the code short and avoid materializing a big array in your own scope.
Group and reduce with Collections. Mixing the two is the pragmatic sweet spot:
$byCategory = collect($jsonq->from('products')->get())
->groupBy('category')
->map(fn ($rows) => collect($rows)->sum('price'));
Chunk when iterating. ->chunk($size, $callback) (or collect($q->get())->chunk()) keeps your processing loop flat when you're writing rows to the DB or pushing to a queue. Note it still holds the whole document in memory — the win is in your own code, not the library.
Pipe remote payloads through disk. Fetch with the HTTP client, json_decode once to validate, write to storage_path('app/tmp/'.Str::uuid().'.json'), then query that file. Unique filenames avoid concurrent-request races; clean up in a finally.
Test with fixture files. new Jsonq(__DIR__.'/Fixtures/catalog.json') in a unit test is faster and more deterministic than mocking the filesystem, and it catches version-specific return-shape surprises. Wrap get() in collect() in the assertion if the shape ever changes.
Eloquent-adjacent use. The package ships a JsonQueriable trait in some versions for querying a JSON column/attribute with the same fluent API — check whether your locked version includes it before designing around it.
php artisan serve fails in a queued job or scheduler. Always use storage_path()/base_path(). A missing file throws at construction, so validate in your repository constructor or a config check."1" (string) will not match where('id', 1) (int). Empty result sets from a type mismatch are the number-one source of "the package is broken" reports — cast inputs and confirm the JSON's actual types with json_decode first.Jsonq object across two queries leaks the first query's where clauses into the second. Build one chain per query, or call from() again to reset — and don't share an instance across a loop without verifying reset behavior in your version.LIKE. Use whereContains, whereStartsWith, whereEndsWith, or whereMatch with a full regex including delimiters ('/^foo/i'). Passing SQL-style %foo% silently matches nothing.whereNull in an orWhere chain, or post-filter the full result set.orWhere chains evaluate left-to-right with no parentheses, so A AND (B OR C) requires two queries and a manual merge/unique().first() returns null on no match — guard before array access; chaining onto a null result raises NullValueException."user.name" will be traversed as two levels. Rename such keys on ingest or use Illuminate\Support\Arr::get for those fields.get(), others Illuminate\Support\Collection. Normalize at the repository boundary with collect()/->toArray() so a version bump doesn't ripple through controllers.Cache::remember keyed on file mtime) or move to SQLite with json_extract. Instantiating new Jsonq(...) per query re-reads the file every time.composer.lock. If you need PHP-version guarantees long-term, budget for a fork — the surface area is small and readable.toSql(). Build the chain into a variable, then dump() after each step (or ->count() to check a filter narrowed as expected) to isolate which condition zeroes the result. If the answer is [], check path/node existence, key type, and operator alias in that order.take() and arbitrary field names from request params are still a resource and information-disclosure problem — whitelist both.How can I help you explore Laravel packages today?