versaorm/versaorm-php
VersaORM is a lightweight, expressive PHP ORM featuring a fluent query builder, active-record models, relationships, and schema migrations. It supports popular databases and keeps your data layer clean without heavy dependencies or complex configuration.
Treat this as a bolt-on query layer, not an Eloquent substitute. versaorm/versaorm-php is a standalone PDO-backed ORM with its own fluent builder, its own connection, and its own VersaModel Active Record base. It ships no service provider, no facade, no DB driver, and no Eloquent bridge. The correct mental model for a Laravel dev is: "I'm adding a specialised SQL library for one workload," not "I'm replacing Eloquent."
Install with an exact pin. The repo is kriollo/versa-orm while the Packagist listing reads versaorm/versaorm-php — resolve that naming ambiguity in the first five minutes rather than after a failed composer require. Pin the patch:
composer require kriollo/versa-orm:2.0.1
Not ^2.0. 2.0.0 shipped with a binding bug that silently wrote NULL into computed columns on SQLite and MySQL; 2.0.1 is the first safe patch of the 2.x line.
Wire the connection yourself in AppServiceProvider. Keep the array in a config file so config:cache and env() behave predictably, and recycle the same env vars as config/database.php:
// config/versaorm.php
return [
'driver' => env('VERSA_DRIVER', env('DB_CONNECTION', 'pgsql')),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 5432),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
];
// AppServiceProvider::register()
$this->app->singleton(\kriollo\VersaORM::class, fn () =>
new \kriollo\VersaORM(config('versaorm'))
);
Use 127.0.0.1, never localhost. PdoConnection has defaulted to TCP since 1.9.0; localhost can resolve to a Unix socket and fail with a misleading connection error.
Before writing a line of application code, read in this order:
example/app/config.php and example/app/BaseModel.php — the only end-to-end wiring sample. Caution: the sample's BaseModel was broken until 2.0.1 because it overrode find(), which has been final since 1.8.8. Don't copy that override.tests/unit/Regression/ — 19 tests, 14 of which fail against v1.9.0. This is the closest thing to a behavioural spec the package has.First use case: a read-only spike, not a model port. Pick pgvector similarity search on PostgreSQL 16 or an aggregate/reporting path. Both are areas where Eloquent gets awkward and where this package actually earns its keep. Do not start by porting User.
$orm = app(\kriollo\VersaORM::class);
$hits = $orm->table('documents')
->selectVectorSimilarity('embedding', $vector, 'distance')
->orderBy('distance')
->limit(10)
->getAll();
Confirm three things against a restored production copy before writing app code: the PostgreSQL major version, that the vector extension is installed, and that your user has GRANT ALL ON SCHEMA public (PG 15+ dropped the implicit write grant).
Coexistence is the day-to-day shape. Eloquent keeps CRUD, relationships, events, policies, casts, and Nova. VersaORM handles vectors, aggregations, and hand-tuned analytical SQL against the same database. That's two PDO connections per request — keep the VersaORM singleton out of hot paths and resolve it lazily inside only the controllers, jobs, and commands that need it.
Wrap every chain in a repository or invokable query class. Given how recently silent-corruption bugs were patched, assume you may need to swap or pin backward. No ->table() chains in controllers, Blade, or models.
final class SimilarDocuments
{
public function __construct(private \kriollo\VersaORM $orm) {}
public function __invoke(array $embedding, int $limit = 20): array
{
return $this->orm->table('documents')
->selectVectorSimilarity('embedding', $embedding, 'distance')
->orderBy('distance')
->limit($limit)
->getAll();
}
}
Binding order is a hard contract, not a code style. Bindings are collected SELECT → WHERE → GROUP BY → HAVING → ORDER BY. If the same placeholder value appears in both selectRaw and orderByRaw, supply it twice — the builder does not de-duplicate:
$orm->table('orders')
->selectRaw('SUM(total_cents) / ? AS avg_basket', [100])
->where('status', 'paid')
->orderByRaw('SUM(total_cents) / ? DESC', [100])
->getAll();
Put aggregate dashboards here; keep CRUD in Eloquent. Weekly revenue, cohort counts, funnel arithmetic over selectRaw all read far better as builder chains than as DB::select with a heredoc. Then cache the result like any other expensive read:
$revenue = Cache::remember("revenue:{$week}", now()->addMinutes(15), fn () =>
app(\kriollo\VersaORM::class)->table('orders')
->selectRaw('DATE_TRUNC(?, created_at) AS bucket', ['week'])
->selectRaw('SUM(total_cents) AS cents')
->where('status', 'paid')
->groupBy('bucket')
->orderBy('bucket')
->getAll()
);
Hand pagination back to Laravel. There is no paginate(), no LengthAwarePaginator, no cursor helper. Do a count query plus a limit/offset fetch in your repository, then build the paginator yourself. Five lines per endpoint — budget for it on every listing view.
Use Active Record sparingly. Subclass VersaModel when you want zero-config table/PK inference for a table Eloquent doesn't own — a log table, a legacy or external table, an ingestion staging table. Two constraints: do not override find() (it's final), and do not expect casts, mutators, or timestamps. Those features simply do not exist in this package.
Multi-engine is a testing strategy, not a runtime feature. Run the bulk of feature tests against in-memory SQLite for speed, then add CI jobs on MySQL and PostgreSQL. Critically: staging must run the production engine. In 2.0.0 the raw-binding bug threw loudly on PostgreSQL and returned silent NULLs on SQLite and MySQL — exactly the asymmetry that makes "passed in staging" meaningless.
Practical day-to-day slots for the package:
documents:reindex) that writes embeddings and rebuilds the query side in one place.DB::select.versaorm.readonly) since the package has no read/write splitting of its own.Keep writes and transactions on the Eloquent side. If a vector insert and a domain write must be atomic, they have to share one connection. Do not straddle the two stacks inside a transaction — you'll end up with a partial commit you cannot explain on rollback.
Schema changes belong in Laravel migrations. vector column definitions, HNSW/IVFFlat indexes, and GRANTs are migration territory. VersaORM is not a schema tool.
Silent data corruption is the failure mode — log and assert. A wrong binding produced NULL in a result column with no exception on SQLite and MySQL. Wrap execution in a local-only logger that dumps SQL and bound parameters before running, and write tests that assert non-null on every computed column in a result set.
select() replaces the column list wholesale. Call it after selectVectorSimilarity() and you erase the similarity expression. This mistake lived in the package's own tests until 2.0.1. Order builder calls so select() comes first, or omit it entirely.
exec() is the raw-statement entry point; there is no $orm->getAll() or $orm->getRow(). Row retrieval lives on the builder chain. Getting this wrong cost upstream three broken tests — expect the same confusion in your codebase.
# is treated as a MySQL comment unless it directly follows <. The pgvector inner-product operator <#> now works, but avoid # everywhere else — use -- or /* */. This is a security-relevant heuristic, so re-check it on every upgrade.
PostgreSQL 15+ needs an explicit schema grant. A database-level GRANT is insufficient; provision GRANT ALL ON SCHEMA public TO your_user (or move to a dedicated schema) in setup scripts, or every CI job dies with "permission denied for schema public."
No Laravel ecosystem features — plan to hand-roll each one. No model events, no factories, no SoftDeletes, no casts, no attribute mutators, no withCount, no first-party package compatibility. Each of those is a decision you're making explicitly, not an accident you discover mid-sprint.
Beware Octane and long-lived workers. A singleton holding a PDO handle persists across requests. Usually fine, but if you hit stale-connection or "server has gone away" errors, stop registering the ORM as a singleton — bind it as scoped/transient instead.
find() is final on VersaModel. You cannot override it. If you need custom lookup behaviour, wrap the model rather than extend it.
You own the upgrade gate. PHPStan level 9 clean and php-cs-fixer clean are genuine signals, but the same project shipped an untested headline feature (pgvector — all 13 tests skipped locally and in CI) and then followed it with a patch fixing silent corruption. Treat every changelog entry as a potential correctness note. Gate bumps behind your own integration tests.
Keep env() confined to config files — more so than usual here, since VersaORM reads a plain array you construct. With config:cache active, an env() call anywhere else silently returns null and your connection drops with a confusing error.
If anything is already in production on 2.0.0, treat it as a data audit. Any feature computing aggregates, prices, scores, or rankings through selectRaw()/orderByRaw() could have written wrong numbers. Run a reconciliation query against a source of truth before you consider the dependency upgraded.
Know the ceiling. pgvector with a proper HNSW index is comfortable into the low millions of vectors. If your retrieval volume or latency SLO points past that, the "no separate vector database" advantage shrinks quickly — evaluate before the roadmap depends on it.
How can I help you explore Laravel packages today?