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).