Custom ORM (Cycle)
Roolith ships with roolith/database and a thin App\Models\Model wrapper, see Models. You only need this page if you want to replace it with Cycle ORM (docs).
Do you need this? For most apps, no. Keep the built-in query builder for simple CRUD. Switch to Cycle if you want DataMapper entities with attributes, compiled schema, and clean
EntityManagerwrites.
How the swap works
Cycle is a DataMapper: entities are plain PHP objects, the schema is compiled from attributes, and writes go through Cycle\ORM\EntityManager. Three things change, in this order:
config/config.phpgains acyclekey with connection details.- A new
app/Core/CycleFactory.phpowns the singleORMinstance, mirroringDatabaseFactory. app/Core/System.phpboots it on each request and resets it afterwards, and your models become entities instead of query builders.
Your controllers keep the same shape: load something, pass it to $this->view(). Only the data-access calls change.
Decide one thing up front:
- Side by side (recommended for existing apps): keep
databasefor old models, addcyclefor new entities. - Full replacement (for new apps): set
databasetonulland use only Cycle.
Step 1 - Install Cycle
composer require cycle/orm cycle/database cycle/annotated spiral/tokenizer symfony/finderMySQL needs pdo_mysql, Postgres pdo_pgsql, SQLite pdo_sqlite. You will likely want cycle/migrations later for production.
Step 2 - Add connection config
Add a cycle key to config/config.php. Keep database for side by side, set it to null for full replacement:
<?php
return [
"baseUrl" => "http://localhost:8080/",
// side by side: keep your existing array here
// full replacement: set "database" => null,
"database" => null,
"cycle" => [
"default" => "default",
"databases" => [
"default" => ["connection" => "mysql"],
],
"connections" => [
"mysql" => [
"driver" => "mysql",
"host" => "localhost",
"port" => 3306,
"database" => "roolith_cms",
"username" => "root",
"password" => "",
"queryCache" => true,
],
],
"entityPaths" => [APP_ROOT . "/app/Entities"],
"schemaCache" => APP_ROOT . "/storage/cache/cycle-schema.php",
"syncTables" => !isProductionEnvironment(),
],
];Keep syncTables on while developing so tables follow your attributes automatically. Turn it off in production and use migrations instead. Other drivers follow the same shape, see Connect to Database.
Step 3 - Add one factory
Create app/Core/CycleFactory.php. Copy this once, you rarely touch it again. It builds the DatabaseManager, compiles the schema from attributes, and returns the ORM singleton:
<?php
namespace App\Core;
use Cycle\Database\Config\DatabaseConfig;
use Cycle\Database\DatabaseManager;
use Cycle\ORM\Factory as OrmFactory;
use Cycle\ORM\ORM;
use Cycle\ORM\Schema;
use Cycle\Schema\Compiler;
use Cycle\Schema\Registry;
use Cycle\Annotated;
use Spiral\Tokenizer\ClassLocator;
use Symfony\Component\Finder\Finder;
use Roolith\Configuration\Config;
class CycleFactory
{
private static ?ORM $orm = null;
private static ?DatabaseManager $dbal = null;
private function __construct() {}
public static function getORM(): ORM
{
if (self::$orm === null) {
self::$orm = self::createORM();
}
return self::$orm;
}
public static function getDBAL(): DatabaseManager
{
self::getORM();
return self::$dbal;
}
private static function createORM(): ORM
{
$cfg = Config::get("cycle");
if (!$cfg) {
throw new \RuntimeException("Missing `cycle` config in config/config.php");
}
$dbal = new DatabaseManager(new DatabaseConfig(
self::buildDbalConfig($cfg)
));
self::$dbal = $dbal;
$entityPaths = $cfg["entityPaths"] ?? [APP_ROOT . "/app/Entities"];
$schemaCache = $cfg["schemaCache"] ?? APP_ROOT . "/storage/cache/cycle-schema.php";
$syncTables = $cfg["syncTables"] ?? !isProductionEnvironment();
$schemaArray = null;
if (!$syncTables && is_file($schemaCache)) {
$schemaArray = require $schemaCache;
}
if ($schemaArray === null) {
$schemaArray = self::compileSchema($dbal, $entityPaths, $syncTables);
if (!$syncTables && $schemaCache) {
$dir = dirname($schemaCache);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents(
$schemaCache,
"<?php return " . var_export($schemaArray, true) . ";"
);
}
}
return new ORM(new OrmFactory($dbal), new Schema($schemaArray));
}
private static function buildDbalConfig(array $cfg): array
{
$connections = [];
foreach ($cfg["connections"] as $name => $conn) {
$driver = strtolower($conn["driver"] ?? "mysql");
if ($driver === "sqlite") {
if (isset($conn["connection"]) && str_starts_with($conn["connection"], "sqlite:")) {
$connections[$name] = new \Cycle\Database\Config\SQLiteDriverConfig(
connection: new \Cycle\Database\Config\SQLite\DsnConnectionConfig(dsn: $conn["connection"]),
queryCache: $conn["queryCache"] ?? true
);
} elseif (($conn["database"] ?? null) === ":memory:") {
$connections[$name] = new \Cycle\Database\Config\SQLiteDriverConfig(
connection: new \Cycle\Database\Config\SQLite\MemoryConnectionConfig(),
queryCache: $conn["queryCache"] ?? true
);
} else {
$connections[$name] = new \Cycle\Database\Config\SQLiteDriverConfig(
connection: new \Cycle\Database\Config\SQLite\FileConnectionConfig(
database: $conn["database"] ?? APP_ROOT . "/storage/database.sqlite"
),
queryCache: $conn["queryCache"] ?? true
);
}
continue;
}
if ($driver === "mysql") {
$connections[$name] = new \Cycle\Database\Config\MySQLDriverConfig(
connection: new \Cycle\Database\Config\MySQL\TcpConnectionConfig(
database: $conn["database"],
host: $conn["host"] ?? "127.0.0.1",
port: $conn["port"] ?? 3306,
user: $conn["username"] ?? $conn["user"] ?? "root",
password: $conn["password"] ?? $conn["pass"] ?? ""
),
queryCache: $conn["queryCache"] ?? true
);
continue;
}
if ($driver === "postgres" || $driver === "pgsql") {
$connections[$name] = new \Cycle\Database\Config\PostgresDriverConfig(
connection: new \Cycle\Database\Config\Postgres\TcpConnectionConfig(
database: $conn["database"],
host: $conn["host"] ?? "127.0.0.1",
port: $conn["port"] ?? 5432,
user: $conn["username"] ?? $conn["user"] ?? "postgres",
password: $conn["password"] ?? $conn["pass"] ?? ""
),
schema: $conn["schema"] ?? "public",
queryCache: $conn["queryCache"] ?? true
);
continue;
}
throw new \RuntimeException("Unsupported cycle driver `$driver` for connection `$name`");
}
return [
"default" => $cfg["default"] ?? "default",
"databases" => $cfg["databases"] ?? ["default" => ["connection" => array_key_first($connections)]],
"connections" => $connections,
];
}
private static function compileSchema(DatabaseManager $dbal, array $paths, bool $syncTables): array
{
$finder = (new Finder())->files()->in($paths);
$classLocator = new ClassLocator($finder);
if (class_exists(\Cycle\Annotated\Locator\TokenizerEntityLocator::class)) {
$embeddingLocator = new \Cycle\Annotated\Locator\TokenizerEmbeddingLocator($classLocator);
$entityLocator = new \Cycle\Annotated\Locator\TokenizerEntityLocator($classLocator);
$embeddings = new Annotated\Embeddings($embeddingLocator);
$entities = new Annotated\Entities($entityLocator);
} else {
$embeddings = new Annotated\Embeddings($classLocator);
$entities = new Annotated\Entities($classLocator);
}
$generators = [
new \Cycle\Schema\Generator\ResetTables(),
$embeddings,
$entities,
new Annotated\TableInheritance(),
new Annotated\MergeColumns(),
new \Cycle\Schema\Generator\GenerateRelations(),
new \Cycle\Schema\Generator\GenerateModifiers(),
new \Cycle\Schema\Generator\ValidateEntities(),
new \Cycle\Schema\Generator\RenderTables(),
new \Cycle\Schema\Generator\RenderRelations(),
new \Cycle\Schema\Generator\RenderModifiers(),
new \Cycle\Schema\Generator\ForeignKeys(),
new Annotated\MergeIndexes(),
new \Cycle\Schema\Generator\GenerateTypecast(),
];
if ($syncTables) {
$generators[] = new \Cycle\Schema\Generator\SyncTables();
}
return (new Compiler())->compile(new Registry($dbal), $generators);
}
public static function reset(): void
{
self::$orm = null;
self::$dbal = null;
}
}Controllers and models now call CycleFactory::getORM() where they used to call DatabaseFactory::getInstance().
Step 4 - Boot it in System.php
You only add a connect and a reset. Everything else in System (Env, config validation, session, security headers) stays unchanged.
In bootstrap(), after the existing setup:
$cycleConfig = Config::get("cycle");
if ($cycleConfig) {
$this->orm = CycleFactory::getORM();
// trigger connection early to surface config errors
$this->orm->getFactory()->getDatabaseManager()->database(
$cycleConfig["default"] ?? "default"
)->getDriver()->getConnection()->getPDO();
}In complete(), before temp cleanup:
CycleFactory::reset();Add a protected ?ORM $orm = null; property to hold it. For side by side, keep the existing $db property and legacy connect block untouched. For full replacement, set "database" => null so the old path skips connecting.
Cycle's EntityManager is disposable per request, so do not store it in System. Create a new one in controllers with new EntityManager($orm).
Sanity check with a temporary route:
return CycleFactory::getORM()->getRepository(\App\Entities\Product::class)->select()->count();Step 5 - Define an entity
Create app/Entities/Product.php:
<?php
namespace App\Entities;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
#[Entity(table: "products")]
class Product
{
#[Column(type: "primary")]
private int $id;
#[Column(type: "string(255)")]
private string $name;
#[Column(type: "decimal(10,2)", nullable: true)]
private ?string $price = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getPrice(): ?string
{
return $this->price;
}
public function setPrice(?string $price): void
{
$this->price = $price;
}
}Relations are attributes too:
use Cycle\Annotated\Annotation\Relation\HasMany;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
#[Entity]
class User
{
#[Column(type: "primary")]
private int $id;
#[HasMany(target: Post::class)]
private array $posts = [];
}
#[Entity]
class Post
{
#[Column(type: "primary")]
private int $id;
#[BelongsTo(target: User::class)]
private ?User $user = null;
}See Relations when you need more.
Step 6 - Use it in a controller
Reads go through the repository, writes through persist()->run(). This replaces User::orm()->where(...)->get():
<?php
namespace App\Controllers;
use App\Core\CycleFactory;
use App\Entities\Product;
use Cycle\ORM\EntityManager;
class ProductController extends Controller
{
// Create
public function store()
{
$orm = CycleFactory::getORM();
$em = new EntityManager($orm);
$product = new Product();
$product->setName("Widget");
$product->setPrice("19.99");
$em->persist($product);
$em->run();
return $this->view("products/show", ["product" => $product]);
}
// Read
public function show(int $id)
{
$orm = CycleFactory::getORM();
$product = $orm->getRepository(Product::class)->findByPK($id);
return $this->view("products/show", ["product" => $product]);
}
// Update
public function update(int $id)
{
$orm = CycleFactory::getORM();
$em = new EntityManager($orm);
$product = $orm->getRepository(Product::class)->findByPK($id);
$product->setName("Habib Hadi");
$em->persist($product);
$em->run();
return $this->view("products/show", ["product" => $product]);
}
// Delete
public function destroy(int $id)
{
$orm = CycleFactory::getORM();
$em = new EntityManager($orm);
$product = $orm->getRepository(Product::class)->findByPK($id);
if ($product) {
$em->delete($product);
$em->run();
}
}
}Queries beyond primary key:
$orm = CycleFactory::getORM();
$repo = $orm->getRepository(Product::class);
// filtered read
$active = $repo->findAll(["status" => "active"]);
// select with conditions, ordering, and relation loading
$products = $repo->select()
->where("price", ">", 10)
->orderBy("name", "ASC")
->limit(10)
->load("user")
->fetchAll();
// pagination: limit plus offset plus a count
$page = max(1, (int) ($_GET["page"] ?? 1));
$perPage = 15;
$total = $repo->select()->count();
$items = $repo->select()->orderBy("id")->limit($perPage)->offset(($page - 1) * $perPage)->fetchAll();Repositories are read-only by design, see Select. For custom finders, point #[Entity(repository: MyRepo::class)] at your own class.
Optional - A Model base for familiar helpers
Skip this if calling the repository directly feels fine. If you miss Product::find(1) and Product::all(), add a small base class. For side by side, name it CycleModel and leave Model.php untouched:
<?php
namespace App\Models;
use App\Core\CycleFactory;
use Cycle\ORM\ORM;
abstract class CycleModel
{
protected static string $entityClass = "";
protected static function findByPK(mixed $id): ?object
{
$class = static::$entityClass ?: static::class;
return CycleFactory::getORM()->getRepository($class)->findByPK($id);
}
protected static function findAll(array $scope = []): iterable
{
$class = static::$entityClass ?: static::class;
return CycleFactory::getORM()->getRepository($class)->findAll($scope);
}
}class Product extends CycleModel
{
protected static string $entityClass = \App\Entities\Product::class;
}Going further
- Schema in development: with
syncTableson, tables follow your attributes on each bootstrap. After changing entities, deletestorage/cache/cycle-schema.phpif reads look stale. - Migrations in production: set
syncTablesto false, commit the cached schema during deploy, and use Migrations withcycle/migrations. Keep Roolith migrations indatabase/migrationsand Cycle migrations in a separate directory. - Debugging the schema: install
cycle/schema-rendererand dump withSchemaRenderer, or add a temporary/debug/cycle-schemaroute that printsCycleFactory::getORM()->getSchema(). - Same pattern, other ORMs: the factory plus
bootstrap()/complete()wiring is identical for Doctrine or Eloquent. The factory returns the manager, the base model exposes it. See Custom ORM (Doctrine). - Keep entities clean: no request or session logic inside entities. Keep helpers like
User::current()inapp/Modelsorapp/Miscand load the user via the repository. - Fully replacing? Update old calls to
Model::orm(),Model::raw(),where(),insert(),update(), anddelete()to theEntityManagerand repository equivalents above.