wscore / decaorm
a simple and small ORM
Requires
- php: >=8.0
- ext-pdo: *
- psr/container: ^2.0
Requires (Dev)
- ext-pdo_sqlite: *
- phpunit/phpunit: ^9.5
This package is auto-updated.
Last update: 2026-03-18 09:16:52 UTC
README
DecaORM is a simple, lightweight data-mapper ORM for PHP 8. It uses PHP 8 attributes to map entity classes to database tables and provides flexible data access via the repository pattern.
Support
PHP: 8.1, 8.2, 8.3, 8.4
Databases: SQLite, MySQL, PostgreSQL
Features
- Attribute mapping — Define mapping with attributes on the entity:
#[Table],#[Column],#[Id], etc. - Repository pattern — Data access logic lives in repositories for clearer, maintainable code.
- Relations — One-to-one, one-to-many, and many-to-many via
#[HasOne],#[HasMany],#[BelongsTo],#[BelongsToOne],#[ManyToMany]. - Lazy loading — Call
load()inside a getter so the relation is loaded on first access. - Batch loading — Load relations for many entities in one query to avoid N+1.
- Identity map — Ensures a single in-memory instance per primary key.
- Dirty tracking — Only changed fields are updated, reducing unnecessary UPDATEs.
- Lifecycle —
#[CreatedAt]and#[UpdatedAt]for automatic timestamps. - Hydrator — Default
AttributeHydratorplus support for custom hydrators. - Explicit design — Behavior is predictable from reading the code.
Not supported
- Unit of Work (UoW) — No automatic save ordering or deferred flush. You must save in dependency order (e.g. parent before children).
- Cascade delete — Deleting a parent does not delete related children; delete them explicitly.
- Eager loading — Relations are not loaded automatically. Use
load()(or lazy loading in getters) when needed.
License
MIT License
Installation
Install with Composer:
composer require wscore/decaorm
Documentation
Japanese: README.md | Entity mapping | SQL builders
Quick start
1. Define an entity
Use attributes from WScore\DecaORM\Attribute and implement EntityInterface with EntityTrait.
use WScore\DecaORM\Attribute\Column; use WScore\DecaORM\Attribute\GeneratedValue; use WScore\DecaORM\Attribute\HasMany; use WScore\DecaORM\Attribute\Id; use WScore\DecaORM\Attribute\Repository; use WScore\DecaORM\Attribute\Table; use WScore\DecaORM\Contracts\EntityInterface; use WScore\DecaORM\Trait\EntityTrait; #[Table(name: 'users')] #[Repository(UserRepository::class)] class User implements EntityInterface { use EntityTrait; #[Id] #[GeneratedValue] #[Column(name: 'user_id')] private ?int $id = null; #[Column(name: 'name')] private string $name = ''; public function getId(): int { return (int) $this->id; } public function getName(): string { return $this->name; } }
See entity-en.md for property types and attributes.
2. Implement a repository
Extend AbstractRepository for your entity.
use PDO; use WScore\DecaORM\AbstractRepository; use WScore\DecaORM\AttributeHydrator; /** * @extends AbstractRepository<User> */ class UserRepository extends AbstractRepository { public function __construct(OrmManager $manager) { $this->setUpRepository($manager, null, User::class); } }
3. Basic CRUD
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass'); $manager = OrmManager::initialize($container); $userRepo = new UserRepository($manager); // Create $user = new User(); $user->fill(['name' => 'Deca Taro']); $user->save(); // INSERT; ID is auto-generated echo $user->getId(); // Read $user = $userRepo->findById(1); if ($user) { echo $user->getName(); } // Update $user->setName('Deca Jiro'); // or setRaw('name', 'Deca Jiro') $user->save(); // UPDATE (ID present) // Delete $user->delete();
Relations
Relations are not loaded automatically. Call load() explicitly or use lazy loading in getters.
Parent entity (e.g. User)
class User implements EntityInterface { // One-to-many: targetEntity = related class, mappedBy = property name on the other side #[HasMany(targetEntity: Post::class, mappedBy: 'user')] private ?array $posts = null; public function getPosts(): EntityCollection { return $this->load('posts'); } /** * @param EntityCollection<Post>|null $posts */ public function setPosts(?EntityCollection $posts): void { $this->associate('posts', $posts); } public function addPost(Post $post): void { $this->addHasMany('posts', $post); } public function removePost(Post $post): void { $this->removeHasMany('posts', $post); } }
Child entity (e.g. Post)
use WScore\DecaORM\Attribute\BelongsTo; use WScore\DecaORM\Attribute\Column; use WScore\DecaORM\Attribute\GeneratedValue; use WScore\DecaORM\Attribute\Id; use WScore\DecaORM\Attribute\Repository; use WScore\DecaORM\Attribute\Table; use WScore\DecaORM\Contracts\EntityInterface; use WScore\DecaORM\Trait\EntityTrait; #[Table(name: 'posts')] #[Repository(PostRepository::class)] class Post implements EntityInterface { use EntityTrait; #[Id] #[GeneratedValue] #[Column(name: 'post_id')] private ?int $id = null; #[Column(name: 'user_id')] private ?int $user_id = null; // FK column for User #[Column(name: 'title')] private string $title = ''; // Many-to-one: foreignKey = FK property, inversedBy = property on the parent #[BelongsTo(targetEntity: User::class, foreignKey: 'user_id', inversedBy: 'posts')] private ?User $user = null; }
Lazy loading
Calling load($relationName) in a getter loads the relation on first access and returns the cached value afterward.
$user = $userRepo->findById(1); $posts = $user->load('posts'); // SELECT runs here $posts = $user->load('posts'); // Returns cached value
Associating relations with associate()
Use the public API associate($relationName, $targetOrTargets) to set relations. DecaORM updates FKs and inverse references accordingly.
- BelongsTo / BelongsToOne / HasOne: pass a single entity or
null. - HasMany / ManyToMany: pass an
EntityCollectionor iterable, ornull.
Note: associate() only updates in-memory links. For ManyToMany, call the repository’s syncManyToMany($entity, $relationName) to persist the join table.
$post->associate('user', $user); $user->associate('roles', $roleCollection);
Batch loading (avoiding N+1)
Load a relation for many entities in one query.
$users = $userRepo->sqlQuery() ->whereIn('user_id', [1, 2, 3, 4, 5]) ->getResult(); $posts = $users->load('posts'); // One query for all users' posts foreach ($users as $user) { foreach ($user->getPosts() as $post) { echo $post->getRaw('title'); } }
EntityCollection
Use the collection for filtering, batch loading, and saving.
$users = $userRepo->sqlQuery()->...->getResult(); $posts = $users->load('posts'); $comments = $posts->load('comments'); $posts->save(); $comments->save();
Many-to-many
Many-to-many uses a join table. Specify the table and column names in the #[ManyToMany] attribute (no separate entity/repository for the join table).
class User implements EntityInterface { /** @var EntityCollection<Role>|null */ #[ManyToMany( targetEntity: Role::class, joinTable: 'user_role', foreignKey: 'user_id', inverseForeignKey: 'role_id' )] private ?EntityCollection $roles = null; public function getRoles(): EntityCollection { return $this->load('roles'); } /** * @param EntityCollection<Role>|null $roles */ public function setRoles(?EntityCollection $roles): void { $this->associate('roles', $roles); } }
Loading: use $user->load('roles') or batch load: $users->load('roles').
Syncing: use ManyToManyTrait in the repository and call syncManyToMany() after changing the relation on the entity. It will INSERT/DELETE rows in the join table as needed.
use WScore\DecaORM\Trait\ManyToManyTrait; class UserRepository extends AbstractRepository { use ManyToManyTrait; } $user->getRoles()->add($role1); $user->getRoles()->add($role2); $user->getRoles()->delEntity($role3); $userRepo->syncManyToMany($user, 'roles');
Saving and dependency order
DecaORM has no Unit of Work. You must save in dependency order (e.g. parent before children).
- Create entities and associate them in memory in any order.
- Save parent first so its ID is set; then save children (FKs are set automatically for BelongsTo/HasMany).
- Use transactions when saving multiple entities.
Example with transaction:
OrmManager::transaction(function () use ($userRepo, $postRepo) { $user = new User(); $user->setName('John Doe'); $post = new Post(); $post->setTitle('My Post'); $user->setPosts(new EntityCollection([$post], $postRepo)); $userRepo->save($user); // Parent first $postRepo->save($post); // Then children });
Default container (once at app bootstrap)
use WScore\DecaORM\OrmManager; $manager = OrmManager::initialize($container);
Scoped execution (per-tenant container)
For “one request = one tenant”, wrap the request in runWithContainer() after resolving the tenant’s container:
return $manager->runWithContainer($tenantContainer, function () use ($handler, $request) { return $handler->handle($request); });
Summary of limitations
- Save order — No UoW; save parents before children.
- Transactions — Use them when saving multiple entities.
- Relations — Never auto-loaded; call
load()when needed. - Foreign keys — Use DB constraints for integrity.
- New vs update — Determined by presence of ID; you can also call
insertEntity()orupdateEntity()explicitly.