在项目迭代中,我们经常会遇到这样的需求:新增一张表之后,需要同时写入一批初始数据,比如系统默认角色、内置的分类目录、基础配置项等。不少开发者图方便,直接在迁移文件的 up() 方法里调用 DB::table()->insert() 或者干脆调用 factory()、Seeder 类,一次性把数据灌进去。这种做法在本地开发环境看起来运转良好,可一旦部署到生产环境,各种问题就会浮出水面:数据重复插入、回滚后残留脏数据、团队协作时其他人本地跑迁移报唯一键冲突等等。这篇文章就来系统梳理迁移与数据填充的边界,以及在确实需要在迁移中写入数据时,如何写出健壮的代码。

为什么迁移和 Seeder 要分开维护
首先要理解 Laravel 设计迁移(Migration)和数据填充(Seeding)两套机制的初衷。迁移负责的是数据库结构的版本演进,它记录的是表结构、索引、字段类型的变化历史;而 Seeder 与模型工厂负责的是数据本身,承担开发环境的假数据生成、初始数据的维护。两者职责不同,生命周期也不同:迁移在生产环境只执行一次且极少回滚,Seeder 则可能反复执行、随业务调整而修改。
如果把大量数据填充逻辑塞进迁移,会带来几个实际麻烦。第一,迁移执行完会被记录在 migrations 表中,之后你修正了 Seeder 里的数据错误,也无法通过 php artisan migrate 重新生效,因为这条迁移已经跑过了。第二,回滚迁移通常只做 dropTable 或 rollback 结构操作,如果你在 up 时插入了一万条数据,回滚再重跑就可能出现数据错乱。第三,团队其他成员拉取代码后本地执行迁移,如果你插入的数据依赖环境特定状态,很容易在他的机器上炸掉。
所以 Laravel 官方的建议始终是:结构变化走迁移,可维护的初始数据走 Seeder,两者通过部署脚本串联。比如部署时依次执行 php artisan migrate --force 和 php artisan db:seed --force,职责清晰,出问题也容易定位。
确实需要在迁移中写入数据时的正确写法
有些场景下数据必须跟随结构一起落地,例如新增一个非空字段时必须给存量行填上默认值,或者某些配置表离开了初始数据系统就跑不起来。这时如果坚持在迁移里写数据,一定要保证两点:可重复执行和可安全回滚。可重复执行的意思是,即使这条迁移被执行两次(比如手动操作数据库导致的脏状态),也不会产生重复数据。
最常用的手段是先查询后插入,配合唯一索引做兜底。来看一个典型示例:
public function up(): void
{
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name')->unique(); // 唯一索引是幂等的基础
$table->string('label');
$table->timestamps();
});
// 幂等插入:先判断再写入
$now = now();
$defaults = [
['name' => 'admin', 'label' => '管理员', 'created_at' => $now, 'updated_at' => $now],
['name' => 'editor', 'label' => '编辑', 'created_at' => $now, 'updated_at' => $now],
];
foreach ($defaults as $item) {
Role::firstOrCreate(['name' => $item['name']], $item);
}
}注意这里用了 Eloquent 的 firstOrCreate 而不是裸的 insert,它会先按 name 查找,不存在才创建。即便迁移因异常中断后重跑,也不会触发唯一键冲突。对应的 down() 方法里应该干净地删除数据并删表:
public function down(): void
{
// 先清理本迁移写入的数据,再处理结构
Role::whereIn('name', ['admin', 'editor'])->delete();
Schema::dropIfExists('roles');
}另外一个细节:在迁移中尽量使用完整的 Eloquent 模型或 DB::table(),而不是在迁移文件里 require Seeder 类。因为 Seeder 类将来可能被修改甚至删除,而迁移文件一旦合入主干就不该再改动。迁移里的数据应该是自包含、不依赖外部类的快照。
调用 Seeder 的推荐方式:命令串联而非代码耦合
如果你已经有写好的 Seeder 类,想让它在迁移执行时自动跑起来,比较优雅的做法不是在迁移里 Artisan::call('db:seed'),而是在部署流程中把两个命令排好队。例如在 CI/CD 脚本或部署钩子里:
php artisan migrate --force php artisan db:seed --force
如果项目有多个 Seeder,可以通过 DatabaseSeeder 统一编排,在 run() 方法中按依赖顺序调用 $this->call():
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
RoleSeeder::class,
CategorySeeder::class,
SettingSeeder::class,
]);
}
}非要代码触发的话,至少用 Artisan::call 并限定Seeder,避免误跑全部填充:
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
// 只调用指定的 Seeder,并捕获异常避免迁移中断
try {
Artisan::call('db:seed', ['--class' => 'SettingSeeder', '--force' => true]);
} catch (\Throwable $e) {
Log::warning('SettingSeeder 执行失败: ' . $e->getMessage());
}
}这里用 try-catch 包住是有意为之:Seeder 失败不应该阻断结构迁移,否则线上部署会卡住。但也要想清楚,这种静默降级可能掩盖问题,务必配合日志告警。
模型工厂的正确使用场景与常见误用
模型工厂(Factory)是给测试和本地开发造数据用的,千万不要把它用在生产环境的迁移或 Seeder 里。一个常见错误是有人在生产部署的 Seeder 中大量调用 User::factory()->count(1000)->create(),结果线上数据库被灌进一千个假用户。工厂生成的数据带随机姓名、随机邮箱,对生产库来说就是垃圾数据。
工厂的正确打开方式是在 Pest 或 PHPUnit 测试中配合 RefreshDatabase trait 使用:
class OrderTest extends TestCase
{
use RefreshDatabase;
public function test_order_total_is_calculated_correctly(): void
{
// RefreshDatabase 会自动执行迁移并重建数据库
$user = User::factory()->create();
$order = Order::factory()
->for($user)
->has(OrderItem::factory()->count(3))
->create(['price' => 100]);
$this->assertEquals(300, $order->items->sum('price'));
}
}而在本地开发环境造假数据,正确路径是写一个专门的 Seeder 调用工厂,并且只在非生产环境下允许执行:
class DemoDataSeeder extends Seeder
{
public function run(): void
{
// 阻止假数据进入生产环境
if (app()->environment('production')) {
return;
}
User::factory()->count(50)->create();
Post::factory()->count(200)->recycle(User::all())->create();
}
}recycle() 是个实用技巧,它复用已创建的模型而不是每次新建,避免五十篇文章生成五十个新用户,让假数据的关联关系更接近真实情况。
总结一下决策思路
面对「要不要在迁移里填数据」这个问题,可以按下面的顺序判断:如果数据是结构的一部分(非空字段的默认值、字典表),写入迁移但必须做到幂等;如果是业务初始数据,放进独立 Seeder,由部署脚本串联执行;如果是测试假数据,交给模型工厂并限制在测试与本地环境。同时记住两条红线:迁移文件一旦合入就不要再改,生产环境永远不要调用模型工厂。把结构、种子、假数据三层职责分开,你的数据库版本管理会清爽很多,团队协作时的诡异冲突也会大幅减少。