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.
- 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
composer require duyler/ormCreate 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
}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,
]);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!Start entity declaration:
Entity::declare(Product::class)Specify database connection:
->database('default')Define table name:
->table('products')Set primary key column:
->primaryKey('id')Map entity properties to table columns:
->columns([
'propertyName' => 'column_name',
'userId' => 'user_id',
])Define type casting for properties:
->typecast([
'id' => 'uuid',
'price' => 'int',
'isActive' => 'bool',
'createdAt' => 'datetime',
])Register custom typecast handlers:
->typecastHandler([
UuidTypecast::class,
MoneyTypecast::class,
])Specify custom repository class:
->repository(ProductRepository::class)Define relationships between entities:
->relations([
'items' => [
'type' => 'hasMany',
'target' => OrderItem::class,
'foreignKey' => 'order_id',
],
'user' => [
'type' => 'belongsTo',
'target' => User::class,
],
])Entity Behaviors provide automatic functionality for your entities without polluting domain classes with infrastructure code.
Automatically set creation timestamp:
Entity::declare(Product::class)
->createdAt() // Uses 'createdAt' field by default
// or with custom field name
->createdAt('created', 'created_at');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);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();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 controlAvailable 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
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
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
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()
);
}
}Add any CycleORM listener classes:
use Cycle\ORM\Entity\Behavior\Listener;
Entity::declare(Product::class)
->listeners([
Listener\CreatedAt::class,
Listener\UpdatedAt::class,
CustomListener::class,
]);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([/* ... */]);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]]);$product = new Product();
$product->setTitle('iPhone 15 Pro');
$product->setPrice(99900);
$orm->persist($product);
$orm->run();$product = $repository->findByPK($id);
$product->setPrice(89900);
$orm->persist($product);
$orm->run();$product = $repository->findByPK($id);
$orm->delete($product);
$orm->run();$products = $repository->select()
->where('price', '>', 1000)
->where('isActive', true)
->orderBy('createdAt', 'DESC')
->limit(10)
->fetchAll();namespace App\Domain\Repository;
use App\Domain\Entity\Product;
interface ProductRepositoryInterface
{
public function findBySlug(string $slug): ?Product;
public function findActive(): array;
}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();
}
}Entity::declare(Product::class)
->repository(ProductRepository::class)
// ... other configuration./bin/do orm:migrations:generate./bin/do orm:migrations:up./bin/do orm:migrations:downnamespace 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();
}
}./bin/do orm:fixtures:loadConfigure 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',
),
),
],
],
];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,
]);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;
}
}All ORM configuration in one place:
// build/entities.php
Entity::declare(Product::class)->...
Entity::declare(Order::class)->...
Entity::declare(User::class)->...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');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());
}// Recommended
->uuid7('id')
// Avoid UUID v4 (bad for index performance)
->uuid4('id')Entity::declare(Entity::class)
->createdAt()
->updatedAt()// For financial operations, orders, inventory
Entity::declare(Order::class)
->optimisticLock(rule: 'increment')// For GDPR compliance and data retention
Entity::declare(User::class)
->softDelete()// Good: Separate class with DI support
->eventListener(ComplexAuditListener::class)
// Avoid: Complex closures in hooks
->hook(fn($e) => /* 100 lines of code */, /* ... */)// 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([/* ... */])Problem: createdAt, updatedAt, or other behaviors don't set values.
Solution: Ensure EventDrivenCommandGenerator is registered in your ORM configuration. This package handles this automatically.
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
Problem: UUID field is null after persist.
Solutions:
- Remove manual UUID generation from entity constructor
- Set
nullable: falsein uuid behavior - Ensure
UuidTypecastis registered
- PHP 8.2+
- Duyler Framework
- CycleORM 2.x
- cycle/entity-behavior 1.7+
# Run tests
composer test
# Run static analysis
composer psalm
# Fix code style
composer cs-fixMIT