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.
- PHP 8.1 or later
ext-pdo- One of
ext-pdo_mysql,ext-pdo_pgsql, orext-pdo_sqlitedepending on the database you target.
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.
composer require initorm/orminitorm/orm pulls in initorm/database, initorm/dbal, and initorm/query-builder transitively — you do not need to require them yourself.
<?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);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.
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. |
When $useSoftDeletes = true:
read()automatically filters to rows where$deletedField IS NULL.delete()sets$deletedFieldto the current timestamp instead of issuing a DELETE.- Pass
purge: trueto bypass soft-delete and remove the row for real:$posts->delete(['id' => 5], purge: true);
- Use
onlyDeleted()to read soft-deleted rows on the nextread():The flag is consumed by the next read and reverts afterwards.foreach ($posts->onlyDeleted()->read()->rows() as $deleted) { // … }
update() and updateBatch() automatically add $deletedField IS NULL to avoid resurrecting soft-deleted rows.
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__setand 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; callsyncOriginal()to refresh it after a save.
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 WritableExceptionAll four — WritableException, ReadableException, UpdatableException, DeletableException — extend ModelException, so a single catch handles all of them.
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.
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 + phpunitIndividual targets:
composer test # phpunit
composer cs # phpcs
composer cs-fix # phpcbf
composer stan # phpstan analyseDeeper, code-first guides live under docs/:
01-getting-started.md— install, bootstrap, the layered architecture.02-defining-models.md— every property, plus auto-schema derivation.03-entities.md— accessors, mutators, attribute bag, thesetAttributerule.04-crud-basics.md—create,read,update,delete, batch variants.05-soft-deletes.md—useSoftDeletes,onlyDeleted,ignoreDeleted,purge.06-timestamps.md— auto-filledcreated_at/updated_at/deleted_at.07-permission-gates.md—$writable/$readable/$updatable/$deletable.08-extending-the-builder.md— accessing the full query-builder API through the Model.09-multiple-connections.md—$credentials, theDBfacade, secondary connections.10-testing-models.md— how the package's own suite is wired and how to mirror it.
Contributions are welcome. The general flow is:
- Fork and branch off
master. - Add tests for the behaviour you change (see
tests/— SQLite in-memory, fast, dependency-free). - Run the full quality suite locally:
composer qa
- 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.
- Muhammet ŞAFAK —
<info@muhammetsafak.com.tr>
Released under the MIT License.