Back to blog

Database

Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories

Covers atomic migrations, safe schema changes in production, Eloquent factories for realistic test data, and migration-only deployments.

  • Laravel
  • Database
  • Migrations
  • Seeds
  • Factories

Reader map

Key points in Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories

Syntax first, runtime behavior second, migration cleanup last.

Read
14 min
Waypoints
8
Track
Database
  1. 01
    Start here

    Keep migrations small and ordered.

  2. 02
    Waypoint

    Make up() safe to run once and down() honest about what can be reversed.

  3. 03
    Waypoint

    Avoid destructive schema changes in the same release that still serves old code.

  4. 04
    Waypoint

    Run production migrations with one process, not from every web server at once.

  5. 05
    Waypoint

    Use factories for realistic test data.

  6. 06
    Waypoint

    Use seeders for deterministic baseline data.

  7. 07
    Waypoint

    Keep random demo data out of production seeders.

  8. 08
    Migration check

    Test rollback paths before the release, not during an incident.

SEO Metadata

SEO Title Options

  1. Database Migrations Best Practices in Laravel: Rollbacks
  2. Laravel Database: Practical 2026 Guide
  3. Database Playbook: Laravel Database

Meta Description Options

  1. Learn Laravel Database with a practical Database framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
  2. Covers atomic migrations, safe schema changes in production, Eloquent factories for realistic test data, and migration-only deployments.

URL Slug

database-migrations-best-practices-laravel-rollbacks-seeds-factories

Focus Keyword

Laravel Database

Additional LSI Keywords

  • Database
  • Laravel
  • Migrations
  • Seeds
  • Factories
  • Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

Laravel Database is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • Laravel Database should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: Laravel Database expert guide for Database]

What Laravel Database means

Laravel Database means applying database knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For database topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: Laravel Database implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat Laravel Database as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: Laravel Database common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for Laravel Database with input, decision boundary, implementation, tests, and production feedback. Alt: Laravel Database concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories. Alt: Laravel Database mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: Laravel Database comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for Laravel Database.]

Internal linking opportunities

Original Technical Deep Dive

The short version

Laravel migrations are deployment code. Treat them with the same discipline as application code.

Use these rules:

  • Keep migrations small and ordered.
  • Make up() safe to run once and down() honest about what can be reversed.
  • Avoid destructive schema changes in the same release that still serves old code.
  • Run production migrations with one process, not from every web server at once.
  • Use factories for realistic test data.
  • Use seeders for deterministic baseline data.
  • Keep random demo data out of production seeders.
  • Test rollback paths before the release, not during an incident.

The main mistake is treating migrations as a local development convenience. In production, a migration can lock tables, drop data, break old application code, or leave a partial deployment behind. The best migration is boring: small, reversible when possible, compatible with the currently deployed code, and easy to inspect.

What belongs in a migration

A migration should describe a schema change:

  • Create a table.
  • Add a column.
  • Add an index.
  • Rename a column.
  • Drop a column.
  • Add or remove a foreign key.
  • Change a column definition when the database can do it safely.

It should not be a dumping ground for arbitrary application logic.

Good migration:

<?php

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('invoices', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('number', 32)->unique();
            $table->string('status', 32)->index();
            $table->unsignedBigInteger('total_cents');
            $table->timestamp('paid_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('invoices');
    }
};

Bad migration:

public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->string('billing_status')->nullable();
    });

    User::query()->chunk(100, function ($users): void {
        foreach ($users as $user) {
            app(BillingApi::class)->sync($user);
        }
    });
}

That migration now depends on Eloquent models, service container bindings, network access, third-party API availability, and current application behavior. Six months later, the User model may no longer match the old schema. Do not make schema history depend on live domain code.

If you must backfill data, either:

  • use direct query builder statements against stable column names,
  • create a separate idempotent Artisan command,
  • or ship the schema change first and run the backfill as an observable job.

One concern per migration

Keep migration files narrow.

Better:

2022_05_11_100000_create_invoice_statuses_table.php
2022_05_11_101500_create_invoices_table.php
2022_05_11_103000_add_paid_at_to_invoices_table.php
2022_05_11_110000_add_status_created_at_index_to_invoices_table.php

