Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Database Migrations and Seeding Best Practices in Laravel

By Kokil Thapa | Last reviewed: August 2026

Getting database migrations and seeding best practices in Laravel right is the difference between a deploy that takes three minutes and one that causes two hours of downtime. In production environments, especially for legal-tech or eCommerce platforms where data integrity is non-negotiable, you cannot treat schema management as an afterthought. This guide covers the exact workflow I use on client projects to ensure every migration is safe, reversible, and performant.

For teams building complex systems, understanding modern Laravel architecture best practices provides the broader context for how database management fits into application design. Migrations are not just about creating tables; they are the primary mechanism for communicating structural intent across development, staging, and production environments.

How do you structure safe database migrations and seeding best practices in Laravel?

Safe migrations start with discipline. On every project I maintain, from simple directories to complex booking systems, the rule is identical: never modify a migration that has been committed to the main branch or run on a shared environment. If you made a mistake in a column type or forgot an index, create a new migration to fix it. Editing existing files breaks the chronological contract that allows your team to replay history reliably.

Naming conventions that prevent confusion

Laravel’s default timestamp prefixing handles ordering, but your descriptive name matters for debugging at 2 AM. Use verb-noun syntax that describes the transformation, not just the table. create_users_table is fine for the initial creation, but subsequent changes should read like instructions:

  • add_verified_at_to_users_table — clear addition
  • change_price_column_to_decimal_in_products_table — explicit type change
  • drop_legacy_status_column_from_orders_table — safe removal with context
  • add_index_for_email_lookup_on_users_table — performance optimization

This convention makes php artisan migrate:status output readable without opening each file. When you are scanning fifty migrations during a production incident, descriptive names save cognitive load.

The two-method vs single-method decision

Laravel supports both up()/down() pairs and the newer Schema::build() pattern introduced in recent versions. For most production applications, stick with explicit up() and down() methods. The ability to roll back is critical when a deployment fails mid-migration or introduces a regression discovered only after release. The build() method is convenient for greenfield prototypes but removes your safety net in environments where data exists.

<?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::table('orders', function (Blueprint $table) {
            $table->decimal('total_amount', 12, 2)->after('subtotal');
            $table->index(['user_id', 'created_at'], 'idx_user_orders_date');
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex('idx_user_orders_date');
            $table->dropColumn('total_amount');
        });
    }
};

Note the explicit index name in the example above. Relying on Laravel’s auto-generated index names makes rollbacks fragile because the generated name depends on column order and can differ between environments if columns were added in different sequences over time. Always name your indexes.

Write MigrationVerb-Noun NamingTest Locallymigrate:fresh + seedCI ValidationLint + Dry RunProduction DeployZero-DowntimeNever Edit CommittedExplicit Index NamesBackup First
Safe workflow for database migrations and seeding best practices in Laravel across environments

When should you use seeders versus factories in Laravel?

A common mistake in Laravel projects is conflating seeders with test data generation. Seeders populate your database with baseline data required for the application to function: admin users, permission roles, country lists, tax rates, or legal document categories. Factories generate arbitrary test data for development and automated testing. Mixing these concerns leads to bloated seeders that take minutes to run and contain logic that belongs in tests.

Idempotent seeders are mandatory

Every seeder you write must be safe to run multiple times without error or duplicate data. In practice, this means avoiding raw insert() calls unless wrapped in existence checks. Prefer updateOrCreate() or upsert() for reference data that might change between releases.

<?php

namespace Database\Seeders;

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

class LegalServiceTypeSeeder extends Seeder
{
    public function run(): void
    {
        $services = [
            ['slug' => 'court-marriage', 'name' => 'Court Marriage Registration', 'is_active' => true],
            ['slug' => 'notary-attestation', 'name' => 'Document Notarization', 'is_active' => true],
            ['slug' => 'divorce-filing', 'name' => 'Divorce Proceedings', 'is_active' => true],
            ['slug' => 'property-transfer', 'name' => 'Property Transfer Deed', 'is_active' => false],
        ];

        foreach ($services as $service) {
            LegalServiceType::updateOrCreate(
                ['slug' => $service['slug']],
                $service
            );
        }
    }
}

