urfat-orm
A lightweight, zero-dependency PHP ORM with Laravel/Eloquent-inspired syntax. Built on top of PDO, it supports multiple named connections, soft deletes, timestamps, eager loading, and pivot table relationships.
Installation
composer install
No external dependencies. PHP 8.0+ required.
Setup
require 'vendor/autoload.php';
use ORM\Connection;
use ORM\Connections;
// Using the factory helper
$pdo = Connection::open('pgsql', 'dbname=mydb host=localhost', 'user', 'pass', 'myprefix_');
// Or register a raw PDO
$pdo = new PDO("pgsql:dbname=mydb;host=localhost", 'user', 'pass');
$pdo->prefix = 'myprefix_';
Connections::addConnection($pdo); // 'default'
Connections::addConnection($pdo, 'secondary'); // named connection
Supported drivers: pgsql, mysql, sqlite, firebird, oracle, mssql.
Defining a Model
use ORM\Entity;
class User extends Entity {
const _tableName = 'users';
const _connectionName = 'default'; // optional, defaults to 'default'
const _softdelete = true; // filters deleted_at IS NULL on all queries
const _timestamps = true; // auto-sets created_at / updated_at on save
const _ignore = ['password_confirm']; // excluded from INSERT / UPDATE
const _idPolice = [ // optional custom ID strategy
'type' => 'nextval',
'min' => 500,
'max' => 9999,
'step' => 10,
];
protected function posts() {
return $this->hasMany(Post::class, 'user_id');
}
protected function country() {
return $this->belongsTo(Country::class, 'country_id', 'id');
}
protected function roles() {
return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');
}
}
Features
CRUD
// Create
$user = new User(['name' => 'Alice', 'email' => 'alice@example.com']);
$user->save(); // INSERT; $user->id is populated after
// Read
$user = User::findOne(42);
$user = User::findOne(['email' => ['=', 'alice@example.com']]);
$users = User::findAll();
$users = User::findMany(['active' => ['=', 1]], ['*'], ['LIMIT' => 10, 'OFFSET' => 0]);
$n = User::count(['active' => ['=', 1]]);
// Update
$user->name = 'Bob';
$user->save(); // UPDATE because $user->id exists
// Delete
$user->delete(); // soft-delete if _softdelete=true, otherwise hard-delete
$user->purge(); // always hard-delete
$user->restore(); // clears deleted_at (soft-delete models only)
Criteria format: ['column' => ['operator', value]]
Supported operators: =, <, >, <=, >=, IN, LIKE
Soft Delete
When _softdelete = true, all findAll, findOne, findMany, and count calls automatically append WHERE deleted_at IS NULL. Pass true as the last argument to include trashed records:
$all = User::findAll(['*'], [], true); // includes soft-deleted
$trashed = User::findOne(42, ['*'], true);
Timestamps
When _timestamps = true, created_at and updated_at are set on construction and updated_at is refreshed on every save().
Relationships
| Method | Description |
|---|---|
hasOne(Class, $remoteField) |
One child record keyed by local id |
hasMany(Class, $remoteField) |
Many child records keyed by local id |
belongsTo(Class, $localField, $remoteField) |
Parent record |
belongsToMany(Class, $pivot, $localKey, $remoteKey) |
Many-to-many via pivot |
belongsToManyExtended(...) |
Many-to-many via pivot; returns pivot rows with foreign object in ->childElement |
// Lazy load (fires a query on access)
$posts = $user->posts()->get();
$country = $user->country; // magic __get shorthand
// Eager load on a Collection
$users = User::findAll()->with(['posts', 'country']);
// Eager load on a single Entity
$user->with(['posts', 'country']);
Multiple Named Connections
Connections::addConnection($pdo1); // 'default'
Connections::addConnection($pdo2, 'analytics'); // 'analytics'
class Event extends Entity {
const _connectionName = 'analytics';
}
Custom ID Strategy (_idPolice)
When type = 'nextval' the ORM computes the next ID from MAX(id) before inserting, respecting min, max, and step. Useful for databases without auto-increment sequences.
Raw Query Helpers (DBInstance)
For queries that don't fit the Entity API:
use ORM\DBInstance;
// Single record with prepared statement
$row = DBInstance::getRecord('users', ['email' => ['=', $email]]);
// Multiple records
$rows = DBInstance::getRecords('users', ['active' => ['=', 1]], ['id', 'name'], ['LIMIT' => 50]);
// Free SQL
$rows = DBInstance::getRecordsSql("SELECT * FROM {users} WHERE created_at > ?", [$date]);
// Insert and get last id
$id = DBInstance::insertRecord('logs', ['action' => 'login', 'user_id' => 1]);
// Update a single field
DBInstance::setField('users', 'active', 0, $id);
// Direct query
DBInstance::execute("UPDATE {users} SET score = score + 1 WHERE id = ?", [$id]);
Table names always use {tableName} placeholder syntax so the connection prefix is applied automatically.
SQL Tracing
$pdo->traceSQL = true; // prints every query to stdout
Known Bugs
| # | Location | Description |
|---|---|---|
| 1 | Collection::with() |
After the Relations namespace refactor, the switch class-name checks still use old names (ORM\HasMany, etc.) — all branches fall to default, which works but is inaccurate |
| 2 | Entity::findMany(), Entity::count() |
When no criteria are given and _softdelete = true, AND deleted_at IS NULL is appended without a preceding WHERE, producing invalid SQL |
| 3 | Entity constructor / update() |
Timestamp format uses date('Y-m-d h:m:s') — h is 12-hour clock and the second m is month; should be date('Y-m-d H:i:s') |
| 4 | Entity::__get() |
Returns 0 for any undefined property, silently masking typos |
| 5 | DBInstance::queryOne() |
When called with $data, it delegates to queryPrepare() which calls fetchAll() and returns an array, not a single object |
| 6 | DBInstance::setField() |
Uses PHP string interpolation {$table} instead of the ORM's {tableName} syntax, so the connection prefix is never applied |
| 7 | Entity::findMany() |
Values are interpolated directly into SQL (no prepared statements), making this method unsafe for user-supplied input |
To Do
- Fix
findMany()andcount()soft-deleteWHEREclause when no other criteria exist - Fix timestamp format (
H:i:s) - Fix
Collection::with()class-name checks after Relations namespace refactor - Fix
DBInstance::setField()table prefix placeholder - Fix
DBInstance::queryOne()to actually return one record - Make
findMany()use prepared statements Collection::with()— use a bulkIN (...)query instead of one query per item (N+1 problem)- Relations cannot be iterated in Mustache / template engines (magic
__getonEntitycalls->get()but templates can't iterate the result transparently) __get()should returnnullinstead of0for undefined properties- Add formal test suite (PHPUnit)
- Support
ORDER BYinfindAll()andfindMany() - Support
whereNull/whereNotNullcriteria shorthand