Never use model calls inside of migrations in Laravel
Yazan Stash • September 22, 2021
One of the strong features of Laravel is its database migrations system, if you don’t know what migrations are, you can read more about it here, but basically, it’s a way to define your database schema in code incrementally, which would enable you to version control the database design and re-build the database to any point in time of the project’s life.
In some cases, you might need to either seed functional static data into a table or transform old data into new formats/tables; let’s take an example.
Imagine that you have a table for holding accepted payment options with an
is_enabled flag, obviously, the options you support are not dynamic, but rather their enabled status is, so when you implemented the feature you created the table using a migration, but now you need a way to add this static list of payment options, and you have two options
A) Fill this data in the same migration that created the table, a migration is to prepare the database after all, no?
B) Use a seeder, since it is literally designed to seed data into your app.
Going with option B is perfectly fine, but you need to make sure that there are no dependencies on the data-to-be-seeded in other migrations since seeders are typically run after all migrations has completed, which should be fine as long you’re disciplined about this. However, we’re here to talk about option A.
I’ve seen a fair bunch of apps solve this by seeding the static data inside the migration using a call to the model, for example inside the migration you’d see something like
PaymentOption::create(['name' => 'Cash on Delivery', 'slug' => 'cod', 'is_enabled' => 1]);
At first glance, this would look fine, but the problem lies in the fact that migrations are incremental, while models are a snapshot. Meaning that the model will look the same on the first migration through the last migration, while the database will not, let’s create a tiny timeline to make it clearer
Commit A: Create migration to create the table, and call the
PaymentOption model to seed the static data.
Commit B: Add a
uuid column to the same table, and register a model hook on the
creating event to generate a value for the
Now if we run the
artisan migrate:fresh command which will drop all tables and re-build the database from scratch, it would fail. And that's because now our model and database are out of sync at CommitA; the model expects a column named
uuid on the table and will try to fill it, but at CommitA we don’t yet have this column on the table because it was added in CommitB. And you got an exception.
You might be inclined to just abandon ship and don’t seed in migrations, but we could still make it work reliably.
The trick is to only use either the Query Builder or native SQL using DB::statement()
This way no matter what the model’s state looks like, we’re still coupling seeding to the database state, not the model’s state.
And this is why you should never call your models from inside migrations, I hope this helps, and as always I would love to hear your thoughts below in the comments section!