This pattern ensures that running db:seed --class=LegalServiceTypeSeeder on production after adding a new service type will not fail or create duplicates. It also allows you to update existing records (e.g., renaming a service or toggling active status) through code rather than manual database edits.

Organizing seeders for large applications

As applications grow, a monolithic DatabaseSeeder becomes unmanageable. Group seeders by domain and call them explicitly. For legal-tech portals I have built, the structure typically separates system-critical data from demo content:

  • System/RolesAndPermissionsSeeder — RBAC foundation, runs first
  • System/SettingsSeeder — application configuration defaults
  • Legal/ServiceTypesSeeder — domain-specific reference data
  • Legal/DocumentTemplatesSeeder — form templates and clauses
  • Demo/SampleClientsSeeder — only called in local/staging, never production

Use the --class flag to run specific seeders in production rather than executing everything. This granularity prevents accidentally inserting demo data into live environments and reduces deployment risk.

Need Database Records?Required for appTesting / Dev onlyUse SEEDERUse FACTORYCharacteristics:• Idempotent (updateOrCreate)• Deterministic values• Version controlled• Safe for production• Reference / config dataCharacteristics:• Random / fake data• Bulk generation• Test assertions• Never in production• Performance testing
Decision framework for seeders vs factories in database migrations and seeding best practices in Laravel

How do you handle large table migrations without downtime?

Adding a column to a table with ten rows is instant. Adding a column to a table with ten million rows can lock the table for minutes or hours depending on the database engine and operation. For eCommerce platforms processing orders or legal portals with extensive case histories, you must plan for this reality.

Understanding MySQL and PostgreSQL locking behavior

MySQL 8.0+ and MariaDB 10.11+ support online DDL for many operations, but not all. Adding a nullable column is typically instant. Adding a NOT NULL column with a default value may require a full table rebuild. Renaming columns or changing types almost always locks. PostgreSQL generally handles concurrent DDL better but still has edge cases.

OperationMySQL 8.0 BehaviorPostgreSQL 16 BehaviorSafe Strategy
Add nullable columnOnline, instantOnline, instantDirect migration
Add NOT NULL + defaultMay rebuild tableOnline since PG 11Two-step for MySQL
Add indexOnline (ALGORITHM=INPLACE)CONCURRENTLY optionUse CONCURRENTLY in PG
Rename columnFull table lockBrief lockExpand-contract pattern
Change column typeFull table rebuildMay rewriteExpand-contract pattern

The expand-contract pattern for risky changes

When you must rename a column or change its type on a large production table, use the expand-contract pattern across three deployments:

  1. Expand: Add the new column alongside the old one. Update application code to write to both columns and read from the new column with fallback to the old.
  2. Migrate: Run a background job or batched script to copy existing data from old to new column. Verify completeness.
  3. Contract: Remove reads from the old column in application code. Drop the old column in a subsequent migration.

This approach requires coordination between code deploys and migrations but eliminates extended table locks. I have used this pattern on order tables exceeding five million rows where even a thirty-second lock would cause payment gateway timeouts.

Batching data migrations within seeders or commands

When backfilling data as part of a migration or seeder, never process unbounded result sets. Chunk your queries to prevent memory exhaustion and allow progress monitoring:

<?php

// Inside a migration or dedicated Artisan command
App\Models\Order::whereNull('calculated_tax')
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            $order->update([
                'calculated_tax' => $order->subtotal * 0.13 // Nepal VAT rate
            ]);
        }
    });

Using chunkById instead of chunk prevents skipped records when updating the same column used for ordering. This distinction matters enormously in production data fixes and is a frequent source of silent data corruption.

Expand-Contract Migration TimelinePhase 1: ExpandAdd new columnDual-write enabledDeploy code + migrationPhase 2: MigrateBackfill existing rowsBatched background jobVerify completenessPhase 3: ContractRemove dual-writeDrop old columnFinal cleanup deployZero downtimeApp stays functionalHours to daysDepends on row countBrief lock onlySafe DROP COLUMN
Three-phase expand-contract pattern for zero-downtime schema changes in production Laravel applications

