
August 17, 2026
11 min read
Table of Contents
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 additionchange_price_column_to_decimal_in_products_table— explicit type changedrop_legacy_status_column_from_orders_table— safe removal with contextadd_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.
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 firstSystem/SettingsSeeder— application configuration defaultsLegal/ServiceTypesSeeder— domain-specific reference dataLegal/DocumentTemplatesSeeder— form templates and clausesDemo/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.
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.
| Operation | MySQL 8.0 Behavior | PostgreSQL 16 Behavior | Safe Strategy |
|---|---|---|---|
| Add nullable column | Online, instant | Online, instant | Direct migration |
| Add NOT NULL + default | May rebuild table | Online since PG 11 | Two-step for MySQL |
| Add index | Online (ALGORITHM=INPLACE) | CONCURRENTLY option | Use CONCURRENTLY in PG |
| Rename column | Full table lock | Brief lock | Expand-contract pattern |
| Change column type | Full table rebuild | May rewrite | Expand-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:
- 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.
- Migrate: Run a background job or batched script to copy existing data from old to new column. Verify completeness.
- 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.
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:rollbackon staging to confirm thedown()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.

