ceddyg/query-builder-repository
ceddyg/query-builder-repository is a Laravel package that layers a repository abstraction over the fluent query builder, giving you a clean, reusable data layer for your models. It streamlines CRUD, filtering, and query organization so controllers and services stay lean, readable, and testable.
Install and confirm the wiring
composer require ceddyg/query-builder-repository
The package is small, so the first productive step is not the README — it is opening vendor/ceddyg/query-builder-repository/src and reading the base repository class. In a package this size you will learn more in five minutes of source reading than from any docs: you need the exact property names (table, primary key, selected columns), the available methods, and whether it ships a config file or an artisan generator.
If a service provider is registered via package discovery, it will be picked up automatically. Publish the config only if one exists:
php artisan vendor:publish --provider="Ceddyg\QueryBuilderRepository\..."
First repository
namespace App\Repositories;
use Ceddyg\QueryBuilderRepository\...\Repository; // confirm FQCN from source
class InvoiceRepository extends Repository
{
protected $table = 'invoices';
protected $primaryKey = 'id';
}
First use case: read-heavy endpoints
The correct mental model is read model vs. write model. Adopt this package for the queries where you only need data out — dashboards, report exports, autocomplete endpoints, list APIs, nightly sync jobs — and keep Eloquent for anything that writes, fires events, or needs relationships. A report controller resolving InvoiceRepository and returning a collection of stdClass will cut both memory and hydration time versus Invoice::all(), and the payload shape you hand to response()->json() is identical.
Read/write split per aggregate
Keep an Eloquent model and a query-builder repository side by side for the same table. Writes go through the model (validation, events, observers, updated_at); reads on hot paths go through the repository. This is the pattern that ages best and avoids the "we replaced Eloquent and lost our observers" failure mode.
class InvoiceController
{
public function index(InvoiceRepository $invoices)
{
return InvoiceResource::collection(
$invoices->paginate(
perPage: 50,
columns: ['id', 'number', 'total_cents', 'issued_at'],
)
);
}
}
Always project columns
Because nothing is hydrated into a model, selecting * is the main way to lose the package's advantage. Pass an explicit column list and keep a constant of the allowed columns per endpoint.
Filtering and sorting from request input
The query builder has no protection against a caller-injected orderBy string. Wrap it:
private const SORTABLE = ['issued_at', 'total_cents', 'number'];
$sort = in_array($request->sort, self::SORTABLE, true) ? $request->sort : 'issued_at';
Do the same for filter keys. This is where a small base repository of your own helps — subclass the package repository once, add applyFilters(array $filters) and applySort(string $column, string $dir) with whitelists, and have every concrete repository inherit it.
Joins instead of relationships
There is no with(). Write explicit joins and alias colliding columns:
$qb->join('customers', 'customers.id', '=', 'invoices.customer_id')
->select([
'invoices.id',
'invoices.number',
'customers.name as customer_name',
]);
Bulk work: chunk and cursor
For exports and syncs, reach for cursor() or chunked reads rather than get(). This is where the package pays for itself — a million-row nightly export that OOMs under Eloquent usually runs comfortably on plain arrays of stdClass.
Caching and testing
Because results are plain objects, caching is trivial: Cache::remember() around a repository method works without worrying about serializing model state or stale relations. Testing is equally cheap — stdClass fixtures are one-liners, and you can assert on repository output without touching the ORM. Point tests at an in-memory SQLite connection and the repositories behave identically to production.
Everything Eloquent gave you for free is gone. No casts, no Carbon on date columns (you get strings), no accessors, no $hidden/$visible, no model events, no SoftDeletes scope, no updated_at bookkeeping. Decide up front where the formatting lives — almost certainly an API Resource or transformer layer — and do dates, money, and booleans there consistently. Mixed output (some endpoints returning Eloquent, some stdClass) is the fastest route to an inconsistent public API.
Join column collisions silently overwrite. With select * on a join, the second id wins and your primary key is wrong. Always alias. This is the single most common bug with query-builder repositories.
No N+1 detection. Looping over results and calling a related repository per row is easy to do accidentally and invisible to Laravel's query log tooling in the usual way. Keep a DB::listen() in local development, or install a strict query-count assertion in tests for hot endpoints.
Raw input is dangerous. Any method accepting a column name, direction, or raw expression must be whitelisted. Treat orderBy, select, and whereRaw as sinks.
Small-package risk. Eleven stars means the API can change between minor versions, there is no community to search for answers, and Laravel-version compatibility is not guaranteed by a large test matrix. Pin the version in composer.json, and be prepared to vendor or fork the base class if the package stalls. It is a thin wrapper — the cost of replacing it with your own 150-line abstract repository is low, which is itself a reason not to build deep coupling to it.
Debugging. You will be working with the underlying builder, so ->toSql() with bindings, DB::enableQueryLog(), and dd($query) mid-method work exactly as expected — a genuine advantage over debugging Eloquent internals.
Extension points. Override package methods in your own base repository rather than editing vendor code; add a toArray()-style mapper there if your controllers expect arrays. If you use read replicas, set the connection explicitly on the repository so reporting queries do not compete with writes.
Verdict. Worth adopting narrowly: reporting, exports, and read-heavy list endpoints on large tables, behind your own thin base class. Not worth adopting as a wholesale replacement for Eloquent — you would be trading a mature, well-documented ORM for an 11-star package and reimplementing casts, scopes, and relationship handling by hand.
How can I help you explore Laravel packages today?