initorm/orm

Lightweight, PDO-based ORM with Active Record-style models and entity accessors/mutators. Built on top of initorm/database, initorm/dbal and initorm/query-builder.

Maintainers

Package info

github.com/InitORM/ORM

Documentation

pkg:composer/initorm/orm

Fund package maintenance!

muhammetsafak

Statistics

Installs: 68

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-05-24 19:14 UTC

This package is not auto-updated.

Last update: 2026-05-24 19:20:57 UTC


README

A lightweight, PDO-based ORM. Each subclass of Model maps a table to an Entity class and exposes Active-Record-style CRUD on top of initorm/database. Entities support per-column accessor and mutator hooks (Laravel-style), and models ship with optional timestamp columns, soft deletes, and per-operation permission gates.

Latest Stable Version Total Downloads License PHP Version Require PHPUnit PHPStan PHP_CodeSniffer

Requirements

  • PHP 8.1 or later
  • ext-pdo
  • One of ext-pdo_mysql, ext-pdo_pgsql, or ext-pdo_sqlite depending on the database you target.

Supported databases

The full query-builder dialect support comes through initorm/database: MySQL/MariaDB, PostgreSQL, and SQLite ship with dialect-aware identifier quoting; any other PDO driver works through a no-escape generic driver.

Installation

composer require initorm/orm

initorm/orm pulls in initorm/database, initorm/dbal, and initorm/query-builder transitively — you do not need to require them yourself.

Quick start

<?php
require_once 'vendor/autoload.php';

use InitORM\Database\Facade\DB;

DB::createImmutable([
    'dsn'      => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
    'username' => 'app',
    'password' => 'secret',
]);

Define a model:

namespace App\Model;

class Posts extends \InitORM\ORM\Model
{
    protected string $schema   = 'posts';
    protected string $schemaId = 'id';

    protected bool    $useSoftDeletes = true;
    protected ?string $createdField   = 'created_at';
    protected ?string $updatedField   = 'updated_at';
    protected ?string $deletedField   = 'deleted_at';

    protected string $entity = \App\Entity\PostEntity::class;
}

Define an entity:

namespace App\Entity;

class PostEntity extends \InitORM\ORM\Entity
{
    public function getTitleAttribute(mixed $value): mixed
    {
        return is_string($value) ? ucwords($value) : $value;
    }

    public function setTitleAttribute(mixed $value): void
    {
        // ALWAYS use setAttribute() inside a mutator —
        // $this->title = ... bypasses __set and creates a dynamic property.
        $this->setAttribute('title', is_string($value) ? trim($value) : $value);
    }
}

Use the model:

use App\Model\Posts;

$posts = new Posts();

// Create
$posts->create(['title' => 'My First Post', 'body' => 'Hello world']);

// Read (returns a DataMapper hydrating PostEntity instances)
foreach ($posts->read()->rows() as $entity) {
    echo $entity->title; // accessor runs here
}

// Update by primary key (lifted out of $set into a WHERE)
$posts->update(['id' => 5, 'title' => 'Edited']);

// Soft delete (sets deleted_at), then permanently purge
$posts->delete(['id' => 5]);
$posts->delete(['id' => 5], purge: true);

How it fits together

QueryBuilder  ──►  Database  ──►  ORM (this package)
DBAL          ──►  Database

A Model holds a DatabaseInterface and forwards unknown calls to it via __call. The Database, in turn, forwards builder calls (where, select, orderBy, …) to the underlying query builder. Chainable calls re-wrap at every boundary, so this all stays fluent on the Model:

$entities = $posts
    ->where('status', '=', 'published')
    ->orderBy('id', 'DESC')
    ->limit(10)
    ->read()
    ->rows();

The full query-builder surface (~100 methods including joins, group/having, sub-queries, LIKE family, BETWEEN, IN, raw expressions) is documented in initorm/query-builder and initorm/database.

Configuration reference

These protected properties shape a model's behaviour. All are optional with sensible defaults.

