
August 17, 2026
13 min read
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 creationadd_verified_at_to_users_table— clear column 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 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.
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 defaultsLegal/ServiceTypesSeeder— domain-specific reference dataLegal/DocumentTemplatesSeeder— form templates and clausesDemo/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.
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.
| Operation | MySQL 8.4 Behavior | PostgreSQL 18 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 table | Expand-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:
- 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.
- Migrate: Run a background job or batched script to copy existing data. Verify completeness before proceeding.
- 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.
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:rollbackon staging. Confirm thedown()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.
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()orupsert(); 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-interactionin CI/CD aftercomposer installand 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
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.

