jord-jd/laravel-natural-where
Adds natural-language where methods to Laravel's query builder and Eloquent, so you can write readable filters like whereName(), whereAgeGreaterThan(), and whereCreatedAtAfter() instead of verbose operator-based clauses.
This package adds natural-language-style where macros to Eloquent/Query Builder, letting you express constraints as readable strings instead of long ->when() chains.
Install and verify
composer require <vendor>/<package> # ^3.1 for Laravel 9–13
composer.json with "dont-discover", add the provider manually.use Illuminate\Database\Eloquent\Builder;
// tinker
Builder::hasMacro('naturalWhere'); // true
Builder::hasMacro('naturalOrWhere'); // true
Where to look first
config/*.php (publish with php artisan vendor:publish --tag=...) — parser settings live here.@method docblocks if the package ships them, so your IDE autocompletes.First use case: collapsing request filters
The classic thing this replaces:
// before
User::query()
->when($request->filled('age'), fn ($q) => $q->where('age', '>', $request->age))
->when($request->filled('country'), fn ($q) => $q->where('country', $request->country))
->get();
// after
User::naturalWhere('age > 30 and country = NL')->get();
Start by converting one read-only listing endpoint (admin index, report, search) and leave writes/authorization-sensitive queries alone until you trust the parser.
Validate the string, not just the columns
Treat the natural-string as untrusted user input. Build it server-side from validated request data rather than passing $request->input('filter') straight through:
$validated = $request->validate([
'min_age' => ['nullable', 'integer', 'min:0'],
'country' => ['nullable', 'string', 'size:2'],
]);
$query = User::query()
->when($validated['min_age'] ?? null, fn ($q, $age) => $q->naturalWhere("age > {$age}"))
->when($validated['country'] ?? null, fn ($q, $c) => $q->naturalWhere("country = {$c}"));
Note the when callbacks return the builder — this only works cleanly because of the v3.1.0 fix that makes naturalOrWhere return the query builder result. On ≤3.0, when() callbacks ending in naturalOrWhere silently returned null and the constraint was dropped from the chain in some paths.
Compose OR groups safely
naturalWhere then naturalOrWhere at the same level produces flat where … or …, which leaks past earlier constraints. Wrap explicitly:
Post::where('tenant_id', $tenantId)
->where(function (Builder $q) {
$q->naturalWhere('status = published')
->naturalOrWhere('published_at < 2020-01-01');
})
->paginate();
Encapsulate in a scope or filter object
class User extends Model
{
public function scopeMatchingCriteria(Builder $q, array $criteria): Builder
{
return $q->naturalWhere($criteria['expression']);
}
}
Keep the string-building in one class so you can unit test it, and snapshot the resulting SQL with ->toSql() / ->getBindings().
Pair with pagination, sorting, and resources
Because the macros return the builder (v3.1.0+), they slot into existing pipelines without special handling:
return UserResource::collection(
User::naturalWhere($expr)->orderBy('created_at', 'desc')->paginate(25)
);
Test matrix awareness
A single release supporting Laravel 5.6 through 13 means CI should cover at minimum your oldest and newest targets. If you maintain a package or a multi-app monorepo, run the suite against both — 5.6-era query builder lacks methods that modern Laravel has, and the package will be branching internally.
The v3.1.0 return-value fix has a subtle history. If you wrote workarounds in ≤3.0 (e.g. calling naturalOrWhere as a statement and re-chaining from the original variable, or avoiding it inside when() closures), revisit them — they're now redundant but harmless. Conversely, if any test asserted null or void from the macro, it will fail after upgrade. Grep your codebase for naturalOrWhere before bumping.
Laravel 5.6 / 8 / 13 in one line is unusual — verify your PHP constraint. Laravel 5.6 runs on PHP 7.1–7.4; Laravel 13 requires PHP 8.3+. The package's composer.json must express php and illuminate/* constraints loosely enough to resolve on both. If composer update fails, check composer why-not rather than pinning blindly. Also confirm the release is actually tested on 5.6 — "retained compatibility" in notes sometimes means "constraint widened, not CI-covered."
Column names are the injection surface. Value binding is usually handled; column/identifier interpolation often is not. Never let end users supply the left-hand side of an expression. If you must support dynamic sortable/filterable columns, whitelist them:
$allowed = ['name', 'email', 'created_at'];
abort_unless(in_array($column, $allowed, true), 422);
Parsing edge cases to test explicitly:
"name = John Doe") — quoting rules?2024-01-01 treated as a string or a carbon-parsable value?LIKE patterns and % wildcards — does the parser add them, or must you?null / IS NULL handling — field = null may render as = ? with a null binding and match nothing.active = true vs active = 1.Debugging. The parser can be opaque. Log the generated SQL and bindings in a local route or test:
$q = User::naturalWhere($expr);
dump($q->toSql(), $q->getBindings());
If the query looks right but returns nothing, it's almost always a hidden and group or a null/boolean comparison — not the macro registration.
Extension points. The service provider is the seam: if you need custom operators, different tokenization, or a naturalWhereIn, wrap the macro in your own builder macro registered in AppServiceProvider::boot() and delegate to the package. Avoid editing vendor code — you'll lose it on the next 3.x patch.
Version pinning. Given the fix landed as a patch (3.1.0 rather than 4.0), pin with ^3.1 so you get the return-value fix but not an unexpected 4.x parser rewrite. Run your filter tests against 3.1 before rolling to production, especially if you lean on naturalOrWhere inside conditional chains.
How can I help you explore Laravel packages today?