Worse:

2022_05_11_100000_rebuild_billing_module.php

Small migrations are easier to review, rollback, and test. They also make production failure easier to reason about. If one file creates six tables, adds ten indexes, updates data, and drops old columns, you no longer have a clean failure boundary.

Make rollbacks honest

Laravel's down() method should reverse up() when reversal is actually safe.

For simple schema changes, this is direct:

public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->string('timezone', 64)->default('UTC');
    });
}

public function down(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->dropColumn('timezone');
    });
}

For destructive changes, be explicit. A rollback cannot magically restore deleted data:

public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->dropColumn('legacy_notes');
    });
}

public function down(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->text('legacy_notes')->nullable();
    });
}

[IMAGE: Supporting visual 1 for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories, showing Laravel Database decisions, examples, and Laravel, Database, Migrations. Alt: Laravel Database database-migrations-best-practices-laravel-rollbacks-seeds-factories visual 1]

[IMAGE: Supporting visual 1 for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories, showing Laravel Database decisions, examples, and Laravel, Database, Migrations. Alt: Laravel Database database-migrations-best-practices-laravel-rollbacks-seeds-factories visual 1]

That down() recreates the column shape, not the lost values. Document that in the pull request and release plan.

Use rollback commands locally and in staging:

php artisan migrate
php artisan migrate:rollback --step=1
php artisan migrate

For a larger batch:

php artisan migrate:rollback --step=5

Do not depend on migrate:reset, migrate:refresh, or migrate:fresh for production rollback. Those commands are useful for development and test databases. In production, rollback should usually mean deploying a compatible application version plus a specific migration rollback or forward-fix.

Safe production schema changes

Production migrations need to be compatible with rolling deploys.

A safe additive migration:

public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->string('display_name')->nullable()->after('name');
    });
}

This is safe because old code can ignore the column and new code can write it.

Then deploy application code that starts writing display_name.

After data is backfilled and reads no longer require name, make stricter changes later:

public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->string('display_name')->nullable(false)->change();
    });
}

For a column rename, use an expand and contract sequence instead of a hard rename:

Release 1:
  Add new nullable column.
  Code writes both old and new columns.

Release 2:
  Backfill old rows.
  Code reads from new column, falls back to old column.

Release 3:
  Code reads only new column.
  Drop old column in a later migration.

This avoids breaking old workers, queued jobs, Horizon processes, Octane workers, and web servers that may still run old code during deployment.

Indexes are production changes too

Indexes improve reads but can hurt writes and lock tables while being created, depending on database engine, version, and index type.

Before adding an index, answer:

  • Which query will use it?
  • Does the column order match the query?
  • Is it covering a real production path?
  • How large is the table?
  • Can the database create it online?
  • What is the rollback plan if it blocks writes?

Example:

Schema::table('orders', function (Blueprint $table): void {
    $table->index(['user_id', 'status', 'created_at'], 'orders_user_status_created_index');
});

This index is useful for queries like:

Order::query()
    ->where('user_id', $userId)
    ->where('status', 'paid')
    ->latest()
    ->limit(25)
    ->get();

It may not help a query that only filters by created_at.

Name important indexes and foreign keys explicitly. Laravel can generate names, but explicit names make rollbacks, renames, and cross-database behavior easier to inspect:

$table->foreign('user_id', 'orders_user_id_foreign')
    ->references('id')
    ->on('users')
    ->cascadeOnDelete();

This matters especially before renaming tables with foreign keys, because generated constraint names can still refer to old table names.

Run migrations once during deployment

If multiple servers deploy at the same time, do not let each server run migrations independently.

Use a single release step:

php artisan migrate --force

When your Laravel version supports isolated migrations and the cache driver is shared by every deploy runner, use:

php artisan migrate --isolated --force

[IMAGE: Supporting visual 2 for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories, showing Laravel Database decisions, examples, and Laravel, Database, Migrations. Alt: Laravel Database database-migrations-best-practices-laravel-rollbacks-seeds-factories visual 2]

--force is required in production automation because Laravel protects production environments with confirmation prompts.

