PHPackages                             prometa/laravel-lucene - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Search &amp; Filtering](/categories/search)
4. /
5. prometa/laravel-lucene

ActiveLibrary[Search &amp; Filtering](/categories/search)

prometa/laravel-lucene
======================

Pure-PHP Lucene query parser with a Laravel/Eloquent adapter that compiles Lucene queries into safe, parameterized SQL.

00PHP

Since Jun 18Pushed todayCompare

[ Source](https://github.com/PROMETA-at/laravel-lucene)[ Packagist](https://packagist.org/packages/prometa/laravel-lucene)[ RSS](/packages/prometa-laravel-lucene/feed)WikiDiscussions main Synced today

READMEChangelogDependenciesVersions (1)Used By (0)

Laravel Lucene
==============

[](#laravel-lucene)

A pure-PHP parser for the **Lucene** query syntax, plus a Laravel adapter that compiles a Lucene query string straight into a **safe, parameterized SQL `WHERE`** on your existing Eloquent models — no separate search index, no sync, no infrastructure.

```
Article::query()
    ->where('active', true)
    ->whereMatch('(title:laravel OR body:lucene) AND -status:archived')
    ->orderBy('published_at')
    ->paginate();
```

Every Lucene group becomes one nested `where(fn …)` closure, every value is a PDO binding, and every column is a whitelisted identifier — so you can hand it an **untrusted end-user search string** without opening a SQL-injection hole.

Why this exists
---------------

[](#why-this-exists)

The PHP ecosystem has plenty of Lucene query *builders* and several "Laravel + Lucene" packages — but they all bolt on a separate ZendSearch/Elasticsearch index you have to populate and keep in sync. None of them compile a Lucene string directly into a `WHERE` clause against the columns you already have. This package does exactly that.

Requirements
------------

[](#requirements)

- PHP `^8.3`
- Laravel `^12.0 | ^13.0` (for the Eloquent adapter; the parser core is framework-agnostic)

Installation
------------

[](#installation)

```
composer require prometa/laravel-lucene
```

The service provider is auto-discovered. Publish the config if you want to tweak the defaults:

```
php artisan vendor:publish --tag=lucene-config
```

Quick start
-----------

[](#quick-start)

Declare the searchable surface of a model with the `Searchable` trait and a `$lucene` array, then call `whereMatch()`:

```
use Illuminate\Database\Eloquent\Model;
use Prometa\Lucene\Laravel\Concerns\Searchable;

class Article extends Model
{
    use Searchable;

    protected array $lucene = [
        'fields' => [
            'title'        => 'text',                 // LIKE %value%
            'body'         => 'text',
            'status'       => 'exact',                // = value
            'views'        => 'number',               // numeric ranges / compare
            'published_at' => 'date',                 // date ranges
            'author'       => 'relation:author.name', // whereHas('author', name LIKE …)
        ],
        'default'  => ['title', 'body'],              // fields searched for a bare term
        'operator' => 'or',                           // how bare clauses combine (or | and)
    ];
}
```

```
Article::query()->whereMatch('title:hobbit')->get();
Article::query()->whereMatch('status:published AND views:[1000 TO *]')->get();
Article::query()->whereMatch('author:tolkien -status:draft')->get();
Article::query()->where('featured', true)->orWhereMatch('"the shining"')->get();
```

`whereLucene()` / `orWhereLucene()` are identical aliases if you prefer to name the backing technology at the call site.

### Without the trait

[](#without-the-trait)

Pass a schema (array or fluent `Schema`) per call — works on plain query builders too:

```
use Prometa\Lucene\Schema;

DB::table('articles')->whereMatch('title:hobbit', [
    'fields'  => ['title' => 'text'],
    'default' => ['title'],
])->count();

$schema = Schema::make()
    ->text('title', 'body')
    ->exact('status')
    ->number('views')
    ->relation('author', 'author.name')
    ->defaultField('title', 'body')
    ->defaultOperator('or');

Article::query()->whereMatch('title:foo', $schema)->get();
```

Supported syntax
----------------

[](#supported-syntax)

LuceneExampleCompiles toTerm`title:hello``title LIKE '%hello%'` (text) / `= 'hello'` (exact)Phrase`title:"pink panther"``title LIKE '%pink panther%'`Wildcards`title:te?t*``LIKE 'te_t%'` (`?`→`_`, `*`→`%`)Boolean`a AND b`, `a OR b`, `a b`nested `where` / `orWhere` groupsRequired / prohibited`+a -b``a` required, `NOT b``NOT``a NOT b``a AND NOT b`Grouping`(a OR b) AND c`parenthesised nested groupsField grouping`title:(a OR b)`both clauses scoped to `title`Inclusive range`views:[10 TO 50]``BETWEEN 10 AND 50`Exclusive range`views:{10 TO 50}``> 10 AND < 50`Mixed range`views:[10 TO 50}``>= 10 AND < 50`Open range`views:[100 TO *]``>= 100`Existence`title:*``title IS NOT NULL`Match all`*`no constraint (matches all rows)Escaping`title:foo\:bar`literal `foo:bar`### Field types

[](#field-types)

TypeBehaviour`text`case-insensitive substring `LIKE` (default for bare terms)`exact`strict equality`number`numeric coercion; equality and ranges`date` / `datetime`parsed via Carbon; equality and ranges`boolean`maps `true/1/yes/on` ⇒ truthy`relation:rel.column`matched through `whereHas('rel', column LIKE …)` — **Eloquent builders only**Aliasing a field to a differently-named column: `'name' => 'text:full_name'`.

### Nested relations

[](#nested-relations)

A relation path may traverse **multiple** hops. The **last** dot-segment is always the column; everything before it is the (possibly nested) Eloquent relation passed to `whereHas`, which accepts a dotted nested relation natively:

```
protected array $lucene = [
    'fields' => [
        // User → contact (belongsTo) → emails (hasMany) → column `email`
        'contact_email' => 'relation:contact.emails.email',
    ],
];
```

```
User::query()->whereMatch('contact_email:acme')->get();   // fielded
User::query()->whereMatch('acme')->get();                 // bare, if listed in `default`
```

So `relation:contact.emails.email` compiles to `whereHas('contact.emails', email LIKE …)`.

### Expression fields

[](#expression-fields)

Match against a developer-authored **SQL expression** instead of a single column — e.g. a full name spanning two columns. The user's term is still a bound parameter; only the static expression is raw (same trust level as naming a column). Expression fields are **text-matching only** (no numeric/date coercion or ranges).

Array form (recommended — raw SQL doesn't fit the `type:spec` string DSL):

```
'fields' => [
    'full_name'    => ['type' => 'expression', 'sql' => "CONCAT(name, ' ', family_name)"],
    // behind a relation — evaluated inside whereHas('contact'):
    'contact_name' => ['type' => 'expression', 'relation' => 'contact', 'sql' => "CONCAT(name, ' ', family_name)"],
],
```

String form (base table only — a relation expression must use the array form):

```
'fields' => ['full_name' => "expression:CONCAT(name, ' ', family_name)"],
```

Or fluently:

```
Schema::make()
    ->expression('full_name', "CONCAT(name, ' ', family_name)")
    ->expression('contact_name', "CONCAT(name, ' ', family_name)", 'contact');
```

> **The expression is your raw SQL**, so it is database-dialect-specific (`CONCAT(...)` on MySQL/PostgreSQL, `a || b` on SQLite). It is static schema config — **never interpolate user input into it.** The searched term is always bound separately.

### Composite fields

[](#composite-fields)

Declare one Lucene name that fans out to **several** targets, matched with OR. A member can be a plain column, a (nested) relation, or an expression — anything a single field can be. List the members as a **sequential array**:

```
'fields' => [
    'email' => [                            // matches EITHER target (OR)
        'text:email',                       //   users.email
        'relation:contact.emails.email',    //   contact emails (nested)
    ],
],
'default' => ['email'],                     // composites participate in bare-term search too
```

Or fluently:

```
Schema::make()->composite('email', 'text:email', 'relation:contact.emails.email');
```

A term on `email` — whether `email:foo` or a bare `foo` via `default` — matches if **any** member matches. Composites don't nest. (An *associative* array, `['type' => …]`, is a single field; only a sequential list is a composite.)

### Notes &amp; limitations

[](#notes--limitations)

- **Relation fields need an Eloquent builder.** `whereHas` requires Eloquent's relation metadata, so a relation field on a plain `DB::table(...)` query or via `Lucene::toSql()` throws `UnsupportedFeatureException` rather than miscompiling. Use `Model::query()->whereMatch(...)`.
- **Date values should be ISO-formatted** (`2020-01-01`). Bounds are parsed with `Carbon::parse`, which is lenient: a bare year like `2020` is read as a *time* (today 20:20), and relative words (`tomorrow`) evaluate against the wall clock. Prefer explicit dates.
- **A range on a `text` field is a lexical comparison** (`BETWEEN`/`>=`), not a `LIKE`, and depends on the column collation — usually you want ranges on `number`/`date` fields.
- **Keep `max_depth` modest.** It guards parser recursion; setting it to many thousands re-opens the risk of a C-stack overflow when PHP destroys a very deeply-nested tree.

Features without a SQL equivalent
---------------------------------

[](#features-without-a-sql-equivalent)

Some Lucene features cannot be faithfully expressed in SQL `WHERE`. Their handling is configurable via `lucene.unsupported` (`throw` | `ignore` | `best_effort`, default `best_effort`):

Feature`best_effort` behaviourFuzzy `roam~2`substring `LIKE` (fuzziness dropped)Proximity `"a b"~5`substring `LIKE` on the phrase (slop dropped)Regex `/jo.*n/`driver-native operator (`REGEXP` / `~`); else throwsBoost `term^4`**always** stripped — SQL has no relevance scoring (`lucene.boost`)Leading wildcard `*foo`rejected by default (`lucene.leading_wildcard`); not sargableStandalone parser
-----------------

[](#standalone-parser)

The `Lucene` facade exposes the framework-agnostic core for inspection and one-off use:

```
use Prometa\Lucene\Laravel\Facades\Lucene;

$ast = Lucene::parse('title:foo AND bar~2');              // immutable AST
echo Lucene::explain('a OR (b AND -c)');                  // human-readable tree
['sql' => $sql, 'bindings' => $b] = Lucene::toSql('title:foo', $schema);
```

Configuration
-------------

[](#configuration)

`config/lucene.php`:

KeyDefaultPurpose`default_operator``or`how bare adjacent clauses combine`case_insensitive``true``ILIKE` on Postgres; collation elsewhere`leading_wildcard``forbid``forbid` | `allow``unsupported``best_effort`fuzzy / proximity / regex policy`boost``ignore``ignore` | `throw``escape_char``\``LIKE` escape character`max_depth` / `max_clauses``100` / `1024`guardrails against pathological inputA model's `$lucene['operator']` overrides `default_operator` for that model.

Security
--------

[](#security)

- **Default-deny fields.** A field is searchable only if declared in the schema; anything else throws `UnknownFieldException`. User input never becomes an arbitrary column name.
- **Always parameterized.** Term/phrase/range/wildcard/regex literals are bound parameters, never concatenated into SQL.
- **Explicit `ESCAPE`.** Every `LIKE` is emitted with an explicit, driver-correct `ESCAPE` clause, and user-typed `%`/`_` are neutralised before wildcard substitution — so `title:50%` matches the literal text, not everything.
- **Expression fields are developer SQL.** The only raw-SQL surface is the static expression string you author in the schema (same trust level as a column name). It must never embed user input; the searched term is always bound separately.
- **Guardrails.** `max_depth` and `max_clauses` bound the compiled query so a hostile string can't explode it.

Errors
------

[](#errors)

All exceptions extend `Prometa\Lucene\Exceptions\LuceneException`:

- `LuceneParseException` — invalid syntax (carries the offset and a caret snippet)
- `UnknownFieldException` — a field not declared in the schema
- `UnsupportedFeatureException` — an un-SQL-able feature under a `throw` policy

A note on precedence
--------------------

[](#a-note-on-precedence)

Lucene's classic parser has a famously quirky, non-associative precedence when `AND`/`OR`/`NOT` are mixed without parentheses. This package instead uses a clean, predictable precedence — term modifiers bind tightest, then `+`/`-`/`NOT`, then `AND`, then `OR`/juxtaposition — which is what you almost always want for a SQL filter. As in Lucene, use parentheses when you want to be unambiguous.

License
-------

[](#license)

MIT.

###  Health Score

21

—

LowBetter than 18% of packages

Maintenance65

Regular maintenance activity

Popularity0

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 75% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/28981?v=4)[PROMETA](/maintainers/PROMETA)[@prometa](https://github.com/prometa)

---

Top Contributors

[![RianFuro](https://avatars.githubusercontent.com/u/10298987?v=4)](https://github.com/RianFuro "RianFuro (3 commits)")[![PROMETA-Development](https://avatars.githubusercontent.com/u/257473180?v=4)](https://github.com/PROMETA-Development "PROMETA-Development (1 commits)")

### Embed Badge

![Health badge](/badges/prometa-laravel-lucene/health.svg)

```
[![Health](https://phpackages.com/badges/prometa-laravel-lucene/health.svg)](https://phpackages.com/packages/prometa-laravel-lucene)
```

###  Alternatives

[shyim/opensearch-php-dsl

OpenSearch/Elasticsearch DSL library

186.7M12](/packages/shyim-opensearch-php-dsl)[awesome-nova/dependent-filter

Dependent filters for Laravel Nova

26190.2k](/packages/awesome-nova-dependent-filter)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
