Migrations
A migration is a PHP file that creates or changes database tables. Plugins ship their migrations in database/migrations/, and Kestello runs them when the plugin is activated and when a new version arrives. This page covers writing migrations, every column type and index method of the schema builder, the table name rules for plugins, when migrations run and how they behave on MySQL, PostgreSQL and SQLite. Read it before you add a table to a plugin, and before you change one that is already on sites.
Write a migration
Generate the file inside your plugin:
bashphp bin/cms make:migration create_events_table --plugin=acme-eventstextCreated: content/plugins/acme-events/database/migrations/2026_11_05_143000_create_events_table.phpA name of the form
create_<name>_tablegets acreate()call forp_<slug>_<name>filled in. Any other name gets an emptyup()anddown().Write
up()anddown():php<?php declare(strict_types=1); use Kestello\Framework\Database\Blueprint; use Kestello\Framework\Database\Migration; return new class extends Migration { public function up(): void { $this->schema()->create('p_acme_events_events', function (Blueprint $table): void { $table->id(); $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); $table->string('title', 200); $table->string('slug', 200)->unique(); $table->text('summary')->nullable(); $table->json('details')->nullable(); $table->datetime('starts_at'); $table->decimal('price', 8, 2)->default(0); $table->boolean('published')->default(false); $table->timestamps(); $table->index('published', 'starts_at'); }); } public function down(): void { $this->schema()->dropIfExists('p_acme_events_events'); } };
Files run in name order, which is why the name starts with a timestamp (YYYY_MM_DD_HHMMSS_). Each file runs once per site: Kestello records its file name and owner (plugin:acme-events) in the migrations table.
Every migration returns an anonymous class, as above. Two plugins can then both have a create_settings_table migration without a class name clash.
down() must undo up(). For plugins it runs when the plugin is uninstalled.
The schema builder
$this->schema() returns a Kestello\Framework\Database\Schema on the site's connection.
| Method | Does |
|---|---|
create(string $table, Closure $callback) | Creates a table. The closure receives a Blueprint. |
table(string $table, Closure $callback) | Changes a table: add columns, drop or rename columns, add or drop indexes and foreign keys. |
drop(string $table) | Drops a table. Fails if it does not exist. |
dropIfExists(string $table) | Drops a table if it exists. |
rename(string $from, string $to) | Renames a table. Both names must have your prefix. |
hasTable(string $table): bool | Whether the table exists. |
tables(): array | Every table name in the database. |
columns(string $table): array | Column names in table order. |
hasColumn(string $table, string $column): bool | Whether the column exists (case-insensitive). |
withoutForeignKeyConstraints(Closure $callback) | Runs the closure with foreign key checks off. |
Table and column names must match ^[A-Za-z_][A-Za-z0-9_]{0,62}$. The schema builder quotes them, so reserved words such as key or order work as column names.
Column types
Types are logical. Each database gets its own native type:
| Method | MySQL / MariaDB | PostgreSQL | SQLite |
|---|---|---|---|
id(string $name = 'id') | BIGINT UNSIGNED AUTO_INCREMENT, primary key | BIGINT GENERATED BY DEFAULT AS IDENTITY, primary key | INTEGER PRIMARY KEY AUTOINCREMENT |
increments(string $name = 'id') | INT UNSIGNED AUTO_INCREMENT, primary key | INTEGER GENERATED BY DEFAULT AS IDENTITY, primary key | INTEGER PRIMARY KEY AUTOINCREMENT |
foreignId(string $name) | BIGINT UNSIGNED | BIGINT | INTEGER |
string(string $name, int $length = 255) | VARCHAR(n) | VARCHAR(n) | VARCHAR(n) |
char(string $name, int $length = 255) | CHAR(n) | CHAR(n) | CHAR(n) |
text(string $name) | TEXT | TEXT | TEXT |
mediumText(string $name) | MEDIUMTEXT | TEXT | TEXT |
longText(string $name) | LONGTEXT | TEXT | TEXT |
tinyInteger(string $name) | TINYINT | SMALLINT | INTEGER |
smallInteger(string $name) | SMALLINT | SMALLINT | INTEGER |
integer(string $name) | INT | INTEGER | INTEGER |
bigInteger(string $name) | BIGINT | BIGINT | INTEGER |
boolean(string $name) | TINYINT(1) | BOOLEAN | INTEGER |
decimal(string $name, int $precision = 8, int $scale = 2) | DECIMAL(p,s) | NUMERIC(p,s) | NUMERIC |
double(string $name) | DOUBLE | DOUBLE PRECISION | REAL |
date(string $name) | DATE | DATE | DATE |
time(string $name) | TIME | TIME(0) WITHOUT TIME ZONE | TIME |
datetime(string $name) | DATETIME | TIMESTAMP(0) WITHOUT TIME ZONE | DATETIME |
timestamp(string $name) | Same as datetime() | Same as datetime() | Same as datetime() |
json(string $name) | JSON | JSONB | TEXT |
uuid(string $name) | CHAR(36) | UUID | TEXT |
ulid(string $name) | CHAR(26) | CHAR(26) | CHAR(26) |
binary(string $name) | LONGBLOB | BYTEA | BLOB |
enum(string $name, array $values) | VARCHAR(255) with a CHECK | VARCHAR(255) with a CHECK | TEXT with a CHECK |
Helpers:
| Method | Adds |
|---|---|
timestamps() | Nullable created_at and updated_at (datetime). Models fill them in UTC. |
softDeletes() | A nullable deleted_at (datetime), for models with $softDeletes = true. |
primary(string ...$columns) | A composite primary key. |
timestamp() is an alias of datetime() on purpose: there is no MySQL TIMESTAMP with its 2038 limit and time zone conversion. Store times in UTC.
MySQL tables are created with ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci unless the connection config says otherwise.
Column modifiers
Every column method returns a ColumnDefinition. Chain these on it:
| Modifier | Effect |
|---|---|
nullable(bool $value = true) | Allows NULL. Columns are NOT NULL by default. |
default(mixed $value) | A literal default. Strings are quoted, booleans become 1/0 (TRUE/FALSE on PostgreSQL). Never raw SQL. |
useCurrent() | DEFAULT CURRENT_TIMESTAMP, for date and time columns. |
unique() | A unique constraint on this column. |
index() | An index on this column. |
unsigned() | UNSIGNED on MySQL integer columns. Ignored elsewhere. |
primary() | Part of the primary key. |
comment(string $comment) | A column comment (MySQL and PostgreSQL). |
constrained(string $table, string $column = 'id') | A foreign key to $table.$column. Returns the ForeignKey. |
Indexes and foreign keys
| Method | Creates |
|---|---|
index(string ...$columns) | An index named idx_<table>_<columns>. |
uniqueIndex(string ...$columns) | A unique index named uniq_<table>_<columns>. |
foreign(string $column) | A foreign key named fk_<table>_<column>. Returns a ForeignKey. |
On a ForeignKey:
| Method | Effect |
|---|---|
references(string $column) | The referenced column (default id). |
on(string $table) | The referenced table. Required. |
onDelete(string $action), onUpdate(string $action) | One of CASCADE, SET NULL, RESTRICT, NO ACTION, SET DEFAULT. The default for both is RESTRICT. |
cascadeOnDelete() | ON DELETE CASCADE. |
nullOnDelete() | ON DELETE SET NULL. The column must be nullable. |
$table->foreignId('event_id');
$table->foreign('event_id')->references('id')->on('p_acme_events_events')->cascadeOnDelete();
// the same, shorter
$table->foreignId('event_id')->constrained('p_acme_events_events')->cascadeOnDelete();A plugin may point foreign keys at core tables such as users. The prefix rule (below) applies to the table being changed, not the one referenced.
Index names are built from the table and column names. MySQL and PostgreSQL limit identifiers to 64 and 63 characters, so keep table names short enough that idx_<table>_<columns> fits.
Change an existing table
Use table(). Columns added there are added with ALTER TABLE ... ADD COLUMN. Alter commands:
| Method | Does |
|---|---|
dropColumn(string ...$columns) | Drops columns. |
renameColumn(string $from, string $to) | Renames a column. |
dropIndex(string ...$columns) | Drops the index made by index() or ->index() on those columns. |
dropUnique(string ...$columns) | Drops the unique index made by uniqueIndex() or ->unique() when altering. |
dropForeign(string $column) | Drops the foreign key on that column. |
<?php
declare(strict_types=1);
use Kestello\Framework\Database\Blueprint;
use Kestello\Framework\Database\Migration;
return new class extends Migration
{
public function up(): void
{
$this->schema()->table('p_acme_events_events', function (Blueprint $table): void {
$table->string('venue', 200)->nullable();
$table->integer('capacity')->default(0);
$table->index('venue');
});
}
public function down(): void
{
$this->schema()->table('p_acme_events_events', function (Blueprint $table): void {
$table->dropIndex('venue');
$table->dropColumn('venue', 'capacity');
});
}
};A new column on a table that has rows must be nullable() or have a default().
Table names in plugins
Inside a plugin's migrations, the schema builder only allows tables whose names start with the plugin's prefix: p_ plus the slug with dashes turned into underscores, plus _. The plugin acme-events may only create, change, rename and drop p_acme_events_*. Themes use t_<slug>_. This covers create, table, drop, dropIfExists and both names of rename.
The guard also knows every installed package's prefix, so prefixes that overlap are kept apart. With the plugins acme and acme-seo both installed, acme owns p_acme_ and acme-seo owns p_acme_seo_. A migration of acme that touches p_acme_seo_orders is refused, because that name belongs to the longer prefix:
Table "p_acme_seo_orders" belongs to another package (prefix "p_acme_seo_").To stay clear of this, do not name a table so that it starts with another plugin's prefix (p_acme_seo_... in the plugin acme), even if that plugin is not installed yet.
Core migrations have no prefix restriction.
When migrations run
| Event | What runs |
|---|---|
plugin:activate, or activating on the Plugins screen | Every pending migration of the plugin, before the provider boots. |
A new version of an active plugin's files (after package:update, an upload or a deploy) | The pending migrations, on the next request or command, then upgrade(). |
plugin:uninstall | Every migration of the plugin rolled back, newest first. |
backup:restore | The database is rebuilt: the migrations recorded in the backup run on an empty database, then the backup's rows replace whatever the migrations inserted. |
migrate, migrate:rollback, migrate:reset, migrate:status | Core migrations only. They do not touch plugin migrations. |
Two consequences for how you write migrations:
- Never change a migration that has shipped. Sites that already ran it will not run it again. Add a new file instead.
- A migration must work on an empty database. A restore runs it from scratch. Rows a migration inserts (default categories, say) are then replaced by the rows in the backup.
A migration that throws stops the run. On activation, the activation fails with the error. During an upgrade, the plugin is suspended with plugin:<slug> failed during upgrade: ..., its version is not recorded, and the pending migrations run again after the fix and the next activation.
Rolling back
There is no command that rolls back one plugin migration. To undo a plugin's tables during development, uninstall it with plugin:uninstall <slug> --keep-files (which runs every down() and deletes its settings), then activate it again.
Locks and transactions
Two processes can try to run the same migrations at once: a deploy script and a web request that finished an update, or two servers. Kestello takes a database-wide lock before it reads which migrations are pending, so each one runs once:
| Database | Lock |
|---|---|
| MySQL / MariaDB | GET_LOCK('kestello_migrations', 300) |
| PostgreSQL | pg_advisory_lock(728391) |
| SQLite | None needed: SQLite allows one writer at a time. |
Each migration then runs with its record in the migrations table:
- PostgreSQL and SQLite run each migration, and the row that records it, in one transaction. A migration that fails halfway leaves nothing behind and is not recorded.
- MySQL and MariaDB commit every
CREATE,ALTERandDROPon their own, so there is no such transaction. A migration that fails after its first statement leaves the earlier changes in place, and it is not recorded, so it runs again from the start. Keep MySQL migrations small (one table per file), or check withhasTable()andhasColumn()before each step.
Differences between databases
| Topic | MySQL / MariaDB | PostgreSQL | SQLite |
|---|---|---|---|
| Schema changes in transactions | No | Yes | Yes |
boolean reads back as | 0 / 1 | true / false | 0 / 1 |
json | Native JSON | JSONB | Text |
uuid | CHAR(36) | Native UUID (must be a valid UUID) | Text |
unsigned() | Applied | Ignored | Ignored |
comment() | Applied | Applied (COMMENT ON COLUMN) | Ignored |
| Add a foreign key to an existing table | Yes | Yes | No: declare foreign keys in create() |
| Drop a foreign key | Yes | Yes | No |
enum | VARCHAR(255) with a CHECK | VARCHAR(255) with a CHECK | TEXT with a CHECK |
Use a model with casts ('published' => 'bool', 'details' => 'array') so your code sees the same PHP types on every database.
If something goes wrong
| Message | What it means |
|---|---|
Table "events" is outside this migration's namespace; table names must start with "p_acme_events_". | Add the prefix. |
Table "p_acme_seo_orders" belongs to another package (prefix "p_acme_seo_"). | The name starts with another installed package's prefix. Rename your table. |
Invalid table name [acme-events]. or Invalid column name [...] | Names are letters, digits and underscores, starting with a letter or underscore, at most 63 characters. |
Cannot add primary/auto-increment column [id] to an existing table. | id(), increments() and primary() columns only work in create(). |
SQLite can't add foreign keys to existing tables (p_acme_events_tickets.event_id); declare them when creating the table. | Move the foreign key into the create() migration, or rebuild the table. |
SQLite can't drop foreign keys from existing tables (fk_p_acme_events_tickets_event_id). | The same limit for dropping. |
Foreign key on [p_acme_events_tickets.event_id] has no referenced table (call ->on()). | foreign() needs ->on('table'). |
Invalid foreign key action [DELETE]. | Use CASCADE, SET NULL, RESTRICT, NO ACTION or SET DEFAULT. |
enum() needs at least one value. | Pass the allowed values. |
Migration "2026_11_05_143000_create_events_table.php" must return an instance: `return new class extends Migration { ... };`. | The file returns nothing, or declares a named class. Return an anonymous class instead. |
Duplicate migration filename "..." in <folder>. | Two directories given to one runner hold the same file name. |
Usage: make:migration <name> | Pass a name, such as create_events_table. |
Related pages
- Plugin tutorial: a plugin with a migration and a model.
- Providers: activation, upgrades and uninstall.
- Manifest: the slug that sets your table prefix.
- Command line:
migrate,plugin:activate,plugin:uninstall,backup:restore.