Skip to content
@aploon

Database Migrations: How to Keep Laravel Projects Maintainable

  • #laravel
  • #database
  • #backend
  • #bestpractices

A database schema rarely stays the same throughout an application's lifetime.

New features introduce new tables and columns. Business requirements change. Relationships become more complex, and existing fields sometimes need to be renamed or replaced.

In Laravel, database migrations provide a version-controlled way to describe these changes. Instead of relying on manual SQL scripts or undocumented database modifications, developers can keep schema changes alongside the application code.

But having migrations is not enough. Poorly designed migrations can still lead to data loss, deployment failures, and inconsistent environments.

This article explores practical principles for writing migrations that remain understandable and manageable as a project evolves.

1. Treat migrations as version-controlled schema changes

A migration describes a change to the database structure. Laravel stores executed migration names in the migrations table, allowing it to determine which migrations have already run.

Create a migration with Artisan:

php artisan make:migration create_projects_table

A typical migration for a projects table might look like this:

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('projects', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }
 
    public function down(): void
    {
        Schema::dropIfExists('projects');
    }
};

The up() method defines the change to apply, while down() describes how to reverse it.

Keeping migrations in version control makes schema changes reviewable and reproducible across environments.

2. Model relationships with foreign keys

When one table references another, define the relationship explicitly in the schema.

For example, if each project belongs to a user:

Schema::create('projects', function (Blueprint $table) {
    $table->id();
 
    $table->foreignId('user_id')
        ->constrained()
        ->cascadeOnDelete();
 
    $table->string('name');
    $table->timestamps();
});

With Laravel's conventions, constrained() references the users table through the user_id column.

The cascadeOnDelete() rule means that deleting a user also deletes the user's related projects at the database level.

Important: cascading deletion is a business decision, not a default rule to apply everywhere. For financial records, audit trails, or historical data, restricting deletion or using soft deletes may be more appropriate.

Foreign keys protect referential integrity even when data changes occur outside the application's normal Eloquent workflows.

3. Use constraints to enforce business rules

Validation in Laravel protects application inputs, but database constraints provide another layer of protection.

Suppose each user must have a unique email address. You can enforce this at the database level:

Schema::table('users', function (Blueprint $table) {
    $table->unique('email');
});

A uniqueness constraint prevents duplicate values even if two concurrent requests pass application-level validation before either record is inserted.

For a many-to-many relationship, a composite unique index can prevent duplicate associations:

$table->unique(['user_id', 'challenge_id']);

This example belongs in the migration that creates the relevant pivot table.

The general principle is to enforce important invariants as close to the data as practical. Application validation improves error messages and user experience; database constraints protect the integrity of the stored data.

4. Evolve existing tables with focused migrations

Once a table exists in a shared or production database, avoid casually rewriting historical migrations that have already been applied.

Instead, create a new migration for each meaningful schema change:

php artisan make:migration add_status_to_projects_table

For example:

Schema::table('projects', function (Blueprint $table) {
    $table->string('status')
        ->default('draft');
});

A focused migration makes it easier to understand when a change was introduced and how it affects existing data.

However, changing a schema is not always as simple as adding a column. Renaming a field, changing its type, or splitting one column into multiple fields may require a data migration and coordination with application code.

5. Deploy schema changes safely

A migration that works locally is not automatically safe to run in production.

Consider renaming full_name to display_name. If the deployed application still expects full_name, changing the column immediately may cause errors while old application instances are running.

For changes that must remain compatible during a rolling deployment, a staged approach is often safer:

  1. Add the new column without removing the old one.
  2. Update the application to write compatible data to both columns when necessary.
  3. Backfill existing records.
  4. Deploy code that reads from the new column.
  5. Remove the old column in a later deployment after verifying that it is no longer needed.

This pattern is commonly known as an expand-and-contract migration.

Not every schema change requires such a process, but it is worth considering for high-traffic applications, large tables, and deployments where old and new application versions may overlap.

6. Understand the difference between migrate, rollback, and fresh

Laravel provides several commands for managing migrations:

CommandPurposeImportant consideration
php artisan migrateApplies pending migrationsReview the pending changes before production deployment
php artisan migrate:rollbackReverses the last migration batchMay remove schema objects or data
php artisan migrate:statusShows migration execution statusUseful for diagnosing environment differences
php artisan migrate:freshDrops all tables and reruns migrationsDestructive: do not use on a database containing data you need

A rollback is not necessarily a complete recovery strategy. If a migration removes data, its down() method may not be able to reconstruct that data.

For production systems, maintain verified backups and a recovery plan. Test risky migrations against representative data before deployment.

7. Make migrations easier to review

A maintainable migration should communicate its intention clearly.

A few practical guidelines help:

  • Use descriptive migration names.
  • Keep unrelated schema changes in separate migrations.
  • Define foreign keys, indexes, and uniqueness constraints explicitly.
  • Consider existing data before adding non-nullable columns.
  • Review database-specific behavior before relying on advanced schema operations.
  • Test both fresh database setup and upgrades from a previous schema.
  • Avoid editing migrations that have already been shared and applied in persistent environments.

Also remember that database engines differ. An operation that is straightforward on one engine may lock a table, require a different strategy, or behave differently on another.

Laravel migrations are a core part of application maintenance because they make database evolution explicit, reviewable, and repeatable.

The best results come from combining focused migrations, meaningful constraints, a clear understanding of existing data, and deployment procedures that account for compatibility and recovery.

Treating schema changes as part of the software delivery process helps prevent database drift and reduces the risk of breaking an application as its requirements evolve.