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: September 2026

Database migrations and seeding best practices in Laravel decide whether a deploy finishes in three minutes or triggers two hours of downtime. Schema changes touch every environment your team runs. On production Laravel applications, especially legal-tech portals and eCommerce systems, data integrity is not negotiable. This guide covers the workflow I use on client projects: safe migrations, idempotent seeders, and deployment safeguards that survive real traffic.

For broader application design context, see modern Laravel architecture best practices. Migrations are not one-time setup scripts. They are the shared contract between development, staging, and production. Seeders supply the reference data your app needs to boot. Factories supply test data. Keeping those roles separate prevents the deployment surprises that database migrations in team environments often surface.

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

Safe migrations start with one rule: never modify a migration that has reached main or run on a shared environment. If you picked the wrong column type or forgot an index, create a new migration to fix it. Editing history breaks the chronological contract your team relies on when replaying schema changes.

Naming conventions that prevent confusion

Laravel timestamp prefixes handle ordering. Your descriptive name handles debugging at 2 AM. Use verb-noun syntax that describes the transformation:

  • create_users_table — initial table creation
  • add_verified_at_to_users_table — clear column 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 readable without opening each file. When you scan fifty migrations during an incident, descriptive names save real time.

The two-method vs single-method decision

Laravel supports explicit up()/down() pairs and newer patterns like Schema::build(). For production apps on Laravel 12 or 13, stick with explicit up() and down() methods. Rollback capability matters when a deploy fails mid-migration or a regression appears after release. The build() shortcut works for greenfield prototypes. It removes your safety net once 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');
        });
    }
};

Name indexes explicitly. Laravel auto-generated index names depend on column order. They can differ between environments if columns were added in different sequences. Named indexes make rollbacks predictable. The official Laravel 13.x migrations documentation covers schema builder methods you will use daily.

Laravel Migration WorkflowWriteVerb-noun nameTestmigrate:freshCI CheckLint + migrateDeployZero downtimeHard RulesNever edit appliedName every indexBackup before prodDatabase migrations and seeding best practices in Laravel start hereRun migrate:status before every production deploy
Safe workflow for database migrations and seeding best practices in Laravel across all environments

When should you use seeders versus factories in Laravel?

A common mistake is conflating seeders with test data generation. Seeders populate baseline data the application needs: admin users, permission roles, country lists, tax rates, or legal document categories. Factories generate arbitrary records for development and automated tests. Mixing these concerns produces bloated seeders that take minutes to run. For a deeper comparison, read Laravel seeders vs factories: when to use each.

Idempotent seeders are mandatory

Every seeder must be safe to run multiple times without error or duplicate rows. Avoid raw insert() calls unless wrapped in existence checks. Prefer updateOrCreate() or upsert() for reference data that may 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
            );
        }
    }
}

Running db:seed --class=LegalServiceTypeSeeder on production after adding a service type will not fail or duplicate rows. You can rename services or toggle active status through code instead of manual SQL edits. The Laravel seeding documentation describes calling individual seeders from DatabaseSeeder.

Organizing seeders for large applications

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

  • System/RolesAndPermissionsSeeder — RBAC foundation, runs first (see Laravel Spatie Permission role management)
  • System/SettingsSeeder — application configuration defaults
  • Legal/ServiceTypesSeeder — domain-specific reference data
  • Legal/DocumentTemplatesSeeder — form templates and clauses
  • Demo/SampleClientsSeeder — local and staging only, never production

Use the --class flag to run specific seeders in production. This prevents demo data from reaching live environments. It also cuts deployment risk. Pair seeders with Laravel model factories for realistic test data in your test suite, not in production pipelines.

Seeder or Factory?Need database records?App requires itTests / dev onlyUse SEEDERUse FACTORYSeeder traitsIdempotent upsertsDeterministic valuesVersion controlledSafe on productionFactory traitsRandom fake dataBulk generationTest assertionsNever in production
Decision framework for seeders vs factories in Laravel database management

