- About
- Features
- Requirements
- Database Compatibility
- Installation
- Quick Start
- Documentation
- Examples
- API Reference
- Contributing
- License
ExtendOrm is a lightweight, easy-to-use Object-Relational Mapping (ORM) library for PHP 8.1+. It provides a simple yet powerful way to interact with your database using PHP objects, with support for relationships, query building, and transactions.
Perfect for developers who want ORM functionality without the complexity and overhead of heavier solutions like Doctrine or Eloquent.
- 🚀 Lightweight & Fast - Minimal overhead, no bloat
- 🔧 PHP Attributes - Clean, modern syntax for model definitions
- 🔗 Relationships - HasOne, HasMany, BelongsTo support
- 📝 CRUD Operations - Simple save, find, update, delete methods
- 🔍 Query Builder - Fluent interface with
IN,BETWEENandORconditions - ⚡ Eager Loading -
with()preloads a relation in one query for the whole result set - 🔒 Transactions - Automatic rollback on errors, with nested savepoints
- 📦 Pagination - Built-in support for paginated results
- 🧮 Dirty Tracking -
save()writes only the columns you actually changed - 🗄️ Dialect Layer - Identifier quoting follows the active PDO driver
- 🎯 Type-Safe - Leverages PHP's type system
- 💾 PDO-Based - Built on plain PDO; MySQL/MariaDB fully supported, other drivers portable but unverified
- PHP 8.1 or higher
- PDO extension enabled
- Database: MySQL or MariaDB (fully supported)
⚠️ Important Note: Identifiers are quoted using the active PDO driver's convention and paging uses the portableLIMIT n OFFSET mform. MariaDB (in CI) and SQLite (by default) are both exercised by the test suite; PostgreSQL should work but has no integration test yet. See Database Compatibility below.
| Database | Status | Notes |
|---|---|---|
| MariaDB | ✅ Verified | The whole suite passes against MariaDB 12.3, and against MariaDB in CI |
| MySQL | ✅ Expected | Not separately verified — the CI service is MariaDB, which shares MySQL's driver |
| SQLite | ✅ Verified | Backs the default in-memory test database |
| PostgreSQL | Quoting and paging are portable, but there is no integration test yet | |
| SQL Server | ❌ Not Tested | Unconfirmed compatibility |
| Oracle | ❌ Not Tested | Unconfirmed compatibility |
Identifier quoting is delegated to a Dialect resolved from the PDO connection:
| Driver | Dialect | Identifiers are quoted with |
|---|---|---|
mysql |
MySqlDialect |
backticks |
| anything else | AnsiDialect |
double quotes |
Pass your own implementation if you need different behaviour:
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilder2;
use FishyBoat21\ExtendOrm\Dialect\MySqlDialect;
$qb = new QueryBuilder2($pdo, new MySqlDialect());The builder emits portable SQL for everything it constructs: identifier quoting follows the active PDO driver, and paging uses LIMIT n OFFSET m. The items below concern your own schema and raw SQL:
-- Identifier quoting is emitted per driver
`column_name` -- MySQL / MariaDB
"column_name" -- SQLite / PostgreSQL
-- Auto-increment
AUTO_INCREMENT
-- Date/Time functions
NOW(), CURDATE()If you hand-write raw SQL alongside the ORM, keep the following in mind. The ORM's own queries already handle the first two:
-
LIMIT/OFFSET Syntax
-- MySQL: LIMIT offset, count LIMIT 0, 10 -- PostgreSQL/SQLite: LIMIT count OFFSET offset LIMIT 10 OFFSET 0
-
Identifier Quoting
-- MySQL: backticks `column_name` -- PostgreSQL/SQLite: double quotes "column_name"
-
Auto-increment Primary Keys
-- MySQL: AUTO_INCREMENT id INT AUTO_INCREMENT PRIMARY KEY -- PostgreSQL: SERIAL id SERIAL PRIMARY KEY -- SQLite: AUTOINCREMENT id INTEGER PRIMARY KEY AUTOINCREMENT
composer require fishyboat21/extendormNote: every release so far is a pre-release (
2.0.0-alpha.x), which Composer's default stability will refuse. Either allow pre-releases for this package:composer require fishyboat21/extendorm:^2.0@alphaor set
"minimum-stability": "alpha"in your owncomposer.json.
- Clone the repository:
git clone https://github.com/FishyBoat21/ExtendOrm.git- Include the autoloader in your project:
require_once 'vendor/autoload.php';- Or manually load the classes:
spl_autoload_register(function ($class) {
$prefix = 'FishyBoat21\\ExtendOrm\\';
$base_dir = __DIR__ . '/ExtendOrm/src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require $file;
}
});<?php
require_once 'vendor/autoload.php';
use FishyBoat21\ExtendOrm\Database;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilder2;
use FishyBoat21\ExtendOrm\Attribute\Table;
use FishyBoat21\ExtendOrm\Attribute\Column;
use FishyBoat21\ExtendOrm\Attribute\PrimaryKey;
use FishyBoat21\ExtendOrm\Model;
use PDO;
// 1. Setup database connection (MySQL example)
$pdo = new PDO(
'mysql:host=localhost;dbname=mydb;charset=utf8mb4',
'username',
'password',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// For PostgreSQL:
// $pdo = new PDO('pgsql:host=localhost;dbname=mydb', 'username', 'password');
// For SQLite:
// $pdo = new PDO('sqlite:/path/to/database.sqlite');
// 2. Boot the ORM
Database::Boot($pdo);
// 3. Create query builder
$qb = new QueryBuilder2();
// 4. Define your model
#[Table('users')]
class User extends Model {
#[PrimaryKey]
#[Column('id')]
public int $id;
#[Column('username')]
public string $username;
#[Column('email')]
public string $email;
}
// 5. Create and save a record
$user = new User($qb);
$user->username = 'johndoe';
$user->email = 'john@example.com';
$user->save();
// 6. Find records
use FishyBoat21\ExtendOrm\Criteria;
use FishyBoat21\ExtendOrm\Criterion;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilderOperator;
$criteria = new Criteria();
$criteria->Add(new Criterion('id', QueryBuilderOperator::Equals, 1));
$foundUser = User::FindOne($criteria, $qb);
echo $foundUser->username; // Output: johndoeInitialize the database connection using the singleton Database class:
use FishyBoat21\ExtendOrm\Database;
use PDO;
// MySQL
$pdo = new PDO(
'mysql:host=localhost;dbname=mydb;charset=utf8mb4',
'username',
'password',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// PostgreSQL
// $pdo = new PDO(
// 'pgsql:host=localhost;dbname=mydb',
// 'username',
// 'password',
// [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
// );
// SQLite
// $pdo = new PDO('sqlite:/path/to/database.sqlite');
// Boot the ORM with your PDO connection
Database::Boot($pdo);
// Get instance later in your code
$db = Database::GetInstance();
$connection = $db->GetConnection();Extend the abstract Model class and use PHP attributes to map your database tables:
use FishyBoat21\ExtendOrm\Model;
use FishyBoat21\ExtendOrm\Attribute\Table;
use FishyBoat21\ExtendOrm\Attribute\Column;
use FishyBoat21\ExtendOrm\Attribute\PrimaryKey;
#[Table('users')]
class User extends Model {
#[PrimaryKey]
#[Column('id')]
public int $id;
#[Column('username')]
public string $username;
#[Column('email')]
public string $email;
#[Column('created_at')]
public ?string $created_at = null;
}| Attribute | Description | Example |
|---|---|---|
#[Table('name')] |
Defines the database table name | #[Table('users')] |
#[Column('field')] |
Maps property to database column | #[Column('user_name')] |
#[PrimaryKey] |
Marks the primary key field | #[PrimaryKey] #[Column('id')] |
#[Relation(...)] |
Defines relationships | See below |
ExtendOrm supports three relationship types using the #[Relation] attribute.
use FishyBoat21\ExtendOrm\Attribute\Relation\RelationType;
RelationType::HasOne // One-to-one
RelationType::HasMany // One-to-many
RelationType::BelongsTo // Many-to-oneA user has one profile:
use FishyBoat21\ExtendOrm\Attribute\Relation;
use FishyBoat21\ExtendOrm\Attribute\Relation\RelationType;
#[Table('users')]
class User extends Model {
#[PrimaryKey]
#[Column('id')]
public int $id;
#[Column('username')]
public string $username;
#[Relation(
type: RelationType::HasOne,
target: Profile::class,
foreignKey: 'user_id',
localKey: 'id',
ownerKey: 'id'
)]
public ?Profile $profile;
}A user has many posts:
#[Table('users')]
class User extends Model {
#[PrimaryKey]
#[Column('id')]
public int $id;
#[Relation(
type: RelationType::HasMany,
target: Post::class,
foreignKey: 'user_id',
localKey: 'id',
ownerKey: 'id'
)]
public array $posts;
}A post belongs to a user:
#[Table('posts')]
class Post extends Model {
#[PrimaryKey]
#[Column('id')]
public int $id;
#[Column('user_id')]
public int $user_id;
#[Relation(
type: RelationType::BelongsTo,
target: User::class,
foreignKey: 'user_id',
localKey: 'user_id',
ownerKey: 'id'
)]
public ?User $author;
}#[Table('posts')]
class Post extends Model {
#[PrimaryKey]
#[Column('id')]
public int $id;
#[Column('user_id')]
public int $user_id;
#[Column('category_id')]
public ?int $category_id = null;
#[Column('title')]
public string $title;
#[Column('content')]
public string $content;
#[Column('published_at')]
public ?string $published_at = null;
// BelongsTo: Post belongs to User (author)
#[Relation(
type: RelationType::BelongsTo,
target: User::class,
foreignKey: 'user_id',
localKey: 'user_id',
ownerKey: 'id'
)]
public ?User $author;
// BelongsTo: Post belongs to Category
#[Relation(
type: RelationType::BelongsTo,
target: Category::class,
foreignKey: 'category_id',
localKey: 'category_id',
ownerKey: 'id'
)]
public ?Category $category;
// HasMany: Post has many Comments
#[Relation(
type: RelationType::HasMany,
target: Comment::class,
foreignKey: 'post_id',
localKey: 'id',
ownerKey: 'id'
)]
public array $comments;
}Touching a relation runs a query the first time it is accessed, so iterating a
result set costs one extra query per model — the classic N+1. AddWith()
preloads relations for the whole set, one query per relation:
$criteria = (new Criteria())->AddWith('posts', 'profile');
foreach (User::FindMany($criteria, $qb) as $user) {
echo $user->posts[0]->title; // already loaded, no extra query
}The loop above costs 3 queries with AddWith — one for the users, one for all
their posts, one for all their profiles — against 1 + N without it.
FindOne() and Paging() honour AddWith() as well. Nested paths such as
AddWith('posts.author') are not supported yet.
$user = new User($qb);
$user->username = 'johndoe';
$user->email = 'john@example.com';
$user->save(); // Performs INSERT
echo $user->id; // Auto-generated primary keyuse FishyBoat21\ExtendOrm\Criteria;
use FishyBoat21\ExtendOrm\Criterion;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilderOperator;
// Find one record
$criteria = new Criteria();
$criteria->Add(new Criterion('id', QueryBuilderOperator::Equals, 1));
$user = User::FindOne($criteria, $qb);
// Find many records
$criteria = new Criteria();
$criteria->Add(new Criterion('email', QueryBuilderOperator::Like, '%@gmail.com'));
$users = User::FindMany($criteria, $qb);
foreach ($users as $user) {
echo $user->username . "\n";
}
// Pagination (limit, offset, criteria, queryBuilder)
$posts = Post::Paging(10, 20, $criteria, $qb); // Page 3, 10 per page// Find the record first
$criteria = new Criteria();
$criteria->Add(new Criterion('id', QueryBuilderOperator::Equals, 1));
$user = User::FindOne($criteria, $qb);
// Modify and save
$user->username = 'updated_name';
$user->email = 'newemail@example.com';
$user->save(); // Performs UPDATE (primary key exists)Only the columns you actually changed are written, so a value another writer updated after you loaded the row is not overwritten from your stale copy. Saving a model you did not edit issues no statement at all.
$criteria = new Criteria();
$criteria->Add(new Criterion('id', QueryBuilderOperator::Equals, 1));
$user = User::FindOne($criteria, $qb);
if ($user) {
$user->delete();
}use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilderOperator;
QueryBuilderOperator::Equals // =
QueryBuilderOperator::NotEqual // !=
QueryBuilderOperator::LessThan // <
QueryBuilderOperator::MoreThan // >
QueryBuilderOperator::LessThanEquals// <=
QueryBuilderOperator::MoreThanEquals// >=
QueryBuilderOperator::Like // LIKE
QueryBuilderOperator::NotLike // NOT LIKE
QueryBuilderOperator::Is // IS (for NULL checks)use FishyBoat21\ExtendOrm\Sort;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilderSortType;
$criteria->AddSort(new Sort('created_at', QueryBuilderSortType::Descending));
$criteria->AddSort(new Sort('name', QueryBuilderSortType::Ascending));use FishyBoat21\ExtendOrm\Criteria;
use FishyBoat21\ExtendOrm\Criterion;
use FishyBoat21\ExtendOrm\Sort;
$criteria = new Criteria();
// Add multiple conditions
$criteria->Add(new Criterion('status', QueryBuilderOperator::Equals, 'active'));
$criteria->Add(new Criterion('age', QueryBuilderOperator::MoreThanEquals, 18));
$criteria->Add(new Criterion('email', QueryBuilderOperator::Like, '%@example.com'));
// Add sorting
$criteria->AddSort(new Sort('created_at', QueryBuilderSortType::Descending));
$criteria->AddSort(new Sort('name', QueryBuilderSortType::Ascending));
// Execute query
$users = User::FindMany($criteria, $qb);use FishyBoat21\ExtendOrm\Criteria;
use FishyBoat21\ExtendOrm\Criterion;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilderOperator;
$criteria = new Criteria();
$criteria->Add(new Criterion('id', QueryBuilderOperator::In, [1, 2, 3]));
$criteria->Add(new Criterion('age', QueryBuilderOperator::Between, [18, 30]));
// AddOr() joins a condition with OR instead of AND.
$criteria->AddOr(new Criterion('role', QueryBuilderOperator::Equals, 'admin'));Note: only AND/OR connectors are supported — there are no nested groups yet, so standard SQL precedence applies.
Add(a),Add(b),AddOr(c)compiles toa AND b OR c, which SQL reads as(a AND b) OR c.
Execute multiple operations in a transaction with automatic rollback on error:
use FishyBoat21\ExtendOrm\Database;
$db = Database::GetInstance();
$db->Transaction(function($pdo) use ($qb) {
// All operations in this closure run in a transaction
$user = new User($qb);
$user->username = 'newuser';
$user->email = 'new@example.com';
$user->save();
$profile = new Profile($qb);
$profile->user_id = $user->id;
$profile->phone = '+1-555-0123';
$profile->save();
// If any exception occurs, everything rolls back automatically
// No need to manually commit or rollback
});<?php
require_once 'vendor/autoload.php';
use FishyBoat21\ExtendOrm\Database;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilder2;
use FishyBoat21\ExtendOrm\Model;
use FishyBoat21\ExtendOrm\Attribute\Table;
use FishyBoat21\ExtendOrm\Attribute\Column;
use FishyBoat21\ExtendOrm\Attribute\PrimaryKey;
use FishyBoat21\ExtendOrm\Attribute\Relation;
use FishyBoat21\ExtendOrm\Attribute\Relation\RelationType;
use FishyBoat21\ExtendOrm\Criteria;
use FishyBoat21\ExtendOrm\Criterion;
use FishyBoat21\ExtendOrm\QueryBuilder2\QueryBuilderOperator;
use PDO;
// Setup
$pdo = new PDO('mysql:host=localhost;dbname=blog', 'user', 'pass');
Database::Boot($pdo);
$qb = new QueryBuilder2();
// Define Models
#[Table('users')]
class User extends Model {
#[PrimaryKey] #[Column('id')] public int $id;
#[Column('username')] public string $username;
#[Column('email')] public string $email;
#[Relation(
type: RelationType::HasMany,
target: Post::class,
foreignKey: 'user_id',
localKey: 'id',
ownerKey: 'id'
)]
public array $posts;
}
#[Table('posts')]
class Post extends Model {
#[PrimaryKey] #[Column('id')] public int $id;
#[Column('user_id')] public int $user_id;
#[Column('title')] public string $title;
#[Column('content')] public string $content;
#[Column('published_at')] public ?string $published_at = null;
#[Relation(
type: RelationType::BelongsTo,
target: User::class,
foreignKey: 'user_id',
localKey: 'user_id',
ownerKey: 'id'
)]
public ?User $author;
}
// Create a user and post
$user = new User($qb);
$user->username = 'johndoe';
$user->email = 'john@example.com';
$user->save();
$post = new Post($qb);
$post->user_id = $user->id;
$post->title = 'My First Post';
$post->content = 'Hello, world!';
$post->published_at = date('Y-m-d H:i:s');
$post->save();
// Query with relationships
$criteria = new Criteria();
$criteria->Add(new Criterion('user_id', QueryBuilderOperator::Equals, $user->id));
$posts = Post::FindMany($criteria, $qb);
foreach ($posts as $post) {
echo $post->title . " by " . $post->author->username . "\n";
}// Find published posts with more than 100 views, sorted by date
$criteria = new Criteria();
$criteria->Add(new Criterion('published', QueryBuilderOperator::Equals, 1));
$criteria->Add(new Criterion('views', QueryBuilderOperator::MoreThan, 100));
$criteria->AddSort(new Sort('published_at', QueryBuilderSortType::Descending));
// Get page 3 with 10 items per page
$posts = Post::Paging(10, 20, $criteria, $qb);
foreach ($posts as $post) {
echo $post->title . "\n";
}| Method | Description | Returns |
|---|---|---|
save() |
Insert or update record based on primary key | self |
delete() |
Delete record from database | void |
FindOne(Criteria $criteria, IQueryBuilder2 $qb) |
Find single record matching criteria | ?static |
FindMany(Criteria $criteria, IQueryBuilder2 $qb) |
Find multiple records matching criteria | array |
Paging(int $limit, int $offset, Criteria $criteria, IQueryBuilder2 $qb) |
Get paginated results | array |
GetTableName() |
Get the table name for the model | string |
| Method | Description | Returns |
|---|---|---|
Boot(PDO $connection) |
Initialize the ORM with PDO connection | void |
GetInstance() |
Get the singleton database instance | Database |
GetConnection() |
Get the PDO connection | PDO |
Transaction(callable $function) |
Execute operations in a transaction | mixed |
| Method | Description | Returns |
|---|---|---|
Add(Criterion $criterion) |
Add a condition joined with AND | self |
AddOr(Criterion $criterion) |
Add a condition joined with OR | self |
AddSort(Sort $sort) |
Add sorting order | self |
AddWith(string ...$relations) |
Preload relations for the result set | self |
| Value | SQL Equivalent |
|---|---|
Equals |
= |
NotEqual |
!= |
LessThan |
< |
MoreThan |
> |
LessThanEquals |
<= |
MoreThanEquals |
>= |
Like |
LIKE |
NotLike |
NOT LIKE |
Is |
IS (NULL only — throws otherwise) |
IsNull |
IS NULL |
IsNotNull |
IS NOT NULL |
In / NotIn |
IN (...) / NOT IN (...) — value is a non-empty array |
Between / NotBetween |
BETWEEN ? AND ? — value is [minimum, maximum] |
| Value | Description |
|---|---|
Ascending |
ORDER BY ... ASC |
Descending |
ORDER BY ... DESC |
ExtendOrm/
├── src/
│ ├── Attribute/
│ │ ├── Column.php
│ │ ├── PrimaryKey.php
│ │ ├── Table.php
│ │ ├── Relation.php
│ │ └── Relation/
│ │ └── RelationType.php
│ ├── Dialect/
│ │ ├── Dialect.php
│ │ ├── AbstractDialect.php
│ │ ├── MySqlDialect.php
│ │ ├── AnsiDialect.php
│ │ └── DialectFactory.php
│ ├── QueryBuilder2/
│ │ ├── QueryBuilder2.php
│ │ ├── IQueryBuilder2.php
│ │ ├── QueryBuilderOperator.php
│ │ └── QueryBuilderSortType.php
│ ├── Model.php
│ ├── ModelMap.php
│ ├── Database.php
│ ├── Criteria.php
│ ├── Criterion.php
│ ├── Sort.php
│ └── ExtendORMException.php
├── tests/
│ ├── Models/ # Fixture models used by the suite
│ ├── Support/ # Test helpers (e.g. QuerySpy)
│ └── *Test.php
├── .github/workflows/ci.yml
├── composer.json
├── phpunit.xml
├── phpstan.neon
├── README.md
└── LICENSE
The suite runs against an in-memory SQLite database by default, so it needs no server:
composer test # PHPUnit
composer analyse # PHPStan (level 5)Point it at a MySQL/MariaDB server to run the identical suite there:
EXTENDORM_TEST_DSN='mysql:host=127.0.0.1;port=3306;dbname=extendorm_test;charset=utf8mb4' \
EXTENDORM_TEST_USER=root \
EXTENDORM_TEST_PASSWORD= \
composer testThe database named in the DSN must already exist — the suite creates and resets
its own tables inside it. Set EXTENDORM_TEST_EXPECT_DRIVER=mysql as well and a
run that silently fell back to SQLite fails instead of passing green.
CI runs the SQLite matrix on PHP 8.1–8.4 plus a MariaDB job, so both supported drivers are covered — see .github/workflows/ci.yml.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
- Follow PSR-12 coding standards
- Add tests for new features — see Testing;
composer testmust stay green - Update documentation as needed
- Keep commits atomic and descriptive
- Note: If adding support for non-MySQL databases, please include appropriate tests and documentation
This project is open-sourced software licensed under the MIT License.
MIT License
Copyright (c) 2024-2026 FishyBoat21
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
- Inspired by Laravel Eloquent and Doctrine ORM
- Built with ❤️ using PHP 8.1+ features (Attributes, Enums, Typed Properties)
- Thanks to all contributors and users!
- Issues: GitHub Issues
- Source: GitHub Repository
- Author: FishyBoat21
Made by FishyBoat21
⭐ Star this repo if you find it useful!