morris/lessql
LessQL is a lightweight, fast PHP database layer with a simple NotORM-style API. Query tables, traverse associations and save data with minimal code, no configuration, and no heavy ORM overhead.
LessQL is not a Laravel package — there is no service provider, facade, config file, or migration support. Treat it as a standalone PDO wrapper you wire in yourself.
composer require morris/lessql$this->app->singleton(\LessQL\Database::class, function () {
$pdo = DB::connection()->getPdo();
$db = new \LessQL\Database($pdo);
// MySQL only — LessQL quotes identifiers with ANSI double quotes by default
$db->setIdentifierDelimiter('`');
return $db;
});
$posts = app(\LessQL\Database::class)
->post()
->where('is_published', 1)
->orderBy('date_published', 'DESC')
->limit(20);
foreach ($posts as $post) {
echo $post['title']; // ArrayAccess
echo $post->user()->name; // reference traversal
}
Read the three classes in this order and you've read the whole library: LessQL\Database (connection + table access), LessQL\Result (lazy iterable query builder), LessQL\Row (array-backed row with save()/delete()). Everything else is inference conventions.
Table access as a query entry point. $db->post() returns a Result; nothing executes until you iterate, first(), or count(). Chain where, orderBy, limit, offset, select, groupBy as needed. This is the closest thing to a "model" LessQL has — there are no classes per table.
Reference traversal instead of relationship definitions. LessQL guesses relationships from column names: a column user_id on post makes $post->user() resolve to the user table; going the other way, $user->post() returns rows whose user_id matches. No hasMany/belongsTo declarations, no registration step. Many-to-many has no shorthand — walk the association table explicitly ($post->post_category()->categorie()-style chaining) or drop to raw SQL.
Persistence is row-centric and dirty-tracked.
$post = $db->post()->createRow(['title' => 'Hi', 'user_id' => 1]);
$post->save(); // INSERT, PK populated
$post->title = 'Hello';
$post->save(); // UPDATE of changed columns
$post->delete();
$db->post()->where('is_published', 0)->delete(); // bulk
$db->post()->insert(['title' => 'Bulk']); // no dirty tracking
Wrap multi-step writes in $db->begin() / commit() / rollback().
Manual eager loading to survive N+1. This is the pattern you'll write over and over:
$posts = $db->post()->where('is_published', 1)->getRows(); // or iterate once into an array
$userIds = array_unique(array_column($posts, 'user_id'));
$users = $db->user()->where('id', $userIds)->getRows(); // check whether your version supports array values here
$byId = array_column($users, null, 'id');
foreach ($posts as $post) {
$author = $byId[$post['user_id']] ?? null;
}
If the builder doesn't support IN conveniently in your version, $db->query('SELECT ... WHERE id IN (...)', $params) is the honest escape hatch — LessQL's raw query path returns the same Result/Row types.
Where it actually earns its place in a Laravel app: artisan commands, importers, legacy-database read models, CSV/report generation, and thin admin screens against a schema you don't control. Keep Eloquent for anything with events, casts, soft deletes, or validation. Mixing both against the same connection is fine; mixing transaction bookkeeping is not (see below).
Testing. new \LessQL\Database(new \PDO('sqlite::memory:')) against a schema you build by hand is fast and needs no Laravel bootstrapping. Good for unit-testing query logic in isolation from Eloquent.
", which MySQL only accepts in ANSI_QUOTES mode. Always call setIdentifierDelimiter('')`.<name>_id columns and a table literally named <name>. Any pluralized/legacy schema (users, tbl_user) silently breaks traversal. Workarounds: SQL views that rename tables/columns to the convention, or explicit raw queries. Don't fight the inference.$post->user() inside a loop is one query per row, every time. There's no with(), no preventLazyLoading, no N+1 warning. Budget for this in code review.save() is insert-or-update with no mass-assignment protection. Passing $request->all() into insert() or createRow() writes every key. Always whitelist with $request->only([...]).Carbon, no casts, no JSON columns, no accessors, no observers, no soft deletes, no updated_at/created_at automation. Dates come back as strings; convert at the boundary.DB::beginTransaction() and LessQL's $db->begin() talk to the same PDO but maintain separate nesting counters. Pick one per code path, or a rollback in one layer will leave the other confused.DB::listen(), Telescope, and DB::enableQueryLog() won't see LessQL statements because they bypass the query builder. For debugging, wrap the PDO instance in a decorator that logs prepare/execute, or enable the database's own general log.id. Composite keys and non-id PKs need setPrimaryKey on the table (check the source for the exact signature in your version) or save() will misbehave.where('col', null) should render IS NULL — verify this in your version rather than assuming; hand-write where('col IS NULL')-style raw fragments if the builder doesn't cover your case.composer.json, keep the surface area small (read paths and simple writes), and put a thin repository class between your app and LessQL\Database so replacing it later is a one-file change rather than a grep across the codebase.Database and Row for logging, type coercion, or a shared save() hook; that's about the ceiling. If you find yourself wanting more, that's the signal to move that table back to Eloquent.How can I help you explore Laravel packages today?