Database and Doctrine
The framework uses Doctrine ORM/DBAL/Migrations for its data layer.
Each plugin owns its own Entities, and each plugin's migrations are
tracked and run completely independently of other plugins —
exactly like each Android app's separate data tables under
/data/data/<package>/.
Defining an Entity
Entities are defined with standard Doctrine PHP 8 attributes — nothing framework-specific here:
namespace Greeter\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: \Greeter\Repository\SubscriberRepository::class)] #[ORM\Table(name: 'greeter_subscribers')] class Subscriber { #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] private ?int $id = null; #[ORM\Column(type: 'bigint', unique: true)] private int $chatId; #[ORM\Column(type: 'string', length: 8)] private string $locale = 'en'; // ... getters/setters }
Every Entity must be declared in the plugin's manifest.php (see
Plugins):
return Manifest::define( package: 'greeter', version: '1.0.0', entities: [\Greeter\Entity\Subscriber::class], // ... );
How the EntityManager Is Built
On every Kernel::boot(), Database\EntityManagerFactory collects
the list of all Entities declared across every installed
manifest, resolves each Entity's folder via Reflection, and builds a
single shared Doctrine\ORM\EntityManagerInterface — the exact
instance registered in the DI Container, which any
Repository/service can receive automatically just by type-hinting it
in its constructor:
namespace Greeter\Repository; use Doctrine\ORM\EntityManagerInterface; use Greeter\Entity\Subscriber; final class SubscriberRepository { public function __construct( private readonly EntityManagerInterface $entityManager, ) {} public function findByChatId(int $chatId): ?Subscriber { return $this->entityManager->getRepository(Subscriber::class) ->findOneBy(['chatId' => $chatId]); } public function save(Subscriber $subscriber): void { $this->entityManager->persist($subscriber); $this->entityManager->flush(); } }
Support for Multiple Database Types
Any database Doctrine DBAL supports is usable via the database
section in config/aicrion.yaml: SQLite, MySQL/MariaDB, PostgreSQL.
The format is documented in Configuration.
There's no driver-specific code in the framework — everything flows
through Doctrine's standard DriverManager::getConnection().
Automatic Migrations — No CLI Command Required
This is one of the main differences from a typical Doctrine project:
pending migrations run automatically on every boot(), exactly
like installing an Android app that builds/updates its internal
database schema on first run.
plugins/greeter/ ├── manifest.php ├── migrations/ │ ├── migrations.php # Doctrine Migrations config for this plugin │ └── versions/ │ └── Version20260101000000.php └── src/
The migrations/migrations.php File
This is a standard
Doctrine\Migrations\Configuration\Migration\PhpFile file — an
array specifying the migrations path and the tracking table name
specific to this plugin:
return [ 'migrations_paths' => ['Greeter\\Migrations' => __DIR__ . '/versions'], 'table_storage' => [ 'table_name' => 'aicrion_migrations_greeter', ], ];
Because each plugin has its own migration tracking table
(aicrion_migrations_<package>), two plugins never collide on
migration version numbering — exactly like each Android app having
its own isolated namespace.
Package\MigrationRunner
PackageManager::runPendingMigrations() builds a MigrationRunner
for each plugin, which:
- Builds the DBAL connection from
FrameworkConfig::$database. - Loads that plugin's
migrations.phpconfig viaDoctrine\Migrations\Configuration\Migration\PhpFile. - Builds a ready-to-run instance with
DependencyFactory::fromConnection(). - Calls
Migrator::migrate()(to the latest version) — no interactive prompt, suitable for automatic execution in the middle of an HTTP request on shared hosting.
Result: just drop the plugin into plugins/ and send a request to
the bot (or run bin/poll.php) — that plugin's tables are created
automatically.
Writing a Migration
namespace Greeter\Migrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20260101000000 extends AbstractMigration { public function up(Schema $schema): void { $table = $schema->createTable('greeter_subscribers'); $table->addColumn('id', 'integer', ['autoincrement' => true]); $table->addColumn('chat_id', 'bigint', ['unique' => true]); $table->addColumn('locale', 'string', ['length' => 8, 'default' => 'en']); $table->setPrimaryKey(['id']); } public function down(Schema $schema): void { $schema->dropTable('greeter_subscribers'); } }
Production Performance Note
EntityManagerFactory runs with isDevMode: true by default (i.e.
Doctrine metadata isn't cached, and attributes are re-read on every
boot). For high traffic in production, add a metadata caching layer
(APCu/filesystem) between EntityManagerFactory and your own
project, or keep the result of boot() alive at the process level
(a long-running worker instead of one-request-per-process).