Coolify 中 383 个 Laravel 迁移文件的治理规范:以大规模迁移体系印证七条数据库迁移最佳实践
本篇以 Laravel 迁移最佳实践规则为骨架,结合 Coolify(一个可自托管、对标 Vercel/Heroku/Netlify 的开源 PaaS)仓库中 383 个真实迁移文件的写法,逐条展开「Artisan 生成迁移、外键 constrained、不可修改已部署迁移、建表即建索引、模型镜像默认值、可回滚 down() 方法、单一职责迁移」七项规范的具体含义与落地方式。读完本文,你将掌握在大型 Laravel 项目中编写、演进和回滚数据库迁移的完整方法论,并能在 Coolify 迁移目录 中找到每条规则的源码级实证。
Coolify 的迁移体系:规范从何而来
Coolify 是典型的「长周期演进」Laravel 项目:从 database/migrations 目录看,当前仓库累计沉淀了 383 个迁移文件,时间跨度从 2014 年的框架基础表(用户、密码重置、会话)一直延续到 2025 年的云服务商令牌等新增表。这种量级意味着迁移本身已经成为项目最重要的「数据架构文档」——任何一个在 CI 或生产环境中被执行过的迁移,都牵动着所有已部署实例的数据库状态。
因此,Coolify 将迁移实践规范沉淀为独立规则文档 migrations.md,作为团队协作与代码审查的依据。下文按该文档的七条规则逐一展开,并用仓库中的真实迁移文件佐证每条规则的执行情况。
一、统一用 Artisan 生成迁移:命名即契约
规则文档的第一条要求:永远使用 php artisan make:migration 生成迁移文件,以获得一致的命名与时间戳,禁止手工创建无时间戳的文件。
# 正确做法(Artisan 生成)
php artisan make:migration create_posts_table
php artisan make:migration add_slug_to_posts_table
对比手工创建的 posts_migration.php(无时间戳、命名随意),Artisan 生成的文件名遵循 Y_m_d_His_action_description.php 格式。时间戳前缀直接决定了 php artisan migrate 的执行顺序,是迁移幂等性与可重放性的基础。
Coolify 仓库的命名风格可以完整印证这一点,例如:
- 建表:2023_03_27_075351_create_projects_table.php、2024_02_01_111228_create_tags_table.php;
- 加列/改表:2023_08_06_142951_add_description_field_to_applications_table.php、2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php。
动词前缀 create_* 与 add_* 让文件按时间排序后,表结构演进脉络一目了然——这正是时间戳命名带来的「可审计性」。
二、外键统一使用 constrained():自动命名 + 引用完整性
规则要求所有外键列使用 foreignId(...)->constrained(),让 Laravel 自动推导约束名称({表}_外键列_foreign)并真正创建数据库级外键;非标准表名时可显式指定:
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// 外键指向非约定命名的表
$table->foreignId('author_id')->constrained('users');
Coolify 的迁移中 constrained() 几乎是外键列的标准写法,且清晰展示了三种级联策略的选择:
cascadeOnDelete()/onDelete('cascade')——子记录随父记录删除。通知配置类表全部采用此策略,如 team_invitations 迁移 中的$table->foreignId('team_id')->constrained()->cascadeOnDelete();,以及 Slack 通知配置迁移、邮件、Discord、Telegram、Pushover 等同类表。团队删除后其通知配置随之级联清理,避免孤儿配置行;onDelete('set null')——父记录删除时置空外键,用于「可选关联」。例如 服务器表追加 cloud_provider_token_id 列的迁移:$table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null');。云令牌被删除时,服务器记录保留、仅解除关联,这是典型的「软解除引用」场景;- 可空外键 + 可空级联——共享环境变量迁移 展示了变量可绑定到 team/project/environment 中任意一层的灵活建模:
$table->foreignId('project_id')->nullable()->constrained()->onDelete('cascade');。
值得注意的是,并非所有 foreignId 都带 constrained()。例如 applications 建表迁移 中 $table->foreignId('environment_id'); 未加约束——从源码结构看,这是早期迁移的遗留写法(可能为规避迁移顺序或大表加外键的锁表开销)。对新迁移而言,规则文档的立场是明确的:能用 constrained() 就应当用,把引用完整性交给数据库而非应用层校验。
三、已部署的迁移视为不可变:只新增、不修改
这是迁移规范中最关键的一条:一旦迁移在生产环境执行过,就把它当作只读文件;要改表,就新建一个迁移。
文档中的反例是编辑已上线的 2024_01_01_create_posts_table.php 追加 slug 列;正确做法是新增 add_slug_to_posts_table 迁移,在 down() 中 dropColumn 实现回滚。
这条规则在 Coolify 的演进史中被反复验证:applications 表自 2023 年 3 月创建后,从未被「就地修改」,其后续所有字段变更都以新迁移追加,例如 2023_08_06_142951_add_description_field_to_applications_table、2024_05_15_091757_add_commit_message_to_app_deployment_queue 等一系列 add_* 文件。383 个迁移文件本质上就是一份「只追加(append-only)」的表结构变更日志——如果允许编辑旧文件,任何新实例执行 migrate 得到的 schema 都会与存量实例分叉,而「不可变 + 新迁移」保证了任意时点全新拉起的数据库与存量数据库的终态一致。
四、建表时就把索引建好:WHERE / ORDER BY / JOIN 的列
规则要求:凡用于 WHERE、ORDER BY、JOIN 的列,应在 Schema::create 阶段直接加索引,而不是事后补迁移。文档给出的标准示例:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->index();
$table->string('status')->index();
$table->timestamp('shipped_at')->nullable()->index();
$table->timestamps();
});
Coolify 仓库中同类写法同样存在,如 会话表迁移:$table->foreignId('user_id')->nullable()->index(); 与 $table->integer('last_activity')->index();——会话查询几乎总是按 user_id 过滤、按 last_activity 排序,索引在表结构诞生之初就与列同时落地,省去了后续「加索引 = 大表锁/重建」的成本。此外 unique() 约束(如 team_invitations 中的 $table->unique(['team_id', 'email']);)在多数数据库中也由索引支撑,属于同一类「建表即完成」的决策。
五、数据库默认值与模型 $attributes 双写镜像
当列定义了数据库默认值时,规则要求在模型的 $attributes 中镜像同样的默认值,使未持久化的新实例在保存前就持有正确值:
// Migration
$table->string('status')->default('pending');
// Model
protected $attributes = [
'status' => 'pending',
];
Coolify 模型中这一模式真实存在:Team 模型 声明了 protected $attributes = ['is_mcp_server_enabled' => true];,其默认值与建表迁移中的列默认值语义一致——这样 new Team 后未显式赋值时,序列化、API 响应、表单回填都拿到与数据库一致的初始值,而不是 null。仓库中 EnvironmentVariable、InstanceSettings、ServiceApplication 等模型也采用了相同的 $attributes 镜像写法。
对照 applications 建表迁移 可以看到大量 ->default(...) 列(health_check_method 默认 GET、git_commit_sha 默认 HEAD 等)。这些默认值对「数据库侧直接插入」是兜底,而模型侧的镜像默认值则覆盖了「应用侧构造对象再落库」的路径,两处默认值保持一致是这条规则成立的前提。
六、默认编写可回滚的 down():让 migrate:rollback 在 CI 与事故现场可用
规则要求:凡是能安全逆操作的 schema 变更,都应实现 down(),使 php artisan migrate:rollback 可用于 CI 环境重置与部署失败回退。
Coolify 的建表迁移普遍遵循此约定,例如 projects 建表迁移:
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('name');
$table->string('description')->nullable();
$table->foreignId('team_id');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('projects');
}
Coolify 的测试体系依赖可重放的迁移链:phpunit.xml 强制 DB_CONNECTION=testing,配合 config/testing.php 的测试库配置,功能测试(tests/Feature 下 500+ 个测试文件)需要在隔离数据库中干净地重跑整套迁移。down() 方法的可执行性,直接决定了 migrate:fresh / rollback 能否在 CI 中稳定工作。
规则同时给出一条务实的边界:对于故意不可逆的迁移(如破坏性数据回填),不要假装支持回滚——留一条清晰的注释说明不可逆原因,并以「向前修复(forward fix)」新迁移代替回滚。这与「不可变迁移」原则一致:回滚能力是设计出来的,不是事后补救出来的。
七、一个迁移只解决一件事:DDL 与 DML 分离
规则的最后一条:每个迁移聚焦单一关注点,绝不混用 DDL(改结构)和 DML(动数据)。文档给出的反例是:在 up() 里既 Schema::create('settings', ...) 又 DB::table('settings')->insert([...])——一旦前半成功、后半失败,数据库将停留在「表已建、种子数据缺失」的不可恢复中间态;正确做法是拆成 create_settings_table 与 seed_default_settings 两个迁移。
这里值得对照 Coolify 仓库中的真实案例做一点深入讨论。Slack 通知配置迁移 的 up() 确实同时包含了 DDL 与 DML:建表之后遍历所有存量团队,为每个 team 插入一行默认通知配置,并用 try/catch + Log::error 包裹单条插入以保证批量循环的幂等与容错:
foreach ($teams as $team) {
try {
DB::table('slack_notification_settings')->insert(['team_id' => $team->id]);
} catch (\Throwable $e) {
Log::error('Error creating slack notification settings for existing teams: '.$e->getMessage());
}
}
从源码结构看,这是对「存量数据升级」场景的务实妥协:新表带 team_id 唯一约束,每个团队必须立刻拥有一行配置(后续 Team 模型的 booted 钩子 已改为在新建团队时自动创建各行通知配置)。严格遵循「单一职责」时应当拆分为建表迁移 + 数据回填迁移两个文件;而把两者合并在同一文件内、辅以逐条容错,则换来了「建表与回填原子生效」的运维便利。理解这一取舍,比机械背诵规则更有价值:规范定义的是默认姿势,而存量数据升级是需要显式权衡的例外场景。
实践清单:提交迁移前逐项核对
综合以上七条规则,可以在代码审查中使用如下检查清单:
| 检查项 | 依据 |
|---|---|
文件由 php artisan make:migration 生成,文件名含时间戳与 create_/add_ 语义前缀 |
命名规范;参见 tags 建表迁移 |
所有外键列使用 foreignId(...)->constrained(),并显式选择 cascadeOnDelete / set null 等策略 |
team_invitations、cloud_provider_token 列 |
未修改任何已部署迁移;表结构变更一律新增 add_* 迁移 |
Coolify 迁移目录 的 append-only 演进模式 |
WHERE/ORDER BY/JOIN 列在 Schema::create 阶段即加 index()/unique() |
sessions 表 |
数据库列默认值在模型 protected $attributes 中镜像 |
Team 模型、applications 建表迁移 |
down() 实现可回滚;确不可逆处留注释并改用向前修复 |
projects 建表迁移 |
| 一个迁移只做一件事;DDL 与 DML 分离(存量回填例外需显式说明并容错) | Slack 配置迁移案例 |
对自托管 PaaS 这类「用户各自持有数据库」的项目,迁移的可重放性就是产品承诺的一部分:新安装、旧库升级、CI 重置三条路径最终必须收敛到同一份 schema。以上七条规则覆盖了从命名、外键、索引到回滚的完整生命周期,而 Coolify 仓库中 383 个迁移文件则提供了每一条规则在大体量、长周期项目中的真实落点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00