| name | cycle-orm |
| description | Cycle ORM patterns, configuration and common pitfalls for Symfony integration |
| license | MIT |
| compatibility | opencode |
| metadata | {"type":"orm","framework":"cycle-orm","language":"php"} |
SKILL: Cycle ORM (Symfony Integration)
Tech Stack
- ORM: Cycle ORM v2
- Framework: Symfony 7 (sin bundle oficial, configuración manual)
- Database: PostgreSQL
- ADR: Ver
docs/adrs/ADR-007-cycle-orm-over-doctrine.md
Arquitectura
Cycle 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
Patrones del Proyecto
A. Entidad Cycle (Infrastructure)
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;
}
B. JSON Columns (Typecast obligatorio)
#[Column(type: 'json')]
public array $pictogramIds = [];
#[Column(type: 'json', typecast: 'json')]
public array $pictogramIds = [];
C. Relaciones
use Cycle\Annotated\Annotation\Relation\BelongsTo;
#[BelongsTo(target: CategoryEntity::class, innerKey: 'categoryId')]
public ?CategoryEntity $category = null;
D. Repository (envuelve Cycle Repository)
use Cycle\ORM\EntityManagerInterface;
use Cycle\ORM\Select\Repository;
final class CycleCategoryRepository implements CategoryRepository
{
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();
}
}
E. Fragment para SQL crudo
use Cycle\Database\Injection\Fragment;
$entities = $this->repository
->select()
->where(new Fragment('LOWER("label") LIKE ?', "%{$query}%"))
->limit($limit)
->fetchAll();
F. Mapper (Domain <-> Entity)
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();
return $entity;
}
}
Configuración DI (Symfony)
cycle.yaml - Registro manual de servicios
services:
Cycle\Database\DatabaseManager:
factory: ['App\Infrastructure\Persistence\Cycle\DatabaseFactory', 'create']
arguments: ['%env(DATABASE_URL)%']
Cycle\ORM\ORM:
factory: ['App\Infrastructure\Persistence\Cycle\OrmFactory', 'create']
arguments:
- '@Cycle\Database\DatabaseManager'
- '%kernel.project_dir%/src/Infrastructure/Persistence/Cycle/Entity'
Cycle\ORM\EntityManagerInterface:
class: Cycle\ORM\EntityManager
arguments: ['@Cycle\ORM\ORM']
cycle.repository.category:
class: Cycle\ORM\Select\Repository
factory: ['@Cycle\ORM\ORM', 'getRepository']
arguments: [App\Infrastructure\Persistence\Cycle\Entity\CategoryEntity]
App\Infrastructure\Persistence\Cycle\Repository\CycleCategoryRepository:
arguments:
$repository: '@cycle.repository.category'
$entityManager: '@Cycle\ORM\EntityManagerInterface'
repositories.yaml - Alias de interfaces Domain
services:
App\Domain\Category\Repository\CategoryRepository:
alias: App\Infrastructure\Persistence\Cycle\Repository\CycleCategoryRepository
public: true
Errores Comunes y Soluciones
1. JSON column devuelve string en vez de array
Problema: #[Column(type: 'json')] sin typecast devuelve string crudo.
Solución: Siempre añadir typecast: 'json':
#[Column(type: 'json', typecast: 'json')]
public array $data = [];
2. LOWER/UPPER no funciona en WHERE
Problema: ->where('LOWER(label)', 'LIKE', $query) genera SQL inválido.
Solución: Usar Fragment:
->where(new Fragment('LOWER("label") LIKE ?', "%{$query}%"))
3. Default values desincronizados
Problema: Default en PHP difiere del default en la BD.
Solución: Definir en AMBOS lugares:
#[Column(type: 'string(7)', default: '#6B7280')]
public string $colorHex = '#6B7280';
4. Repository no se resuelve en DI
Problema: Cycle repositories no son autowireables (Symfony no sabe crearlos).
Solución: Registrar como factory en cycle.yaml:
cycle.repository.pictogram:
class: Cycle\ORM\Select\Repository
factory: ['@Cycle\ORM\ORM', 'getRepository']
arguments: [App\Infrastructure\Persistence\Cycle\Entity\PictogramEntity]
App\Infrastructure\Persistence\Cycle\Repository\CyclePictogramRepository:
arguments:
$repository: '@cycle.repository.pictogram'
$entityManager: '@Cycle\ORM\EntityManagerInterface'
App\Domain\Pictogram\Repository\PictogramRepository:
alias: App\Infrastructure\Persistence\Cycle\Repository\CyclePictogramRepository
5. Entidad no encontrada por el schema compiler
Problema: Nueva entidad Cycle no es detectada por el ORM.
Solución: Verificar que:
- El archivo está en el directorio configurado en
OrmFactory (src/Infrastructure/Persistence/Cycle/Entity)
- Tiene el atributo
#[Entity(table: 'xxx')]
- Ejecutar
php bin/console cache:clear
6. persist() no guarda cambios
Problema: $entityManager->persist($entity) sin efecto.
Solución: Siempre llamar run() después:
$this->entityManager->persist($entity);
$this->entityManager->run();
Checklist Nuevo Repository