Skip to content

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 ​

  1. Generate the file inside your plugin:

    bash
    php bin/cms make:migration create_events_table --plugin=acme-events
    text
      Created: content/plugins/acme-events/database/migrations/2026_11_05_143000_create_events_table.php

    A name of the form create_<name>_table gets a create() call for p_<slug>_<name> filled in. Any other name gets an empty up() and down().

  2. Write up() and down():

    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.

MethodDoes
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): boolWhether the table exists.
tables(): arrayEvery table name in the database.
columns(string $table): arrayColumn names in table order.
hasColumn(string $table, string $column): boolWhether 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:

MethodMySQL / MariaDBPostgreSQLSQLite
id(string $name = 'id')BIGINT UNSIGNED AUTO_INCREMENT, primary keyBIGINT GENERATED BY DEFAULT AS IDENTITY, primary keyINTEGER PRIMARY KEY AUTOINCREMENT
increments(string $name = 'id')INT UNSIGNED AUTO_INCREMENT, primary keyINTEGER GENERATED BY DEFAULT AS IDENTITY, primary keyINTEGER PRIMARY KEY AUTOINCREMENT
foreignId(string $name)BIGINT UNSIGNEDBIGINTINTEGER
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)TEXTTEXTTEXT
mediumText(string $name)MEDIUMTEXTTEXTTEXT
longText(string $name)LONGTEXTTEXTTEXT
tinyInteger(string $name)TINYINTSMALLINTINTEGER
smallInteger(string $name)SMALLINTSMALLINTINTEGER
integer(string $name)INTINTEGERINTEGER
bigInteger(string $name)BIGINTBIGINTINTEGER
boolean(string $name)TINYINT(1)BOOLEANINTEGER
decimal(string $name, int $precision = 8, int $scale = 2)DECIMAL(p,s)NUMERIC(p,s)NUMERIC
double(string $name)DOUBLEDOUBLE PRECISIONREAL
date(string $name)DATEDATEDATE
time(string $name)TIMETIME(0) WITHOUT TIME ZONETIME
datetime(string $name)DATETIMETIMESTAMP(0) WITHOUT TIME ZONEDATETIME
timestamp(string $name)Same as datetime()Same as datetime()Same as datetime()
json(string $name)JSONJSONBTEXT
uuid(string $name)CHAR(36)UUIDTEXT
ulid(string $name)CHAR(26)CHAR(26)CHAR(26)
binary(string $name)LONGBLOBBYTEABLOB
enum(string $name, array $values)VARCHAR(255) with a CHECKVARCHAR(255) with a CHECKTEXT with a CHECK

Helpers:

MethodAdds
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:

ModifierEffect
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 ​

MethodCreates
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:

MethodEffect
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.
php
$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:

MethodDoes
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
<?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:

text
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 ​

EventWhat runs
plugin:activate, or activating on the Plugins screenEvery 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:uninstallEvery migration of the plugin rolled back, newest first.
backup:restoreThe 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:statusCore 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:

DatabaseLock
MySQL / MariaDBGET_LOCK('kestello_migrations', 300)
PostgreSQLpg_advisory_lock(728391)
SQLiteNone 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, ALTER and DROP on 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 with hasTable() and hasColumn() before each step.

Differences between databases ​

TopicMySQL / MariaDBPostgreSQLSQLite
Schema changes in transactionsNoYesYes
boolean reads back as0 / 1true / false0 / 1
jsonNative JSONJSONBText
uuidCHAR(36)Native UUID (must be a valid UUID)Text
unsigned()AppliedIgnoredIgnored
comment()AppliedApplied (COMMENT ON COLUMN)Ignored
Add a foreign key to an existing tableYesYesNo: declare foreign keys in create()
Drop a foreign keyYesYesNo
enumVARCHAR(255) with a CHECKVARCHAR(255) with a CHECKTEXT 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 ​

MessageWhat 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.
  • 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.

Kestello documentation.