tlikai/laravel-repository
A Laravel package that adds a clean repository layer to your app, wrapping database queries behind interfaces for better separation of concerns, easier testing, and swappable data sources. Ships with base repository classes and service provider bindings for quick setup.
Install and wire up
composer require <vendor>/<package> # 1.0.5 or later — see the note below
php artisan vendor:publish --tag=<package>-config
Create a repository for the model you actually search on, bind it in a service provider, and inject the interface — never the concrete class — into your controllers/services.
class UserRepository extends BaseRepository implements UserRepositoryInterface
{
public function model(): string { return User::class; }
}
// AppServiceProvider::register()
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
Where to look first
src/Criteria/ — this is where the value is. FuzzySearch and AttributeSearch are the two you will use daily; read them before writing your own.pushCriteria() / popCriteria() / skipCriteria() / resetCriteria() methods — they define the entire lifecycle of a criterion (per-query, not per-instance, in most implementations).config/<package>.php — pagination defaults, request-key mapping, and any "searchable fields" allowlist.First use case
A search endpoint with a free-text box and a few dropdown filters — exactly the scenario the release notes describe:
public function index(SearchUsersRequest $request)
{
$repo = $this->users;
if ($keyword = $request->validated('q')) {
$repo->pushCriteria(new FuzzySearch($keyword, ['name', 'pinyin']));
}
foreach (array_filter($request->validated('filters', [])) as $field => $value) {
$repo->pushCriteria(new AttributeSearch([$field => $value]));
}
return UserResource::collection($repo->paginate(20));
}
Upgrade note for 1.0.5: if you are on 1.0.4 or earlier, upgrade deliberately. The SQL for that exact snippet changes from
where `name` LIKE "%Jack%" OR `pinyin` LIKE "%%Jack" AND `city` = "北京"
to
where (`name` LIKE "%Jack%" OR `pinyin` LIKE "%%Jack") AND `city` = "北京"
The old query was wrong (operator precedence made the city filter apply only to the pinyin branch), so anyone relying on the buggy wider result set will see row counts drop after the upgrade. That is the fix working, not a regression — but it will break assertions and any paginated "result count" snapshot tests.
Treat criteria as composable, ordered query fragments. Each pushCriteria() call appends a WHERE fragment; the order you push them is the order they land in SQL. Keep push order deterministic: text criteria first (they group their own ORs), attribute criteria after.
Keyword + filters is the canonical pattern. Because FuzzySearch now wraps its OR set in parentheses, (fuzzy) AND (attribute) AND (attribute) composes correctly no matter how many filters follow. Before 1.0.5 you had to hand-roll the grouping yourself; you no longer need to.
Keep the criteria chain in one place. Put the "build the criteria stack from a request" logic in a dedicated method or a form request → DTO, not scattered across controllers:
protected function applySearch(RepositoryInterface $repo, SearchCriteriaData $data): void
{
$data->keyword && $repo->pushCriteria(new FuzzySearch($data->keyword, $data->columns ?? ['name']));
foreach ($data->attributes as $field => $value) {
$repo->pushCriteria(new AttributeSearch([$field => $value]));
}
}
Scope resets. Repositories are often resolved as singletons; call resetCriteria() at the start of a request/command if your version persists criteria across calls, otherwise a criterion pushed for one endpoint leaks into the next repository call in the same request (queues, console commands, and Livewire actions are the usual victims).
Use skipCriteria() for privileged paths. Admin exports, internal jobs, and "select all matching" operations should bypass the fuzzy criteria you apply to the public search. This is a one-liner and far safer than duplicating query code.
Integration tips
FuzzySearch or AttributeSearch from raw $request->all() — an unfiltered field name can reach the WHERE clause as an identifier.FuzzySearch a constant on the repository (UserRepository::SEARCHABLE), so it can't be widened from the request.->paginate() after the criteria are applied; results are lazy, so order matters only for correctness, not performance.Testing pattern. Assert on generated SQL, not just row counts, when touching search:
DB::listen(fn ($q) => $this->assertStringContainsString('(`name` LIKE', $q->sql));
$repo->pushCriteria(new FuzzySearch('Jack', ['name', 'pinyin']));
$repo->pushCriteria(new AttributeSearch(['city' => '北京']));
$repo->all();
Add at least one fixture-based test where a row matches the keyword but not the attribute filter — that is exactly the case 1.0.5 changed.
Extension point. Custom criteria are the clean way to encode business search rules:
class ActiveOnly implements CriteriaInterface
{
public function apply($model, RepositoryInterface $repository)
{
return $model->where('status', 'active');
}
}
Subclass FuzzySearch if you need a different wildcard strategy (e.g. prefix-only for indexed columns) rather than patching vendor code.
1.0.5 is a silent behaviour change, not a cosmetic one. Pre-1.0.5, A OR B AND C evaluated as A OR (B AND C). Any report, export, or count query built on the old criteria chain will now return fewer rows. Audit anything that consumed those results — CSV exports, dashboards, notification recipient lists — before rolling it out. This is the single biggest thing to check in this release.
Beware the %%Jack wildcard. The second column gets a double leading % ("%%Jack") while the first gets "%Jack%". Functionally it behaves as a suffix/contains match, but it's almost certainly unintentional and it means your name and pinyin columns are treated differently by the same criterion. If pinyin matching looks asymmetric, this is why — pin the expected behaviour with a test or override the criterion.
Leading wildcards defeat indexes. %term% cannot use a B-tree index. Fine for small tables; on anything past a few hundred thousand rows you want a full-text index or Laravel Scout, not this criterion. Don't discover this in production.
No trimming or normalisation. The sample SQL shows = " 北京" with a leading space — the value passed in is bound verbatim. Trim, lowercase, and normalise inputs in the form request, or you will get mysterious zero-result searches from copy-pasted values.
Grouping is not transitive. Each criterion groups its own ORs. You cannot get (A OR B) AND (C OR D) by pushing two FuzzySearch instances — you'll get (A OR B) AND C AND D-style nesting that is wrong for your intent. Combine into one custom criterion when you need parallel OR groups.
Order of pushCriteria is observable. Reordering calls can change generated SQL and, with it, test snapshots. If you refactor a search method, re-run SQL assertions.
Debugging. DB::listen() or DB::enableQueryLog() is more reliable than toSql() (which drops bindings and can hide the parenthesisation you're trying to verify). For quick inspection, Laravel Debugbar/Telescope shows the final SQL with bindings — that's where you'll see whether the grouping fix is actually in play.
Empty inputs. Verify what FuzzySearch('') and AttributeSearch([]) do in your version — most implementations no-op, but if they don't you'll emit LIKE "%%" and match every row (and skip the index). Guard at the call site.
Extension points to prefer. Custom CriteriaInterface implementations and repository method overrides are safe. Editing vendor/ for the fuzzy wildcard or the grouping is not — 1.0.5 exists precisely because that logic belongs upstream.
How can I help you explore Laravel packages today?