Cycle ORM patterns, configuration and common pitfalls for Symfony integration
docs/adrs/ADR-007-cycle-orm-over-doctrine.mdCycle ORM se usa SOLO en la capa de Infrastructure. El Domain NO conoce el ORM.
Domain/ -> Entidades puras, Repository interfaces
Infrastructure/
Persistence/
Cycle/
Entity/ -> Entidades Cycle (anémicas, propiedades públicas)
Repository/ -> Implementaciones de repos del Domain
Mapper/ -> Conversión Domain <-> Cycle Entity
OrmFactory.php
DatabaseFactory.php
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
#[Entity(table: 'categories')]
class CategoryEntity
{
#[Column(type: 'uuid', primary: true)]
public string $id = '';
#[Column(type: 'string(50)')]
public string $name = '';
#[Column(type: 'integer', default: 0)]
public int $displayOrder = 0;
}
// SIN typecast: devuelve string crudo, NO array
#[Column(type: 'json')]
public array $pictogramIds = []; // BUG: sera string
// CON typecast: devuelve array correctamente
#[Column(type: 'json', typecast: 'json')]
public array $pictogramIds = []; // CORRECTO: sera array
use Cycle\Annotated\Annotation\Relation\BelongsTo;
#[BelongsTo(target: CategoryEntity::class, innerKey: 'categoryId')]
public ?CategoryEntity $category = null;
use Cycle\ORM\EntityManagerInterface;
use Cycle\ORM\Select\Repository;
final class CycleCategoryRepository implements CategoryRepository
{
/** @param Repository<CategoryEntity> $repository */
public function __construct(
private readonly Repository $repository,
private readonly EntityManagerInterface $entityManager
) {}
public function findById(CategoryId $id): ?Category
{
$entity = $this->repository->findByPK($id->value());
if (!$entity instanceof CategoryEntity) {
return null;
}
return CategoryMapper::toDomain($entity);
}
public function save(Category $category): void
{
$entity = CategoryMapper::toEntity($category);
$this->entityManager->persist($entity);
$this->entityManager->run();
}
}
use Cycle\Database\Injection\Fragment;
// Para funciones SQL como LOWER, UPPER, COALESCE:
$entities = $this->repository
->select()
->where(new Fragment('LOWER("label") LIKE ?', "%{$query}%"))
->limit($limit)
->fetchAll();
final class CategoryMapper
{
public static function toDomain(CategoryEntity $entity): Category
{
return new Category(
CategoryId::fromString($entity->id),
$entity->name,
$entity->icon,
$entity->colorHex,
$entity->displayOrder
);
}
public static function toEntity(Category $domain): CategoryEntity
{
$entity = new CategoryEntity();
$entity->id = $domain->id()->value();
$entity->name = $domain->name();
// ... mapear todos los campos
return $entity;
}
}