What deployment safeguards protect production databases during migration?

Your migration strategy is only as good as your deployment pipeline. Running php artisan migrate manually on a production server via SSH is acceptable for solo hobby projects but unacceptable for any business application. Automated, tested, and reversible deployments are part of professional Laravel development regardless of team size.

Pre-deployment checklist

Before any migration reaches production, verify these items:

  • Backup verified: Not just taken, but tested for restore capability. A backup you cannot restore is not a backup.
  • Migration tested on staging: With production-scale data volume, not an empty database. Timing matters.
  • Rollback tested: Run migrate:rollback on staging to confirm the down() method actually works.
  • Maintenance mode planned: If the migration requires exclusive locks, schedule maintenance windows and communicate them.
  • Application compatibility confirmed: New code deployed before or atomically with the migration, never after a gap.

Integrating migrations with zero-downtime deployment

When using Deployer 7 or similar tools with symlinked releases, migrations should run once per deployment against the new release directory before the symlink swap. This ensures the new code and new schema activate simultaneously. Configure your deployment script to run migrations after composer install but before deploy:symlink:

# In deploy.php or GitLab CI pipeline stage
task('deploy:migrate', function () {
    run('cd {{release_path}} && php artisan migrate --force --no-interaction');
});

// Hook into deployment flow
after('deploy:vendors', 'deploy:migrate');
before('deploy:symlink', 'deploy:migrate');

The --force flag is required in production environments because Laravel prompts for confirmation by default. The --no-interaction flag prevents hanging in automated pipelines. Always combine these flags in CI/CD contexts.

Handling migration failures gracefully

Migrations can fail mid-execution due to timeouts, constraint violations, or resource limits. Your pipeline must detect failure and either roll back automatically or halt deployment entirely. Never leave a production database in a partially migrated state with new code expecting the completed schema. Configure your deployment tool to run migrate:rollback on failure, or better yet, wrap migrations in transactions where your database engine supports transactional DDL (PostgreSQL does; MySQL does not for most operations).

For MySQL environments where transactional DDL is unavailable, consider wrapping risky migrations in feature flags at the application level. Deploy the migration and new code together, but gate the new functionality behind a flag that you enable only after confirming migration success. This decouples schema deployment from feature activation and provides a safer rollback path.

How do database migrations and seeding best practices in Laravel improve long-term maintainability?

Adhering to database migrations and seeding best practices in Laravel pays compounding dividends over the lifetime of a project. Teams that treat migrations as disposable setup scripts accumulate technical debt that manifests as deployment fear, inconsistent environments, and onboarding friction for new developers. Teams that treat migrations as a first-class artifact build systems that evolve safely over years.

The practices outlined here—immutable migrations, idempotent seeders, explicit naming, expand-contract patterns for large tables, and automated deployment integration—are not theoretical ideals. They are extracted from maintaining production Laravel applications serving real businesses, including legal portals handling sensitive client data and eCommerce systems processing daily transactions. The cost of skipping these practices is measured in emergency fixes, lost data, and eroded trust.

Start by auditing your current migration history. Identify edited migrations, non-idempotent seeders, and unnamed indexes. Create corrective migrations rather than rewriting history. Set up CI validation that runs migrate:fresh --seed on every pull request. Integrate migration execution into your deployment pipeline if it is not already there. These incremental improvements compound into a development workflow where schema changes are routine rather than stressful.

If your team needs guidance implementing these patterns or auditing an existing Laravel application’s database layer, reach out to discuss your project. Whether you are building a new platform or stabilizing a legacy system, getting your migration and seeding foundations right is the highest-leverage investment you can make in long-term maintainability.

Frequently Asked Questions

Migrations define schema structure like tables and columns. Seeders populate those tables with test or default data.

Never edit executed migrations in production environments. Create a new migration to alter the schema instead.

Use the renameColumn method in a new migration. Always backup first, as some database drivers require table recreation which risks data loss during the operation. Test thoroughly on staging with production-scale data before deploying to verify integrity and application compatibility.

