emilianosuarez/bruno
Bruno is a lightweight Laravel package by Emiliano Suárez that adds a clean, fluent API for common application tasks. It ships with sensible defaults, publishable configuration, and straightforward integration so you can drop it into any project and start using it quickly.
Bruno is a Laravel package for declarative Eloquent filtering. Version 1.1.0 is a minor, non-breaking feature release: it adds support for applying filters across relationships, so a single filter definition can now reach into related models instead of being limited to the base query's own columns.
Start here, in order:
composer require emilianosuarez/bruno:^1.1 — pin to ^1.1 rather than ^1.0, since relationship filtering is the capability you'll likely be reaching for. Upgrading an existing 1.0.0 install is safe; nothing in the release notes indicates removed or renamed behaviour.php artisan vendor:publish) and check for a default allowlist/denylist of filterable keys before wiring it into a controller.First use case: an index endpoint that currently accumulates if ($request->filled('status')) { $query->where(...) } chains. Move those conditions into a filter definition, then add one relationship filter — e.g. filtering a list of posts by an attribute on the author — and confirm the generated SQL with DB::listen() or ->toSql() before shipping.
Controller as thin adapter. Keep the pattern Model::filter($request->validated())->paginate() in the controller and push all key→column/operator mapping into the filter definition. The controller should never enumerate filterable fields; that's what makes the package worth adopting over hand-rolled conditionals.
Form Requests as the allowlist. The safest way to prevent arbitrary filter keys is validating with 'filter.*' => ['sometimes', 'string', 'max:255'] plus an explicit only() list in the Form Request. Combine with whatever allowlist the package exposes — belt and braces, because a filter surface is a query surface.
Relationship filters. With 1.1.0, treat relationship filters as scoping, not joining: the natural implementation is a correlated subquery (whereHas-style). That means result sets stay deduplicated automatically. If you need to sort by a related column, that's a separate concern — relationship filtering doesn't imply ordering support, so verify before assuming.
Composition over conditionals. Build named, reusable filter fragments (e.g. ActiveOnly, OwnedBy, Recent) and compose them per endpoint. This keeps the same semantics consistent between /posts and /users/{user}/posts.
Eager-loading discipline. Relationship filters do not eager-load. Pair the filtered query with ->with('author') when the resource serialises related data, or you'll trade a clean API for N+1 queries in the transformer.
Testing pattern. Assert on behaviour, not SQL strings, but use DB::listen() in a debug test to confirm a relationship filter emits a single subquery rather than a join fan-out. Cover: unknown filter key (ignored or 422, per your validation), empty string value, array value, and a filter on a relationship that doesn't exist on the model.
where clauses on a query that also joins (for pagination counts, ordering, or eager-load constraints) will throw ambiguous-column errors. Qualify columns as posts.status in filter definitions and relationship paths.orWhere grouping. If you mix package-managed filters with hand-written orWhere, ensure the package's constraints are wrapped in their own group. A stray ungrouped orWhere will silently widen result sets and is the classic filtering bug.?status= (empty), ?status=0, and repeated keys are meaningful. Most filtering bugs are here, not in the query builder.paginate() runs a count() on the same query; a costly subquery doubles up. Consider simplePaginate() on large relationship-filtered endpoints.composer.json and add a smoke test for relationship filters, so a future release that changes the relationship strategy fails loudly in CI rather than quietly returning different rows.How can I help you explore Laravel packages today?