Property Type Default Notes
$schema string (derived) Table name. When unset, auto-derived from the short class name via snake_case conversion.
$schemaId string 'id' Primary-key column. Lifted out of update()'s $set into a WHERE; used by save() to pick CRUD.
$entity class-string Entity::class Class used to hydrate read() rows.
$credentials array|null null Standalone connection credentials; null binds to the shared DB facade.
$writable bool true When false, create()/createBatch() throw WritableException.
$readable bool true When false, read() throws ReadableException.
$updatable bool true When false, update()/updateBatch() throw UpdatableException.
$deletable bool true When false, delete() throws DeletableException.
$createdField string|null null Auto-filled with the current timestamp on every create. Disabled when null.
$updatedField string|null null Auto-filled with the current timestamp on every update. Disabled when null.
$useSoftDeletes bool false When true, delete() sets $deletedField instead of issuing a DELETE. Requires $deletedField.
$deletedField string|null null Soft-delete marker column. Must be set when $useSoftDeletes is true (enforced at construction).
$timestampFormat string 'Y-m-d H:i:s' date() format used for created / updated / deleted columns.

Soft deletes

When $useSoftDeletes = true:

  • read() automatically filters to rows where $deletedField IS NULL.
  • delete() sets $deletedField to the current timestamp instead of issuing a DELETE.
  • Pass purge: true to bypass soft-delete and remove the row for real:
    $posts->delete(['id' => 5], purge: true);
  • Use onlyDeleted() to read soft-deleted rows on the next read():
    foreach ($posts->onlyDeleted()->read()->rows() as $deleted) {
        // …
    }
    The flag is consumed by the next read and reverts afterwards.

update() and updateBatch() automatically add $deletedField IS NULL to avoid resurrecting soft-deleted rows.

Entities, accessors, and mutators

Entity stores values in an internal attribute bag ($attributes) and exposes them through the magic __get / __set accessors. For column post_title, you can define:

class PostEntity extends \InitORM\ORM\Entity
{
    public function getPostTitleAttribute(mixed $value): mixed
    {
        return ucwords((string) $value);
    }

    public function setPostTitleAttribute(mixed $value): void
    {
        $this->setAttribute('post_title', strtolower((string) $value));
    }
}
  • Accessor receives the stored attribute value as its single argument.
  • Mutator must write back via $this->setAttribute('post_title', …). Plain $this->post_title = … from inside a class method bypasses __set and creates a dynamic property (deprecated in PHP 8.2+, fatal in a future PHP version), so the value would never reach the attribute bag.
  • toArray() / getAttributes() return the raw attribute bag.
  • getOriginal() returns a snapshot captured at construction time; call syncOriginal() to refresh it after a save.

Permission gates

Each operation is gated by a flag — flip any to false and the matching typed exception fires:

class ReadOnlyConfig extends \InitORM\ORM\Model
{
    protected string $schema    = 'configuration';
    protected bool   $writable  = false;
    protected bool   $updatable = false;
    protected bool   $deletable = false;
}

(new ReadOnlyConfig())->create([...]); // throws WritableException

All four — WritableException, ReadableException, UpdatableException, DeletableException — extend ModelException, so a single catch handles all of them.

Multiple connections

Models bind to the shared DB facade by default. Set $credentials on a subclass to give it its own connection:

class ReportsModel extends \InitORM\ORM\Model
{
    protected string $schema = 'events';

    protected ?array $credentials = [
        'dsn'      => 'pgsql:host=reports.internal;dbname=reports',
        'username' => 'reports_ro',
        'password' => '',
        'driver'   => 'pgsql',
    ];
}

$credentials is passed through to InitORM\Database\Facade\DB::connect(), which builds a fresh Database (and underlying connection) without touching the shared facade slot.

Testing

The library ships with a comprehensive PHPUnit 10 suite that exercises the model against an in-memory SQLite database. Patterns for testing your own models live in tests/Support/AbstractModelTestCase.php.

Run the full quality suite locally:

composer qa   # phpcs + phpstan + phpunit

Individual targets:

composer test         # phpunit
composer cs           # phpcs
composer cs-fix       # phpcbf
composer stan         # phpstan analyse

Documentation

Deeper, code-first guides live under docs/:

Contributing

Contributions are welcome. The general flow is:

  1. Fork and branch off master.
  2. Add tests for the behaviour you change (see tests/ — SQLite in-memory, fast, dependency-free).
  3. Run the full quality suite locally:
    composer qa
  4. Open a PR — CI runs the same suite across PHP 8.1–8.4.

By submitting a contribution you agree to license it under the MIT License.

Credits

License

Released under the MIT License.