Factories generate realistic random data for testing relationships and edge cases efficiently. Manual arrays suit static reference data like countries or roles where exact values matter. In my experience building legal-tech portals, mixing both approaches works best: factories for user-generated content simulation and hardcoded arrays for system constants that must remain consistent across all environments.

Yes, but only for essential reference data. Guard production seeders with environment checks or dedicated classes. Avoid seeding test users or dummy orders live. On client projects, I separate dev-only seeders from production-critical ones using distinct classes called explicitly via artisan db:seed --class=ProductionDefaultsSeeder rather than running DatabaseSeeder blindly, preventing accidental test data pollution.

Define referenced tables before dependent ones. Use unsignedBigInteger matching the parent primary key type. Add indexes for performance. If reordering fails, temporarily disable checks with Schema::disableForeignKeyConstraints but re-enable immediately after. In practice, proper migration ordering prevents most constraint errors without needing dangerous workarounds that can leave databases in inconsistent states during deployment failures.

Run php artisan migrate:rollback to execute the down method. If the migration lacks a proper down implementation, manually fix the schema then mark it rolled back in the migrations table. Never delete migration files. I have encountered this during deployments where partial execution left orphaned tables; always write reversible migrations even if rollbacks seem unlikely in your workflow.

Chunk inserts using Model::insert with batches of 500-1000 records. Disable mass assignment protection temporarily. Wrap chunks in transactions for atomicity. For millions of rows, consider raw DB::table inserts bypassing Eloquent overhead. On eCommerce projects with extensive product catalogs, chunked seeding reduced import time from hours to minutes while keeping memory usage under 256MB consistently.

Commit production seeders containing reference data, permissions, or configuration defaults. Exclude development-only seeders generating fake test data via .gitignore or separate directories. Version-controlled seeders ensure consistent baseline data across staging and production. For legal service platforms I maintain, role definitions and service categories live in committed seeders so fresh deployments automatically configure required access controls without manual intervention.

Create a new migration adding the index. For tables exceeding millions of rows, use pt-online-schema-change or gh-ost externally. Standard ALTER TABLE locks the entire table during index creation. Schedule during low-traffic windows. Monitor query performance before and after. In production systems I manage, off-peak indexing with proper monitoring prevents customer-facing slowdowns that would otherwise occur during business hours.

Use descriptive snake_case names reflecting the action: create_users_table, add_email_to_orders, remove_legacy_status_column. Laravel prefixes timestamps automatically. Avoid generic names like update_table or fix_data. Clear names make git history readable and help teammates understand schema evolution without opening files. This discipline pays dividends during debugging sessions months later when tracing why a column exists.

Never modify enum values directly in executed migrations. Create a new migration altering the column definition. Map old values to new ones if renaming. Consider migrating to string columns with application-level validation for flexibility. Enums cause painful migrations as requirements evolve. On projects where status workflows changed frequently, switching to validated strings eliminated future migration headaches while maintaining data integrity through form requests and policies.

Yes, wrap related inserts in DB::transaction blocks for atomicity. This ensures partial failures roll back completely rather than leaving inconsistent state. Be cautious with auto-increment gaps and long-running transactions locking resources. For complex seeding involving multiple related models, transactions guarantee referential integrity. I use this pattern when seeding hierarchical category structures where orphaned child records would break navigation if parent insertion failed midway.

Run migrate:fresh on a staging copy of production data regularly. Verify application functionality after migration. Check query performance with EXPLAIN. Review migration output for warnings. Maintain a staging environment mirroring production schema and data volume. Before major releases on client systems, I validate migrations against anonymized production dumps to catch issues that only surface at scale, preventing deployment-day surprises.

Missing down methods, incorrect column types for foreign keys, forgetting indexes on constrained columns, assuming column existence without checking, and editing executed migrations. Also watch for MySQL strict mode rejecting invalid defaults. Always test both up and down paths locally. In fifteen years of Laravel work, these preventable errors still cause most deployment failures; disciplined migration authoring and thorough local testing eliminate nearly all production incidents.

Share this article

Quick Contact Options
Choose how you want to connect me: