duyler / orm
Duyler Cycle ORM integration library
Installs: 36
Dependents: 3
Suggesters: 0
Security: 0
Stars: 0
Watchers: 1
Forks: 0
Open Issues: 0
pkg:composer/duyler/orm
Requires
- php: ^8.4
- cycle/entity-behavior: ^1.7
- cycle/entity-behavior-uuid: ^1.2
- cycle/migrations: ^4.2
- cycle/orm: ^2.10
- cycle/schema-migrations-generator: ^2.3
- duyler/builder: dev-main
- duyler/console: dev-main
- nette/php-generator: ^4.1
- ramsey/uuid: ^4.7
- symfony/serializer: ^7.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.30
- phpunit/phpunit: ^10.0
- vimeo/psalm: ^6.0
This package is auto-updated.
Last update: 2025-11-24 11:26:28 UTC
README
Duyler Cycle ORM Integration
A declarative ORM integration package for Duyler Framework based on CycleORM. This package provides a clean, attribute-free approach to entity definition using a fluent Builder API.
Features
- Declarative Entity Builder - Define entities without attributes in entity classes
- Entity Behaviors - Full support for CycleORM behaviors (timestamps, soft delete, optimistic lock, UUID generation, hooks)
- Clean Domain Layer - Keep your entities as pure POPOs (Plain Old PHP Objects)
- Console Commands - Built-in commands for migrations and fixtures
- Type Safety - Strict typing with 99%+ type coverage
- Flexible Configuration - Centralized entity configuration
- Custom Repositories - Easy integration of custom repository classes
Installation
composer require duyler/orm
Quick Start
1. Define Your Entity Class
Create a clean POPO without any ORM attributes:
namespace App\Domain\Entity; use DateTimeInterface; use Ramsey\Uuid\UuidInterface; class Product { private UuidInterface $id; private string $title; private int $price; private DateTimeInterface $createdAt; private DateTimeInterface $updatedAt; // Getters and setters... public function getId(): UuidInterface { return $this->id; } public function setTitle(string $title): self { $this->title = $title; return $this; } // ... other methods }
2. Declare Entity Using Builder
Configure your entity declaratively in build/entities.php:
use Duyler\ORM\Build\Entity; Entity::declare(Product::class) ->database('default') ->table('products') ->primaryKey('id') // Entity Behaviors ->uuid7('id') // Auto-generate UUID v7 ->createdAt() // Auto-set creation timestamp ->updatedAt() // Auto-update modification timestamp // Column mapping ->columns([ 'id' => 'id', 'title' => 'title', 'price' => 'price', 'createdAt' => 'created_at', 'updatedAt' => 'updated_at', ]) // Type casting ->typecast([ 'id' => 'uuid', 'title' => 'string', 'price' => 'int', 'createdAt' => 'datetime', 'updatedAt' => 'datetime', ]) ->typecastHandler([ UuidTypecast::class, ]);
3. Use in Your Application
use Cycle\ORM\ORMInterface; // In your action handler $product = new Product(); $product->setTitle('iPhone 15 Pro'); $product->setPrice(99900); $orm->persist($product); $orm->run(); // $product->id, $product->createdAt, $product->updatedAt // are set automatically by behaviors!
Entity Builder API
Basic Configuration
declare()
Start entity declaration:
Entity::declare(Product::class)
database()
Specify database connection:
->database('default')
table()
Define table name:
->table('products')
primaryKey()
Set primary key column:
->primaryKey('id')
columns()
Map entity properties to table columns:
->columns([ 'propertyName' => 'column_name', 'userId' => 'user_id', ])
typecast()
Define type casting for properties:
->typecast([ 'id' => 'uuid', 'price' => 'int', 'isActive' => 'bool', 'createdAt' => 'datetime', ])
typecastHandler()
Register custom typecast handlers:
->typecastHandler([
UuidTypecast::class,
MoneyTypecast::class,
])
repository()
Specify custom repository class:
->repository(ProductRepository::class)
Relations
Define relationships between entities:
->relations([ 'items' => [ 'type' => 'hasMany', 'target' => OrderItem::class, 'foreignKey' => 'order_id', ], 'user' => [ 'type' => 'belongsTo', 'target' => User::class, ], ])
Entity Behaviors
Entity Behaviors provide automatic functionality for your entities without polluting domain classes with infrastructure code.
Timestamps
createdAt()
Automatically set creation timestamp:
Entity::declare(Product::class) ->createdAt() // Uses 'createdAt' field by default // or with custom field name ->createdAt('created', 'created_at');
updatedAt()
Automatically update modification timestamp:
Entity::declare(Product::class) ->updatedAt() // Uses 'updatedAt' field by default // or with custom field and nullable option ->updatedAt('updated', 'updated_at', nullable: true);
Soft Delete
Mark records as deleted instead of removing them from database:
Entity::declare(Product::class) ->softDelete() // Uses 'deletedAt' field by default // or with custom field name ->softDelete('removed', 'removed_at');
Usage:
$orm->delete($product); // Sets deletedAt instead of deleting $orm->run(); // Soft-deleted entities are automatically excluded from queries $products = $orm->getRepository(Product::class)->findAll();
Optimistic Locking
Prevent race conditions during concurrent updates:
Entity::declare(Order::class) ->optimisticLock(rule: 'increment') // Auto-increment integer // Other strategies: // ->optimisticLock(rule: 'microtime') // Microtime string // ->optimisticLock(rule: 'datetime') // Datetime version // ->optimisticLock(rule: 'random-string') // Random string // ->optimisticLock(rule: 'manual') // Manual control
Available lock strategies:
increment- Auto-incrementing integer (default for int fields)microtime- Microtime string (default for string fields)datetime- Datetime-based version (default for datetime fields)random-string- Random string generationmanual- Manual version management
UUID Generation
Automatically generate UUIDs for primary keys:
Entity::declare(User::class) ->uuid7('id') // UUID v7 - RECOMMENDED (time-sortable) // or ->uuid4('id') // UUID v4 (random) // or ->uuid1('id') // UUID v1 (time-based with MAC)
Why UUID v7?
- Time-sortable for better index performance
- No MAC address leakage
- Compatible with database indexes
Custom Hooks
Execute custom logic on entity lifecycle events:
use Cycle\ORM\Entity\Behavior\Event\Mapper\Command; Entity::declare(Article::class) ->hook( callable: function (Command\OnCreate $event) { $entity = $event->entity; $entity->setSlug(Str::slug($entity->getTitle())); }, events: Command\OnCreate::class, ) // Multiple events ->hook( callable: fn(Command\OnUpdate $event) => Logger::log($event), events: [Command\OnUpdate::class, Command\OnDelete::class], );
Available events:
Command\OnCreate- Before entity creationCommand\OnUpdate- Before entity updateCommand\OnDelete- Before entity deletion
Event Listeners
Add custom listeners with dependency injection support:
Entity::declare(Product::class) ->eventListener(ProductAuditListener::class) // or with arguments ->eventListener(CustomListener::class, ['param' => 'value']);
Listener example:
class ProductAuditListener { public function __construct( private AuditService $auditService, private LoggerInterface $logger, ) {} public function __invoke(Command\OnUpdate $event): void { $this->auditService->logChanges( $event->entity, $event->state->getChanges() ); } }
Generic Listeners
Add any CycleORM listener classes:
use Cycle\ORM\Entity\Behavior\Listener; Entity::declare(Product::class) ->listeners([ Listener\CreatedAt::class, Listener\UpdatedAt::class, CustomListener::class, ]);
Combining Behaviors
All behaviors can be combined in any order:
Entity::declare(Order::class) ->database('default') ->table('orders') ->primaryKey('id') // UUID primary key ->uuid7('id') // Timestamps ->createdAt() ->updatedAt() // Soft delete ->softDelete() // Optimistic locking ->optimisticLock(rule: 'increment') // Custom hooks ->hook( fn(Command\OnCreate $e) => $e->entity->setStatus('new'), Command\OnCreate::class, ) // Audit listener ->eventListener(OrderAuditListener::class) // Standard configuration ->columns([/* ... */]) ->typecast([/* ... */]) ->relations([/* ... */]);
Working with ORM
Retrieving Entities
use Cycle\ORM\ORMInterface; // Get repository $repository = $orm->getRepository(Product::class); // Find by primary key $product = $repository->findByPK($id); // Find one by criteria $product = $repository->findOne(['slug' => 'iphone-15']); // Find all $products = $repository->findAll(); // Find with criteria $products = $repository->findAll(['price' => ['>' => 1000]]);
Creating Entities
$product = new Product(); $product->setTitle('iPhone 15 Pro'); $product->setPrice(99900); $orm->persist($product); $orm->run();
Updating Entities
$product = $repository->findByPK($id); $product->setPrice(89900); $orm->persist($product); $orm->run();
Deleting Entities
$product = $repository->findByPK($id); $orm->delete($product); $orm->run();
Query Builder
$products = $repository->select() ->where('price', '>', 1000) ->where('isActive', true) ->orderBy('createdAt', 'DESC') ->limit(10) ->fetchAll();
Custom Repositories
Define Repository Interface
namespace App\Domain\Repository; use App\Domain\Entity\Product; interface ProductRepositoryInterface { public function findBySlug(string $slug): ?Product; public function findActive(): array; }
Implement Repository
namespace App\Infrastructure\Repository; use Cycle\ORM\Select\Repository; use App\Domain\Repository\ProductRepositoryInterface; class ProductRepository extends Repository implements ProductRepositoryInterface { public function findBySlug(string $slug): ?Product { return $this->findOne(['slug' => $slug]); } public function findActive(): array { return $this->select() ->where('isActive', true) ->fetchAll(); } }
Register in Entity Declaration
Entity::declare(Product::class) ->repository(ProductRepository::class) // ... other configuration
Console Commands
Migrations
Create Migration
./bin/do orm:migrations:generate
Apply Migrations
./bin/do orm:migrations:up
Rollback Last Migration
./bin/do orm:migrations:down
Fixtures
Create Fixture
namespace App\Fixtures; use Cycle\ORM\ORMInterface; class ProductFixture { public function __construct( private ORMInterface $orm, ) {} public function load(): void { for ($i = 0; $i < 10; $i++) { $product = new Product(); $product->setTitle("Product $i"); $product->setPrice(rand(1000, 10000)); $this->orm->persist($product); } $this->orm->run(); } }
Load Fixtures
./bin/do orm:fixtures:load
Database Configuration
Configure database connections in config/db.php:
use Cycle\Database\Config\DatabaseConfig; use Cycle\Database\Config\PostgresDriverConfig; use Cycle\Database\Driver\Postgres\PostgresDriver; return [ DatabaseConfig::class => [ 'databases' => [ 'default' => [ 'connection' => 'postgres', ], ], 'connections' => [ 'postgres' => new PostgresDriverConfig( connection: new PostgresDriver( dsn: 'pgsql:host=localhost;dbname=myapp', username: 'user', password: 'pass', ), ), ], ], ];
Real-World Example
Complete entity with all features:
use Duyler\ORM\Build\Entity; use Cycle\ORM\Entity\Behavior\Event\Mapper\Command; Entity::declare(Order::class) ->database('default') ->table('orders') ->primaryKey('id') ->repository(OrderRepository::class) // Behaviors ->uuid7('id') ->createdAt('createdAt', 'created_at') ->updatedAt('updatedAt', 'updated_at') ->softDelete('deletedAt', 'deleted_at') ->optimisticLock('version', rule: 'increment') // Business logic hooks ->hook( callable: function (Command\OnCreate $event) { $order = $event->entity; $order->setOrderNumber( 'ORD-' . date('Y') . '-' . str_pad($order->getId(), 6, '0', STR_PAD_LEFT) ); $order->setStatus(OrderStatus::New); }, events: Command\OnCreate::class, ) // Validation on update ->hook( callable: function (Command\OnUpdate $event) { $order = $event->entity; if ($order->getTotal() < 0) { throw new InvalidOrderException('Order total cannot be negative'); } }, events: Command\OnUpdate::class, ) // Audit logging ->eventListener(OrderAuditListener::class) // Column mapping ->columns([ 'id' => 'id', 'orderNumber' => 'order_number', 'userId' => 'user_id', 'status' => 'status', 'total' => 'total', 'version' => 'version', 'createdAt' => 'created_at', 'updatedAt' => 'updated_at', 'deletedAt' => 'deleted_at', ]) // Type casting ->typecast([ 'id' => 'uuid', 'orderNumber' => 'string', 'userId' => 'uuid', 'status' => 'string', 'total' => 'int', 'version' => 'int', 'createdAt' => 'datetime', 'updatedAt' => 'datetime', 'deletedAt' => 'datetime', ]) // Relations ->relations([ 'user' => [ 'type' => 'belongsTo', 'target' => User::class, ], 'items' => [ 'type' => 'hasMany', 'target' => OrderItem::class, 'foreignKey' => 'order_id', ], ]) ->typecastHandler([ UuidTypecast::class, ]);
Benefits of Declarative Approach
Clean Domain Layer
Your entities remain pure PHP objects without infrastructure concerns:
// Clean POPO - no attributes, no ORM dependencies class Product { private UuidInterface $id; private string $title; private int $price; // Pure business logic public function applyDiscount(int $percent): void { $this->price = $this->price * (100 - $percent) / 100; } }
Centralized Configuration
All ORM configuration in one place:
// build/entities.php Entity::declare(Product::class)->... Entity::declare(Order::class)->... Entity::declare(User::class)->...
Flexibility
Easy to change mapping without modifying entity classes:
// Different mapping for the same entity Entity::declare(Product::class) ->database('primary') ->table('products'); // vs Entity::declare(Product::class) ->database('analytics') ->table('product_snapshots');
Testability
Entities are easier to test without ORM dependencies:
// Pure unit test - no database needed public function test_apply_discount(): void { $product = new Product(); $product->setPrice(1000); $product->applyDiscount(10); $this->assertEquals(900, $product->getPrice()); }
Best Practices
1. Use UUID v7 for Primary Keys
// Recommended ->uuid7('id') // Avoid UUID v4 (bad for index performance) ->uuid4('id')
2. Always Use Timestamps
Entity::declare(Entity::class) ->createdAt() ->updatedAt()
3. Use Optimistic Lock for Critical Data
// For financial operations, orders, inventory Entity::declare(Order::class) ->optimisticLock(rule: 'increment')
4. Soft Delete for User Data
// For GDPR compliance and data retention Entity::declare(User::class) ->softDelete()
5. Keep Complex Logic in Event Listeners
// Good: Separate class with DI support ->eventListener(ComplexAuditListener::class) // Avoid: Complex closures in hooks ->hook(fn($e) => /* 100 lines of code */, /* ... */)
6. Behavior Order Matters
// Recommended order Entity::declare(Entity::class) ->uuid7('id') // 1. UUID generation ->createdAt() // 2. Timestamps ->updatedAt() ->softDelete() // 3. Soft delete ->optimisticLock() // 4. Concurrency control ->hook(/* ... */) // 5. Custom hooks ->eventListener(/* ... */) // 6. Event listeners ->columns([/* ... */]) // 7. Standard config ->typecast([/* ... */])
Troubleshooting
Behaviors Not Working
Problem: createdAt, updatedAt, or other behaviors don't set values.
Solution: Ensure EventDrivenCommandGenerator is registered in your ORM configuration. This package handles this automatically.
Optimistic Lock Exceptions
Problem: Getting OptimisticLockException on every update.
Solutions:
- Ensure version field is in columns mapping
- Use correct type for the lock strategy (
intforincrement) - Reload entity after save to get updated version
UUID Not Generated
Problem: UUID field is null after persist.
Solutions:
- Remove manual UUID generation from entity constructor
- Set
nullable: falsein uuid behavior - Ensure
UuidTypecastis registered
Requirements
- PHP 8.2+
- Duyler Framework
- CycleORM 2.x
- cycle/entity-behavior 1.7+
Testing
# Run tests composer test # Run static analysis composer psalm # Fix code style composer cs-fix
License
MIT