Writing Database Migrations

Introduction

Zero CMS has no ORM schema-sync step — every schema change is a hand-written, numbered migration class extending the abstract Zero\Database\Migration, implementing up() and down(). MigrationManager discovers migration files by globbing src/Database/Migrations/[0-9]*_*.php for core, and src/Modules/*/Database/Migrations/[0-9]*_*.php for every module, then runs every not-yet-applied file across both sets in a single flat numeric order, tracking progress in a migrations table (migration, batch, run_at).

A Real Migration (0029_AddExpiresAtToSitesTable.php)

A Real Migration (0029_AddExpiresAtToSitesTable.php) php
namespace Zero\Database\Migrations;

use Zero\Database\DB;

class AddExpiresAtToSitesTable extends \Zero\Database\Migration
{
    public function up(): void
    {
        DB::query("ALTER TABLE sites ADD COLUMN expires_at DATETIME NULL");
    }

    public function down(): void
    {
        DB::query("ALTER TABLE sites DROP COLUMN expires_at");
    }
}

Numbering & Running Migrations

Because core and module migrations share one flat numeric order, always check the highest existing migration number across every Database/Migrations/ folder in the repo before adding a new one — not just your own module's folder. Every table follows the same conventions: a VARCHAR(36) UUIDv7 primary key, a nullable site_id for tenant scoping, and a soft-delete deleted_at column.

Applying Migrations

Applying Migrations bash
docker exec -w /data/misc/zero php83 bin/migrate