Contents
- What is a Laravel migration and how does it work?
- Why do migrations matter for production deployments?
- How do you safely add columns to tables that already have data?
- What other migration patterns come up on real projects?
- How do migrations fit into a broader deployment workflow?
- What does the migration history tell you two years later?
Key Takeaways
- Laravel migrations version-control your database schema the same way Git versions your code — every change is tracked, reversible, and reproducible across environments
- Each migration has an
up()method to apply a change and adown()method to reverse it — rollback is a single command, not a panicked support call- Adding nullable columns to existing tables is a common pattern that prevents migration failures on tables with existing rows
- The audit trail migrations create is genuinely valuable — two years later you can trace exactly when a feature was added and why the schema is shaped the way it is
Every database-driven project eventually has the same horror story. Someone ran a SQL query directly in production — maybe to fix a bug fast, maybe to hit a deadline — and six months later nobody knows if staging matches prod. The columns are different. The indexes are missing. Nobody's sure what's real anymore.
Laravel migrations fix this. They give your database schema the same version control as your code, so every change is tracked, reversible, and deployable with a single command.
What is a Laravel migration and how does it work?
A migration is a PHP file that describes one discrete database change. It has two methods: up() applies the change, and down() reverses it. Laravel tracks which migrations have run in a migrations table, so it knows exactly where each environment stands at any point in time.
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained();
$table->decimal('amount', 10, 2);
$table->enum('status', ['draft', 'sent', 'paid'])->default('draft');
$table->date('due_date');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
The up() method creates an invoices table with a client relationship, an amount column, a status enum, and a due date. The down() method removes it cleanly. If you deploy this migration and something goes wrong, php artisan migrate:rollback undoes it instantly — no manual SQL, no guesswork.
Scaffold a new migration with php artisan make:migration create_invoices_table and Laravel generates the file stub with the correct naming convention and timestamp prefix automatically.
Why do migrations matter for production deployments?
Deployments that touch the database are the highest-risk part of a release. Without migrations, the process often looks like: deploy the code, then manually run some SQL, then hope nothing breaks, then spend thirty minutes debugging why the new feature isn't working on staging when it worked fine locally.
With migrations, the process is php artisan migrate. That command runs every pending migration in the correct order, on whatever environment you're on, and the database ends up in exactly the state the code expects. New developer joins the project and clones the repo? They run php artisan migrate and their local database matches production exactly. No setup document six months out of date. No mystery columns nobody can explain.
Rollbacks are equally important. If a release goes sideways, php artisan migrate:rollback reverses the most recent batch of migrations. The database goes back to the prior state at the same time the code does. That's a real rollback — not a recovery operation.
How do you safely add columns to tables that already have data?
Adding a column to an existing production table is one of the most common places migrations go wrong. A new required column on a table with ten thousand rows will fail — the database can't populate that column retroactively, so the migration throws an error and everything stops.
The fix is nullable(). A nullable column allows existing rows to have a null value, so the migration succeeds, and new records can fill in the value going forward:
public function up(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->string('tax_id')->nullable()->after('email');
});
}
public function down(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->dropColumn('tax_id');
});
}
The after('email') call places the column in a predictable position rather than appending it to the end of the table — a small thing that makes the schema easier to read over time.
If you need the column to be required for new records but still need the migration to run safely on existing data, add a default value with ->default('') or handle the backfill as a separate data migration after the schema change lands.
What other migration patterns come up on real projects?
A few patterns that appear regularly in production codebases:
Renaming columns. $table->renameColumn('old_name', 'new_name') handles this cleanly. Before Laravel 9 this required the doctrine/dbal package — from Laravel 9 onward it is built in.
Adding indexes. If a column appears frequently in WHERE clauses or as a foreign key, add an index: $table->index('client_id') or $table->unique('email'). Missing indexes are a common source of slow queries on tables that were small during development but grew in production.
Modifying column types. $table->string('description', 500)->change() increases the length of an existing column. The change() call is what tells Laravel you are modifying rather than creating.
Dropping columns safely. Use $table->dropColumn('legacy_field') inside a migration rather than removing it from the model first. The order matters — code that references a column should be removed or updated before the column itself disappears from the schema.
How do migrations fit into a broader deployment workflow?
Migrations pair naturally with Laravel's other deployment tools. Laravel Horizon can give you visibility into queue jobs that run after a deploy. Laravel Telescope logs queries so you can see exactly what SQL a migration generates in non-production environments before it touches production data.
For applications with scheduled tasks, the Laravel scheduler can run data migrations or cleanup jobs on a cadence separately from schema migrations — useful when you need to backfill data in batches without locking tables during a deploy.
Running php artisan migrate --force in production (required because Laravel asks for confirmation interactively) should be part of your deployment script, not a manual step. Automating it removes the risk of a developer forgetting the step or running migrations out of order.
What does the migration history tell you two years later?
This is the part of migrations most developers undervalue until they've lived it. The file history of your migrations is effectively a changelog for your database. Every table creation, column addition, index change, and relationship is documented in the order it happened.
When a client comes back two years later wanting to add a feature to an application built by a different developer, that migration history is where we start. We read the migrations, understand the intent behind the structure, and build forward cleanly — rather than reverse-engineering why the schema is shaped the way it is.
The alternative — direct SQL edits, shared .sql dump files, environment-specific manual tweaks — is not just messy. It is a slow accumulation of technical debt that eventually makes every schema change expensive and every deployment stressful. Migrations make changes cheap and deployments boring. That is the goal.
If you are working on a Laravel project and the database has gotten out of hand, get in touch. A short audit usually surfaces the fix faster than you would expect.