Files
orm/README.md
2026-08-19 11:00:28 -03:00

7.2 KiB

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() and count() soft-delete WHERE clause 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 bulk IN (...) query instead of one query per item (N+1 problem)
  • Relations cannot be iterated in Mustache / template engines (magic __get on Entity calls ->get() but templates can't iterate the result transparently)
  • __get() should return null instead of 0 for undefined properties
  • Add formal test suite (PHPUnit)
  • Support ORDER BY in findAll() and findMany()
  • Support whereNull / whereNotNull criteria shorthand