How do you handle large table migrations without downtime?

Adding a column to a ten-row table is instant. Adding one to a ten-million-row table can lock the table for minutes. eCommerce order tables and legal case histories hit this wall quickly. Plan for it from day one. Review Laravel migrations best practices for zero downtime and zero-downtime Laravel database migrations for deployment-specific patterns.

Understanding MySQL and PostgreSQL locking behavior

MySQL 8.4 LTS and MariaDB 12.3 support online DDL for many operations, but not all. Adding a nullable column is typically instant. Adding a NOT NULL column with a default may rebuild the table. Renaming columns or changing types almost always locks. PostgreSQL 18 handles concurrent DDL better but still has edge cases. See the MySQL online DDL reference before scheduling risky changes.

OperationMySQL 8.4 BehaviorPostgreSQL 18 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 rewrite tableExpand-contract pattern

If you run PostgreSQL, read PostgreSQL for Laravel developers for index and constraint specifics. For MySQL-heavy apps, MySQL query optimization for slow queries helps you spot tables that cannot tolerate locks.

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 both columns. Read from the new column with fallback to the old.
  2. Migrate: Run a background job or batched script to copy existing data. Verify completeness before proceeding.
  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. It eliminates extended table locks. I have used it on order tables exceeding five million rows where even a thirty-second lock caused payment gateway timeouts.

Batching data migrations within seeders or commands

When backfilling data, never process unbounded result sets. Chunk queries to prevent memory exhaustion and allow progress monitoring:

<?php

App\Models\Order::whereNull('calculated_tax')
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            $order->update([
                'calculated_tax' => $order->subtotal * 0.13
            ]);
        }
    });

Use chunkById instead of chunk. Updating the same column used for ordering causes skipped records with plain chunk. That leads to silent data corruption in production fixes. Avoid heavy data backfills inside migration up() methods when possible. Prefer dedicated Artisan commands you can monitor and retry.

Expand-Contract PatternPhase 1: ExpandAdd new columnEnable dual-writePhase 2: MigrateBackfill in batchesVerify row countsPhase 3: ContractStop dual-writeDrop old columnTimeline NotesDeploy 1App stays onlineHours to daysDepends on row countDeploy 3Brief lock on DROPThree separate deploys — never combine expand and contract in one release
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 over SSH works for solo hobby projects. Business applications need automated, tested, reversible deploys. That is part of professional Laravel development regardless of team size. Integrate migrations into CI/CD as described in database migrations in CI/CD pipelines.

Pre-deployment checklist

Before any migration reaches production, verify these items:

  • Backup verified: Not just taken, but tested for restore. A backup you cannot restore is not a backup.
  • Staging tested: Run against production-scale data volume, not an empty database. Timing matters.
  • Rollback tested: Run migrate:rollback on staging. Confirm the down() method works.
  • Maintenance planned: If the migration needs exclusive locks, schedule a window and notify stakeholders.
  • Code compatibility confirmed: Deploy new code before or atomically with the migration. Never leave a gap.

Integrating migrations with zero-downtime deployment

With Deployer 7 and symlinked releases, run migrations once per deployment against the new release directory before the symlink swap. New code and new schema activate together. Hook migrations after composer install but before deploy:symlink:

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

after('deploy:vendors', 'deploy:migrate');
before('deploy:symlink', 'deploy:migrate');

The --force flag is required in production. Laravel prompts for confirmation by default. Add --no-interaction so automated pipelines do not hang. See zero-downtime deployment for Laravel with Deployer and deploy a Laravel app with GitLab CI/CD to a VPS for full pipeline examples.

Handling migration failures gracefully

Migrations fail mid-execution due to timeouts, constraint violations, or resource limits. Your pipeline must detect failure and halt deployment. Never leave production partially migrated with new code expecting a completed schema. PostgreSQL supports transactional DDL for many operations. MySQL does not for most ALTER statements. Understand which engine you run before assuming rollback safety. Read Laravel database transactions and deadlocks for application-level consistency patterns.