--isolated protects multi-server deployments by acquiring a cache-backed atomic lock before running migrations. That lock only works if every deploy process talks to the same cache backend. A local file cache on each server does not protect a cluster.

[IMAGE: Supporting visual 2 for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories, showing Laravel Database decisions, examples, and Laravel, Database, Migrations. Alt: Laravel Database database-migrations-best-practices-laravel-rollbacks-seeds-factories visual 2]

Keep migrations out of web request startup. A user request should never be responsible for changing production schema.

Migration-only deployments

Sometimes the safest deployment is only a migration.

Use migration-only deployments for:

  • adding nullable columns before code uses them,
  • adding tables before a feature flag turns on,
  • adding indexes before a traffic-heavy release,
  • adding lookup tables needed by future code,
  • creating compatibility columns for a later rename.

Deployment order:

1. Deploy migration-only release.
2. Run migrations once.
3. Verify schema and database health.
4. Deploy application code that uses the new schema.
5. Backfill if needed.
6. Remove old schema later.

This is slower than a single all-in release, but it avoids coupling schema availability to application rollout timing.

Seeds are not factories

Seeders and factories solve different problems.

Factories define how to make realistic model records:

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class CustomerFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'company' => fake()->company(),
            'external_id' => (string) Str::uuid(),
            'trial_ends_at' => now()->addDays(14),
        ];
    }

    public function expiredTrial(): static
    {
        return $this->state(fn (): array => [
            'trial_ends_at' => now()->subDay(),
        ]);
    }
}

Seeders decide which records should exist in a specific environment or test setup:

<?php

namespace Database\Seeders;

use App\Models\Customer;
use Illuminate\Database\Seeder;

class DemoCustomerSeeder extends Seeder
{
    public function run(): void
    {
        Customer::factory()
            ->count(25)
            ->create();

        Customer::factory()
            ->expiredTrial()
            ->count(5)
            ->create();
    }
}

Use factories in tests and local/demo seeders. Use deterministic seeders for reference data.

Deterministic seeders

Reference data should be predictable and idempotent.

Good examples:

  • roles,
  • permissions,
  • countries,
  • currencies,
  • feature flags,
  • order statuses,
  • billing plan definitions.

Use upsert() instead of raw inserts that fail on the second run:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class OrderStatusSeeder extends Seeder
{
    public function run(): void
    {
        DB::table('order_statuses')->upsert([
            ['key' => 'draft', 'label' => 'Draft', 'sort_order' => 10],
            ['key' => 'paid', 'label' => 'Paid', 'sort_order' => 20],
            ['key' => 'shipped', 'label' => 'Shipped', 'sort_order' => 30],
            ['key' => 'cancelled', 'label' => 'Cancelled', 'sort_order' => 40],
        ], ['key'], ['label', 'sort_order']);
    }
}

Then call it from DatabaseSeeder:

public function run(): void
{
    $this->call([
        OrderStatusSeeder::class,
        PermissionSeeder::class,
    ]);
}

Production seeding should be explicit:

php artisan db:seed --class=OrderStatusSeeder --force

Do not run a random demo seeder in production because it happens to be inside DatabaseSeeder.

Realistic factories

A useful factory creates valid data that behaves like real data.

Avoid this:

return [
    'email' => fake()->word(),
    'total_cents' => 1,
    'status' => 'x',
];

Use domain-shaped values:

return [
    'email' => fake()->unique()->safeEmail(),
    'total_cents' => fake()->numberBetween(1000, 200000),
    'status' => fake()->randomElement(['draft', 'paid', 'shipped']),
];

Use states for important business cases:

public function paid(): static
{
    return $this->state(fn (): array => [
        'status' => 'paid',
        'paid_at' => now(),
    ]);
}

public function cancelled(): static
{
    return $this->state(fn (): array => [
        'status' => 'cancelled',
        'cancelled_at' => now(),
    ]);
}

Tests become clearer:

$order = Order::factory()
    ->paid()
    ->for(User::factory())
    ->create();

$this->assertDatabaseHas('orders', [
    'id' => $order->id,
    'status' => 'paid',
]);

Factories should know how to create valid records. Tests should describe the scenario.

Factory relationships

Use factory relationships instead of manually wiring IDs everywhere.

Create a user with orders:

$user = User::factory()
    ->has(Order::factory()->count(3))
    ->create();

Create an order for a user:

$order = Order::factory()
    ->for(User::factory())
    ->create();

Create nested data:

$customer = Customer::factory()
    ->has(
        Invoice::factory()
            ->count(3)
            ->has(LineItem::factory()->count(5))
    )
    ->create();

This produces test data that matches your Eloquent relationships. It also keeps tests readable when the schema evolves.

[IMAGE: Supporting visual 3 for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories, showing Laravel Database decisions, examples, and Laravel, Database, Migrations. Alt: Laravel Database database-migrations-best-practices-laravel-rollbacks-seeds-factories visual 3]

Testing migrations and seeders

Use RefreshDatabase for feature tests by default:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CheckoutTest extends TestCase
{
    use RefreshDatabase;

    public function test_paid_order_is_created(): void
    {
        $this->seed(OrderStatusSeeder::class);

        $user = User::factory()->create();

        $response = $this->actingAs($user)->post('/checkout', [
            'plan' => 'pro',
        ]);

        $response->assertRedirect('/billing');

        $this->assertDatabaseHas('orders', [
            'user_id' => $user->id,
            'status' => 'paid',
        ]);
    }
}

Use $this->seed() when the test needs baseline data. Do not load the entire demo database into every feature test. Slow tests train teams to skip tests.

For critical seeders, write a focused test:

public function test_order_status_seeder_is_idempotent(): void
{
    $this->seed(OrderStatusSeeder::class);
    $this->seed(OrderStatusSeeder::class);

    $this->assertDatabaseCount('order_statuses', 4);
    $this->assertDatabaseHas('order_statuses', [
        'key' => 'paid',
        'label' => 'Paid',
    ]);
}

That catches duplicate inserts before production does.

Backfills without pain

For small tables, a migration with query builder updates may be acceptable:

public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->string('display_name')->nullable();
    });

    DB::table('users')->orderBy('id')->chunkById(500, function ($users): void {
        foreach ($users as $user) {
            DB::table('users')
                ->where('id', $user->id)
                ->update(['display_name' => $user->name]);
        }
    });
}

For large tables, separate the schema change from the backfill:

1. Add nullable column.
2. Deploy code that handles null.
3. Run queue or command backfill in chunks.
4. Monitor progress.
5. Add not-null constraint in a later release.

Large backfills inside migrations create long deploys, lock contention, and awkward failure modes. A command can be restarted, monitored, throttled, and paused.

[IMAGE: Supporting visual 3 for Database Migrations Best Practices in Laravel: Rollbacks, Seeds & Factories, showing Laravel Database decisions, examples, and Laravel, Database, Migrations. Alt: Laravel Database database-migrations-best-practices-laravel-rollbacks-seeds-factories visual 3]

Common mistakes

Do not use Eloquent models heavily inside old migrations.

Models change over time. A migration from 2022 should not depend on a 2026 model shape.

Do not drop columns in the same release that removes reads from application code.

Old code, queued jobs, and long-running workers may still read the column.

Do not make down() pretend data can be recovered.

If values are lost, say so.

Do not run migrate:fresh --seed against shared databases.

It drops all tables. It is a local and test database tool, not a production repair strategy.

Do not let random factories create invalid states.

If impossible data enters tests, tests stop describing reality.

Do not put every seeder into production.

Separate reference data from demo data.

Release checklist

Before merging a migration:

  • The migration has one clear purpose.
  • php artisan migrate --pretend output was reviewed when the change is risky.
  • up() works on a copy of realistic data.
  • down() was tested or documented as intentionally limited.
  • Large table changes have a lock and runtime plan.
  • Foreign keys and important indexes have explicit names.
  • The migration is compatible with old and new application code.
  • Backfills are chunked or moved into a restartable command.
  • Production seeders are deterministic and idempotent.
  • Factories create valid domain data.
  • Tests use RefreshDatabase and only seed what they need.

FAQ

What is Laravel Database?

Laravel Database is a practical database topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use Laravel Database?

Use Laravel Database when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with Laravel Database?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test Laravel Database?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does Laravel Database affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

Laravel Database is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top