[Complete Practical Guide] Laravel Migrations, Seeding, and Factory Design — Safe DB Changes, Test Data, Initial Data, and Rollbacks
What you will learn in this article
- How to safely design Laravel migrations
- Practical patterns for reducing problems when adding, changing, or deleting columns
- How to create initial data and test data using Seeders and Factories
- The order of DB changes to be careful about during production deployment
- Operations with rollbacks, backups, data migration, and zero downtime in mind
- Clear data design that connects to admin screens and forms
- Key points for accessibility-conscious initial data, wording, and state management
Target readers
- Beginner to intermediate Laravel engineers: Those who can write migrations but feel uneasy about production DB changes
- Tech leads: Those who want to establish DB change rules and review standards within a team
- QA / maintenance staff: Those who feel that test data and initial data are unstable and difficult to verify
- Designers / CS / accessibility staff: Those who want to organize data used for status names, initial wording, and screen display in an easy-to-understand way
Accessibility level: ★★★★☆
Although the main topic of this article is DB design, how data is stored is directly connected to UI clarity. For example, if status values, role names, notification templates, and form options are not well organized, they become difficult to explain on the screen side. This article also explains points such as state display that does not rely only on color, readable labels, and initial data that is easy to translate.
1. Introduction: Migrations Are the “Resume” of Your Database
Laravel migrations are a mechanism for managing database structures as code. They allow you to keep changes such as creating tables, adding columns, adding indexes, and defining foreign keys as files. This is not merely a convenient feature; it plays a very important role in team development and production operations.
When DB changes are made manually, it becomes unclear “who changed what, when.” Problems are more likely to occur, such as a column existing locally but not in staging, an index missing only in production, or initial data differing in the test environment. By using migrations, you can manage DB change history with Git and allow everyone on the team to reproduce the same steps.
However, while migrations are convenient, they require caution when used in production. In particular, column deletion, type changes, large-scale data updates, foreign key additions, and index additions to huge tables can cause service outages or long locks. In this article, we will organize Laravel migrations not only as “how to write them,” but also as a way of thinking about “how to operate them safely.”
2. Basic Migrations: Start by Creating a Table
First, let’s create a basic table. For example, to create a posts table, run the following command.
php artisan make:migration create_posts_table
Write the table structure in the generated file.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title', 120);
$table->string('slug')->unique();
$table->text('body');
$table->string('status', 30)->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['status', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
What matters in this example is that the column names and states are easy to use on the screen.
The status column is assumed to contain values such as draft, published, and archived. On the screen, these can be translated and displayed as “Draft,” “Published,” and “Archived.” A design where machine-friendly values are stored in the DB and converted into human-readable labels in the UI is easier to handle.
3. Naming Rules: Use Names That Are Clear Even Later
Migration file names will later be read as history.
For that reason, it is recommended to give them names that clearly show what the file does.
Good examples:
create_posts_table
add_status_to_orders_table
add_published_at_to_posts_table
create_notification_preferences_table
Unclear examples:
update_posts
fix_table
change_columns
add_fields
In team development, it is important that the outline of a change can be understood just by looking at the migration name. In reviews as well, specific file names make it easier to check the content.
Also, because table names and column names are connected to the UI and business operations, avoid vague names.
For example, choosing meaningful names such as is_active instead of flag, notification_type instead of type, and display_order instead of value makes maintenance easier.
4. Adding Columns: Carefully Consider nullable and default First
When adding a column to an existing table, you need to assume that production data already exists.
Here is an example of adding timezone to the users table.
php artisan make:migration add_timezone_to_users_table
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('timezone', 64)->nullable()->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
};
Making it nullable() at first makes it easier to add safely even when existing data is present. After that, add a fallback in the application for cases where the value has not been set.
$timezone = $user->timezone ?? config('app.timezone');
After confirming that values have been entered for all users, consider removing nullable if necessary.
If you add a not null column immediately, the operation may fail because existing data has no value. DB changes are safer when divided into the order of “add,” “migrate,” and “strengthen constraints.”
5. Deleting Columns: Proceed Especially Carefully in Production
Column deletion is one of the riskiest DB changes. The reason is that once a column is deleted, an error will occur the moment old application code references it. If you aim for zero downtime, do not delete it immediately; proceed step by step.
Example of a safe flow:
- Add a new column
- Make the application compatible with the new column
- Stop using the old column
- Confirm through logs and monitoring that the old column is no longer referenced
- Delete the old column in a separate release
In other words, column deletion is the “final cleanup.”
It is often safer not to include deletion migrations in the same release as a feature change.
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('old_summary');
});
For operations like this, during review you should check whether the application really no longer references the column, whether backups exist, and whether rollback will cause problems.
6. Index Design: Supporting Search and List Performance
Many causes of slow databases are missing indexes or inappropriate indexes. Laravel migrations allow indexes to be managed as code as well.
$table->index('status');
$table->index(['status', 'published_at']);
$table->unique('slug');
Design indexes by considering the conditions commonly used on list screens.
For example, when displaying published articles in order of publication date, the following condition often appears.
WHERE status = 'published'
ORDER BY published_at DESC
In this case, you can consider a composite index on status and published_at.
$table->index(['status', 'published_at']);
However, more indexes are not always better. They also increase write load and storage usage.
In practice, it is safer to think in the following order:
- What search conditions are frequently used?
- What sort orders are frequently used?
- Does the list screen handle many records?
- Is the index being used in the DB execution plan?
- Are unnecessary indexes increasing too much?
Indexes affect both perceived screen speed and operational cost.
7. Foreign Key Constraints: Protect Data Integrity at the DB Level Too
In Laravel, foreign key constraints can be written concisely.
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
This setting means that user_id references users.id, and related data is also deleted when the user is deleted.
However, although cascadeOnDelete() is convenient, you need to be careful about where you use it. Consider whether it is really appropriate for posts or orders to disappear when a user is deleted, based on business requirements.
For example, order data may need to remain as history even after a user is deleted. In that case, change the foreign key deletion behavior or design the system so that users are not physically deleted but instead suspended by status.
$table->foreignId('user_id')
->nullable()
->constrained()
->nullOnDelete();
DB constraints are a final line of defense separate from application-side validation.
There are situations where the DB protects data integrity even if there is an application bug or unexpected operation.
8. Designing Status Values: Make Them Easy to Display Clearly in the UI
Many applications use a status column.
For example, an order may have the following states:
pendingpaidshippedcancelled
Store mechanical English values in the DB, and translate them for display on the screen.
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Waiting for payment',
self::Paid => 'Paid',
self::Shipped => 'Shipped',
self::Cancelled => 'Cancelled',
};
}
}
You can use Enums with Laravel casts.
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
When displaying states, do not communicate meaning with color alone.
For example, always display text such as “Cancelled,” instead of only showing a red badge. This is effective not only for accessibility but also for preventing misunderstandings in operational UIs.
9. Seeder: Reproduce Initial Data as Code
Seeders are a mechanism for inserting initial data.
They can be used for roles, permissions, categories, setting values, development data, and more.
php artisan make:seeder RoleSeeder
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Role;
class RoleSeeder extends Seeder
{
public function run(): void
{
foreach (['admin', 'editor', 'viewer'] as $role) {
Role::firstOrCreate(['name' => $role]);
}
}
}
Call it from DatabaseSeeder.
public function run(): void
{
$this->call([
RoleSeeder::class,
]);
}
What matters with Seeders is that they do not break even when executed multiple times.
If you only use create(), duplicates may be created when the Seeder is run again. For initial data, using firstOrCreate() or updateOrCreate() is safer.
10. Separate Production Seeders and Development Seeders
Development dummy data and initial data required in production should be considered separately.
Examples of initial data required in production:
- Roles
- Permissions
- Prefectures / regions
- Notification templates
- System settings
- Fixed categories
Examples of development data:
- Test users
- Dummy articles
- Sample orders
- Large amounts of Factory data
Running development Seeders in production is dangerous.
Separate Seeder names and calling methods so that it is clear which Seeders are executed in each environment.
public function run(): void
{
$this->call([
RoleSeeder::class,
PermissionSeeder::class,
]);
if ($this->app->environment('local')) {
$this->call([
DemoUserSeeder::class,
DemoPostSeeder::class,
]);
}
}
11. Factory: Create Readable Test Data
Factories are a mechanism for creating data for tests and development.
php artisan make:factory PostFactory --model=Post
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'title' => fake()->sentence(4),
'slug' => fake()->unique()->slug(),
'body' => fake()->paragraphs(3, true),
'status' => 'draft',
'published_at' => null,
];
}
public function published(): static
{
return $this->state(fn () => [
'status' => 'published',
'published_at' => now()->subDay(),
]);
}
}
Usage:
Post::factory()->published()->create();
Adding state methods to Factories makes tests easier to read.
public function test_published_posts_are_visible()
{
Post::factory()->published()->create(['title' => 'Published article']);
Post::factory()->create(['title' => 'Draft article']);
$response = $this->get('/posts');
$response->assertSee('Published article')
->assertDontSee('Draft article');
}
For test data, it is important not simply to increase the number of records, but to create “states whose intent is clear.”
12. Factories and Relationships: Create Data Close to Reality
Test data that includes relationships can also be created with Factories.
$user = User::factory()
->has(Post::factory()->count(3)->published())
->create();
Many-to-many relationships, such as tagged posts, can also be created.
$post = Post::factory()
->published()
->hasAttached(Tag::factory()->count(2))
->create();
However, if you create more data than necessary in tests, the tests become slow.
What matters is creating realistic structures in a small size.
Thinking “I need two records that satisfy this condition” instead of “I need 100 records” makes tests faster and easier to read.
13. Data Migration: Do Not Update Too Much Data Inside Migrations
When adding a column with a migration, you may want to update existing data at the same time.
This is fine for small tables, but if you update a large amount of data all at once inside a migration, production deployment may be blocked for a long time.
Dangerous example:
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('timezone')->nullable();
});
User::query()->update(['timezone' => 'Asia/Tokyo']);
}
For large amounts of data, it is safer to separate the steps as follows:
- Add the column
- Add a fallback in the application for unset values
- Fill the data little by little with batches or jobs
- Confirm that all records have been filled
- Remove
nullableif necessary
It is safer not to pack DB structure changes and large-scale data updates into the same migration.
14. Rollback: Separate Changes That Can Be Reverted from Those That Cannot
Laravel migrations have down(). However, in production, some changes cannot be rolled back simply.
Changes that are relatively easy to revert:
- Creating a new table
- Adding a nullable column
- Adding an index
Changes that are difficult to revert:
- Deleting a column
- Changing a type
- Transforming data
- Overwriting existing values
In particular, changes that lose data may not be reversible even if down() is written.
For example, after deleting a column, even if you recreate the column during rollback, the original data will not return.
Therefore, do not rely too much on rollback. Prepare the following:
- Pre-deployment backups
- Prior execution in staging
- Small change units
- Compatible release procedures
- Manual recovery procedures if necessary
down() is important, but whether the change can “really be reverted” must be considered separately.
15. DB Changes During Production Deployment: Keep Compatibility in Mind
In deployments that include DB changes, old code and new code may temporarily coexist. This is especially important for zero downtime and multi-server configurations.
The safe principles are as follows:
- Add first
- Make it work with both old and new code
- Switch to the new code
- Delete the old structure later
For example, when splitting name into first_name and last_name, deleting name immediately is dangerous.
First add the new columns, make the application able to handle both, migrate the data, and only delete the old column after the migration is complete.
In this way, DB changes should be handled more carefully than code changes.
Because table structure is the foundation of the entire application, it is safer to avoid sudden deletions or changes.
16. Accessibility and DB Design: Organize States, Labels, and Options
DB design also affects how easy screens are to understand.
For example, if status values are vague, the UI display will also be vague.
Examples to avoid:
status = 1
status = 2
status = 3
States that are only numeric like this become difficult to read later.
If you use numbers, make their meaning clear with Enums or constants.
Preferred examples:
draft
published
archived
On the screen, they can be displayed as follows:
- Draft
- Published
- Archived
Even when colors are added, the presence of text labels means the UI does not depend on color perception.
Also, structuring options and initial data so that they are easy to translate makes multilingual support easier.
17. Testing: Protect the Quality of Migrations, Seeders, and Factories
In Laravel, you can build the DB with migrations during testing.
Using RefreshDatabase makes it easier to reset the DB state for each test.
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_published_post_is_visible(): void
{
Post::factory()->published()->create([
'title' => 'Published article',
]);
$response = $this->get('/posts');
$response->assertSee('Published article');
}
}
Testing Seeders is also important.
In particular, initial data such as roles and permissions may be required for the application to work.
public function test_roles_are_seeded(): void
{
$this->seed(RoleSeeder::class);
$this->assertDatabaseHas('roles', [
'name' => 'admin',
]);
}
Factories are directly connected to test readability.
You do not need to add too many state methods, but it is convenient to collect frequently used states into Factories.
18. Common Pitfalls and How to Avoid Them
18.1 Adding a not null Column Suddenly in Production
This may fail when existing data is present.
First add it as nullable, then strengthen the constraint after data migration.
18.2 Deleting a Column in the Same Release as a Feature Change
An error will occur if old code references it.
Delete it later after confirming there are no references.
18.3 Seeder Creates Duplicate Data
Using only create() causes duplication when run again.
Use firstOrCreate() or updateOrCreate().
18.4 Factory Data Is Too Unrealistic
If the data only works in tests, bugs may be overlooked.
Create Factories with at least minimal consistency.
18.5 Status Values Are Only Numbers and Their Meaning Is Unclear
This makes screen display and maintenance difficult.
Clarify the meaning using Enums or string values.
18.6 Packing Large-Scale Data Updates into a Migration
Deployment may stop for a long time.
Migrate gradually using batches or jobs.
19. Checklist for Distribution
Migration Design
- [ ] The file name describes the change
- [ ] Column names are meaningful
- [ ] nullable / default is considered with existing data in mind
- [ ] Column deletion is carefully separated
- [ ] Indexes match search conditions
- [ ] Foreign key deletion behavior matches business requirements
Production Operations
- [ ] Backups have been confirmed before DB changes
- [ ] Large-scale data updates are not packed into migrations
- [ ] The order of add → migrate → delete is followed
- [ ] It has been confirmed whether rollback can really restore the previous state
Seeder
- [ ] Production initial data and development data are separated
- [ ]
firstOrCreate()/updateOrCreate()is used so re-execution is safe - [ ] Seeders exist for required data such as roles and permissions
Factory
- [ ] Frequently used state methods exist
- [ ] Data with relationships can be created naturally
- [ ] Only the minimum number of records required for tests is created
Accessibility / UI Integration
- [ ] Status values can be displayed clearly on the screen
- [ ] State labels can be created without relying only on color
- [ ] Options and initial data are easy to translate
- [ ] They can be converted into meaningful labels in the admin screen
20. Conclusion
Laravel migrations are not only for managing DB structures; they are also an important mechanism for stabilizing team development and production operations. By managing table creation, column additions, indexes, and foreign keys as code, you can reduce environment differences and clarify change history.
On the other hand, production DB changes must be handled carefully. In particular, adding not null columns, deleting columns, changing types, and performing large-scale data migrations are safer when divided into stages such as add → migrate → delete. By organizing Seeders and Factories, initial data and test data also become easier to reproduce.
DB design is not only a backend concern. Status values, role names, options, and initial wording are also connected to screen clarity and accessibility. By organizing data so that it is easy for both machines and people to handle, the overall quality of a Laravel application improves. Start by reviewing existing migrations and Factories, and gradually improve them toward meaningful names, safe changes, and initial data that is robust against re-execution.