For MySQL environments, gate new functionality behind feature flags. Deploy migration and code together. Enable the feature only after confirming migration success. This decouples schema deployment from feature activation.

Deploy Pipeline + MigrationsGit PushPR mergedCI Testsmigrate:freshReleasecomposer installMigrate--force flagSymlinkGo liveOn failure: halt deployDo not swap symlinkAlert team immediatelyOn success: reload PHP-FPMInvalidate opcacheRun smoke tests
CI/CD pipeline running Laravel migrations before the production symlink swap

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

Teams that treat migrations as disposable setup scripts accumulate deployment fear and inconsistent environments. Teams that treat migrations as first-class artifacts build systems that evolve safely over years. The practices here—immutable migrations, idempotent seeders, explicit naming, expand-contract patterns, automated deployment—come from maintaining production Laravel apps serving real businesses.

On projects like Adventure Third Pole Trek, a Laravel booking platform, schema changes ship weekly without drama because migration discipline was enforced from launch. On Court Marriage In Nepal, reference data for legal service types lives in version-controlled seeders, not manual phpMyAdmin edits. That audit trail matters when content changes need to match code releases.

Start by auditing your migration history. Find edited migrations, non-idempotent seeders, and unnamed indexes. Create corrective migrations instead of rewriting history. Set up CI validation that runs migrate:fresh --seed on every pull request. Review database schema design common mistakes to prevent problems migrations cannot easily fix later. When seeding JSON configuration, validate payloads with a JSON formatter tool before committing fixture files.

Skipping these practices costs emergency fixes, lost data, and eroded client trust. Getting migration and seeding foundations right is the highest-leverage investment in long-term maintainability. If you need help auditing an existing Laravel database layer, custom software development services can cover schema review and pipeline setup alongside application work.

Key Takeaways

  • Never edit migrations that have run on shared or production environments—always create a new migration to fix mistakes.
  • Write idempotent seeders with updateOrCreate() or upsert(); reserve factories for tests and local development only.
  • Name indexes explicitly in migration files so rollbacks work reliably across every environment.
  • Use the expand-contract pattern for column renames and type changes on large tables.
  • Run php artisan migrate --force --no-interaction in CI/CD after composer install and before the symlink swap.
  • Test migrations on staging with production-scale data volumes and verify backups restore successfully.

People Also Ask

Can you edit a Laravel migration after it has been deployed?

No. Once a migration has run on staging or production, treat it as immutable. Editing the file changes checksums and breaks team environments that already applied the original version. Create a new migration to alter the schema instead. This is the single most important rule in database migrations and seeding best practices in Laravel.

Should you run seeders in production?

Run production-safe seeders that insert reference or configuration data using idempotent methods. Never run demo seeders on production. Use the --class flag to target specific seeder classes. Keep demo and test seeders out of your default DatabaseSeeder call chain for production deploy scripts.

What is the difference between migrate:fresh and migrate in Laravel?

migrate runs pending migrations forward without touching existing data. migrate:fresh drops all tables and re-runs every migration from scratch—it destroys all data. Use migrate:fresh --seed only in local development and CI test pipelines. Never run it against staging or production databases.

How do you roll back a failed Laravel migration?

Run php artisan migrate:rollback to execute the down() method of the last batch. This only works if you wrote a correct down() method and your database engine supports reversing the operation. On MySQL, many ALTER operations cannot roll back inside a transaction. Test rollbacks on staging before relying on them in production.

Next Steps for Your Laravel Database Layer

Schema management should feel routine, not terrifying. Audit your current migrations today. Fix non-idempotent seeders. Wire migration execution into your deploy pipeline if it is not already there. These incremental changes compound into a workflow where database migrations and seeding best practices in Laravel protect every release.

Need help stabilizing a legacy Laravel app or setting up migration pipelines for a new platform? Contact us to discuss your project. You can also reach out directly with questions about your current schema. Solid migration discipline is the foundation every maintainable Laravel application builds on.

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

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: