iwastenot / laravel-api-query-helper
Turn HTTP query strings into safe Eloquent queries - filtering, sorting, _q search, relation filters, and OR groups for Laravel APIs
Requires
- php: >=7.3
- illuminate/support: ^8.0|^9.0|^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^6.0|^7.0|^8.0|^9.0|^10.0
- phpunit/phpunit: ^9.6|^10.5|^11.5|^12.0
This package is not auto-updated.
Last update: 2026-08-12 14:16:06 UTC
README
Turn HTTP query strings into safe Eloquent queries for Laravel APIs
The iWasteNot API Query Helper transforms HTTP query parameters into robust Eloquent (or Laravel Scout) queries: filtering, sorting, field selection, eager loading, free-text search, and OR grouping for JSON APIs — whitelisted against your models, without repetitive controller code.
- Filtering —
price-gte=100,status=active, full operator suffix set - OR alternatives & groups —
title-lk=%a%|%b%,_or[0][title-lk]=%x%&_or[0][info-lk]=%x% - Relation filters —
category-name-lk=%chairs%constrains parent rows viawhereHas, nesting allowed - Sorting —
_sort=-created_at,category-name, including related columns - Search —
_q=term, or-matched across a model-declared$searchablefield list - Field selection & eager loading —
_fields=id,title+_with=photoswith per-relation filters
🧱 Core Concept
Write API endpoints like:
GET /api/items?_with=photos&_fields=id,title,price&_sort=-created_at&price-gte=100&status-eq=active
and get this automatically:
Item::with(['photos'])
->select(['id', 'title', 'price'])
->where('price', '>=', 100)
->where('status', '=', 'active')
->orderBy('created_at', 'desc')
->get();
All query parsing, validation, and recursion are handled internally.
✅ Compatibility
| Component | Supported versions |
|---|---|
| PHP | >= 7.3 |
| Laravel / Illuminate | 8.x, 9.x, 10.x, 11.x, 12.x |
Notes:
- When PHP is 7.3 or 7.4, only Laravel/Illuminate 8.x is supported.
⚡ Quick Start
composer require iwastenot/laravel-api-query-helper
Developing the package itself:
composer install --no-interaction --prefer-dist
vendor/bin/phpunit
🚀 Installation
composer require iwastenot/laravel-api-query-helper
Then import the helper where you build your API queries:
use IWasteNot\ApiQueryHelper\Services\ApiQueryHelper;
class ItemController extends Controller
{
public function index(Request $request, ApiQueryHelper $api)
{
$query = Item::query();
$results = $api->indexHelper($query, $request->all())
->paginate();
return new IndexCollection($results);
}
}
📚 Documentation
docs/tutorials/getting-started.md— walkthrough from model to requestdocs/how-to/filters.md— filter patterns + suffix listdocs/how-to/relations.md— nested_with,_fields,_sort, filtersdocs/how-to/scout.md— Scout search and_qdocs/ApiQueryHelper.md— reference for helper methodsdocs/ApiModel.md— reference for model helpersdocs/glossary.md— glossary of query termsdocs/faq.md— common questions and troubleshootingdocs/migration-notes.md— behavior changes between releasesdocs/developer-guide.md— contributor setup notes
⚙️ Supported Parameters
| Parameter | Type | Description |
|---|---|---|
_with | comma-separated list | Eager-load relationships (with()) |
_fields | comma-separated list | Restrict selected columns |
_sort | comma-separated list | Sort fields, prefix with - for descending; dotted paths (category-name) sort by related columns |
_q | string | Model-directed search across the model's $searchable fields |
_or[N][...] | bracketed groups | Or-groups: entries or-combine, groups and-combine with everything else |
| all others | filters | Translated into where*() clauses based on suffix |
🔎 Filter Syntax
Filters are derived from all parameters except _with, _fields, _sort,
_q, and _or. Keys that start with _ are ignored by the filter parser.
| Example | Effect | Method |
|---|---|---|
status-eq=active | status = 'active' | where() |
price-gte=100 | price >= 100 | where() |
deleted-null | deleted IS NULL | whereNull() |
deleted-not-null | deleted IS NOT NULL | whereNotNull() |
id-in=1,2,3 | id IN (1,2,3) | whereIn() |
role-not-in=admin,staff | role NOT IN (...) | whereNotIn() |
created-between=2024-01-01,2024-12-31 | Date range | whereBetween() |
gte=price,100 | price >= 100 | where() |
status=active | status = 'active' | where() |
title-lk=%a%\|%b% | title LIKE '%a%' OR title LIKE '%b%' | grouped orWhere() |
category-name-lk=%x% | parent rows with a matching category | whereHas() |
_or[0][a-lk]=%x%&_or[0][b-lk]=%y% | (a LIKE '%x%' OR b LIKE '%y%') | nested where() |
Supported suffixes
The helper supports these suffixes by default:
lk,like=>where ... likenot-lk,not-like=>where ... not likegte=>where ... >=lte=>where ... <=gt=>where ... >lt=>where ... <eq=>where ... =not,not-eq=>where ... !=null=>whereNullnot-null=>whereNotNullin=>whereInnot-in=>whereNotInbetween=>whereBetweennot-between=>whereNotBetweendate=>whereDateyear=>whereYearmonth=>whereMonthday=>whereDaytime=>whereTimecolumn=>whereColumn
For null / not-null, the value list is ignored and no parameters are passed
to the where method.
Suffixes and aliases are normalized via a configurable $suffixes map.
In the suffix-as-key form (for example, gte=price,100), the first value is
the field name and the remaining values are parameters for the where clause.
In the suffix-in-key form, hyphens in the field portion are interpreted as dots
for nested relations (for example, photos-filename-like=image targets
photos.filename).
🧮 Eloquent Integration
Models intended for API use should extend the provided ApiModel class.
Example
use IWasteNot\ApiQueryHelper\Models\ApiModel;
class Item extends ApiModel
{
protected $fillable = ['title', 'price', 'status'];
protected $joinable = ['photos', 'tags'];
protected $details = ['description', 'notes'];
public function photos() { return $this->hasMany(Photo::class); }
public function tags() { return $this->belongsToMany(Tag::class); }
}
This gives ApiQueryHelper introspection capabilities through:
getJoinable()— whitelist of relationships allowed in_withgetKeys()— all known safe fieldsgetIndexKeys()— indexable, visible subsetgetIndexAttributes()— filtered attributes for display
💡 Usage Examples
Basic filtering, sorting, and field selection
GET /api/items?_fields=id,title,price&_sort=-created_at&price-lte=100&status-eq=active
Item::select(['id','title','price'])
->where('price', '<=', 100)
->where('status', '=', 'active')
->orderBy('created_at', 'desc')
->get();
Eager loading and nested filtering
GET /api/items?_with=photos,tags&photos.filename-like=image
→ Loads related photos where the filename matches "image".
Nested relation fields and sorts
GET /api/items?_with=photos&photos._fields=id,filename&photos._sort=-created_at
→ Scopes _fields and _sort to the photos relation.
Full-text search with Laravel Scout
$results = $api->indexHelper(Item::class, ['_q' => 'hammer']);
When you call indexHelper() with a model class name, Scout mode is used only
when the params are limited to _q, per_page, and offset. The helper does
not apply pagination itself; those values are for your controller/paginator.
_q searches the fields that your Scout driver indexes for the model. Define
them by overriding toSearchableArray() and reindex after changes.
Custom prefiltering in models
public function indexHelperEloquent($query, $joins, $fields, $sorts, $filters)
{
$filters['status'][] = ['=', 'active'];
return [$query, $joins, $fields, $sorts, $filters];
}
See docs/ApiQueryHelper.md for extension-point details.
Show helper for single models
$item = Item::findOrFail($id);
$item = $api->showHelper($item, request()->all());
→ Loads _with relations without altering the base query.
🧩 Internal Structure
| File | Role |
|---|---|
ApiQueryHelper.php | Main query parser and orchestrator |
Arr.php | Array filtering utilities for dotted keys |
ApiModel.php | Base model with introspection hooks |
IndexCollection.php | (optional) Resource wrapper for paginated results |
🧪 Testing
vendor/bin/phpunit
⚠️ Limitations
- Scout filters are not whitelisted via
getKeys(). - Pagination is controlled by your controller (the helper does not paginate).
🩹 Troubleshooting
- If Scout returns no results, confirm the driver is configured and the model index is built/rebuilt.
- If filters are ignored, ensure the field is present in
getKeys(). - If nested filters do nothing, confirm the parent relation is in
_with.
Changelog
See CHANGELOG.md for release notes and historical changes.
📄 License
MIT. See LICENSE.