exyplis/eloquent-builder-macros
This Laravel package adds reusable macros to Eloquent’s query builder, making common filtering, ordering, and query-building tasks shorter and more expressive. Keep models and repositories cleaner by replacing repetitive query code with handy, chainable macros.
Install and verify registration
composer require exyplis/eloquent-builder-macros
The package ships a service provider that is auto-discovered on Laravel 5.5+. It registers macros onto Illuminate\Database\Eloquent\Builder (and in most cases the underlying Illuminate\Database\Query\Builder). No config, no migrations, no publishing step.
Where to look first
vendor/exyplis/eloquent-builder-macros/src — the provider and the macro definitions are the actual documentation. With a package this small there is no manual; read the Builder::macro(...) closures directly to learn exact signatures.foreach (['whereLike', 'orWhereLike', 'whereStartsWith', 'whereEndsWith', 'whereDateBetween'] as $m) {
dump($m, \Illuminate\Database\Eloquent\Builder::hasMacro($m));
}
First real use case
The archetypal win is a search endpoint. Instead of scattering wildcards through controllers:
// Before
$query->where('name', 'like', "%{$term}%")
->orWhere('email', 'like', "%{$term}%");
// After
User::query()->whereLike(['name', 'email'], $term)->get();
Start by replacing one repetitive where(... 'like', "%...%") clause in a scope, confirm the generated SQL via ->toSql(), then expand.
Search and filter scopes
Wrap macros in local scopes so the macro never leaks into controllers and so call sites stay refactorable:
public function scopeSearch(Builder $q, ?string $term): Builder
{
return $q->when(
filled($term),
fn (Builder $q) => $q->whereLike(['name', 'email', 'company'], $term)
);
}
This composes cleanly with when() and keeps the "macro exists" dependency at one layer.
Layering conditional filters
The natural workflow is: query builder macros handle predicate construction, when() handles presence, and scopes handle reuse.
Post::query()
->when($request->q, fn ($q, $v) => $q->whereLike('title', $v))
->when($request->from, fn ($q, $v) => $q->where('created_at', '>=', $v))
->when($request->from && $request->to, fn ($q) => $q->whereDateBetween('created_at', $request->from, $request->to))
->paginate();
Integrating into existing query objects
Macros return the builder, so they slot into any chain, including inside whereHas closures and relationship constraints:
User::whereHas('posts', fn ($q) => $q->whereLike('title', $term))->get();
Because macros are bound to the builder instance, $this inside the closure is the builder itself — a closure written as fn ($col, $v) => $this->where(...) works, but prefer the explicit fn (Builder $b, ...) parameter form for readability.
Testing
Assert on SQL rather than results — it isolates the macro contract from database state:
$sql = User::whereLike('name', 'foo')->toSql();
$this->assertStringContainsString('"name" like ?', $sql);
If you depend on a specific macro set across many tests, add a single smoke test asserting Builder::hasMacro('whereLike') so a package upgrade that renames or drops a macro fails loudly in CI rather than at runtime.
Vendoring as an escape hatch
With zero dependents and no release history to speak of, a defensible pattern for anything load-bearing is to copy the macro closures into a AppServiceProvider::boot() or a dedicated QueryMacros class. You lose updates you were unlikely to get, and you gain the ability to add types, tests, and IDE docblocks.
Macros are global and collision-prone
Registering whereLike overrides any other package or app-level macro with the same name, silently and load-order-dependently. Check Builder::hasMacro('name') before defining your own, and audit composer show for other macro packages before introducing this one.
Query vs. Eloquent builder
A macro registered only on Illuminate\Database\Eloquent\Builder will not be available inside DB::table(...) chains or in some raw query contexts. If a macro seems to "not exist" in one place and work in another, this is almost always why. Register on both classes if you need parity.
like wildcards and index usage
Any macro that prepends % to the term disables B-tree index usage. On tables beyond a few hundred thousand rows, switch to a full-text index or whereFullText, or accept the scan knowingly. Also confirm whether the package escapes % and _ in the search term — user input containing % otherwise becomes a wildcard and can return the whole table.
Case sensitivity is a collation concern, not a macro concern
whereLike does not normalise case. On MySQL with utf8mb4_general_ci it's case-insensitive; on Postgres it is case-sensitive and ILIKE is what you want. Verify what the macro emits per driver — a package written against MySQL defaults may produce surprise behaviour on Postgres or SQLite.
SQL injection surface
Macros built with DB::raw("... $value ...") interpolation are injectable even though they look like builder methods. Read the closure bodies before trusting a macro with request input; prefer ones that pass values as bindings.
Static analysis and IDE support
Macros are invisible to PHPStan/Psalm and to IDE autocompletion — User::whereLike() will be flagged as an undefined method. Options: add a @method docblock to a base model or a _ide_helpers stub, install barryvdh/laravel-ide-helper, or write a PHPStan stub. Expect to do one of these on day one or your static analysis baseline will regress.
Extension points are thin
There is no config file, no driver abstraction, and no event system — the only extension point is Builder::macro() itself. If you need a variant (say a whereLike that lowercases), define a new macro name rather than trying to monkey-patch, and keep it in your own provider so it survives a composer update.
Version pinning
Given the package's maturity, pin an exact version ("exyplis/eloquent-builder-macros": "1.0.*") and commit the lockfile. A patch release that changes a macro's argument order or adds a default % wrapper would otherwise reach production untested.
Debugging checklist
When a macro misbehaves: ->toSql() with ->getBindings() reveals exactly what was built; Builder::hasMacro() confirms registration; app()->getLoadedProviders() confirms the service provider booted (relevant if you disabled package discovery); and a DB::listen() dump confirms the macro ran at all versus being short-circuited by a when() returning false.
How can I help you explore Laravel packages today?