Skip to main content
Persistence is Doctrine ORM over MySQL. Mapping is done with attributes on entity classes in src/, and the schema is versioned with Doctrine Migrations.

Mapping

Value objects are embeddables, so validation lives in the value object and the column stays flat.
Only src/ is scanned for mappings. Plugins can’t add entities. See Data and persistence.

Identifiers

IDs are UUIDv7 values stored as binary. They’re unique without a round trip, and they sort by creation time, which is what makes cursor pagination possible.

Inheritance

Several tables use single-table inheritance with a discriminator column, so related kinds share one table:

Repositories

Domain code depends on repository interfaces; Doctrine implementations are bound during boot. The shared base class is immutable, so filters return a narrowed clone rather than mutating shared state:
Two behaviors come from the base:
  • Soft deletes. Entities with a deletedAt field are excluded automatically.
  • Cursor pagination. Results can start after, or end before, a known ID, which is what the API’s starting_after and ending_before parameters use.

When writes happen

Handlers call add() and entity methods; the entity manager is flushed once, after the response is emitted. Console commands and the cron entry point flush too. Flush explicitly only when you must, such as when streaming a response that has to persist state before it finishes.

Schema migrations

Migrations live in migrations/mysql, are namespaced Migrations\MySql, and are tracked in a migration table.
Files are named by the version they belong to, such as Version50000 for 5.0.0, alongside timestamped ones for changes within a release.
migrations:diff compares the database against the entity mappings. Always read the generated SQL before running it: it will happily drop a column that an extension added outside the mapping.

Data migrations

Schema changes aren’t always enough: an update may need to reshape existing rows. Those live in migrations/update as classes implementing the migration interface, and are run in order by the migration manager, which records what it has run in the migrated option so each one runs once. The update flow triggers them after the schema migration.

Working with the schema

orm:schema-tool:update --force bypasses migrations and leaves the migration table out of step with reality. Use migrations on anything but a scratch database.

Tables

Core tables include user, workspace, workspace_invitation, plan, plan_snapshot, subscription, order, coupon, option, category, preset, assistant, voice, file, library_item, message, artifact_version, data_unit, chatbot, chatbot_message, chatbot_conversation, contact, company, affiliate, payout, import_job, stat and migration.
Adding columns to core tables is a fork: the next update’s migrations don’t know about them, and migrations:diff will propose dropping them. If you need extra data, see Data and persistence.

Backups

Anything that changes the schema, including an update, should be preceded by a database dump. The update flow migrates in place, and there’s no automatic rollback.