Laravel migrations: add a column, roll back, delete

Introduction Adding columns or tables to your database by hand can be a daunting job, and it usually ends in inconsistencies between your environments. Laravel migrations let you version your database so that everyone on your team can work from a

Laravel migrations: add a column, roll back, delete
Quick answer

You delete a migration by deleting its file, but only if it has never run. Check with php artisan migrate:status: if it shows Ran, first run php artisan migrate:rollback --step=1, then delete the file. To add a column to an existing table, never edit the old migration: create a new one with --table.

Migrations version your database schema the way Git versions your code: every change is a file, replayable on any environment. Three operations cover most of the day-to-day work: adding a column to a table that already exists, rolling back a migration, and deleting a file that should never have been created.

Part of the Web development reading path. Every command and every output below was run on Laravel 13.30.1 with PHP 8.4.25.

Creating a migration

The command follows a naming convention that decides what ends up in the generated file:

bash
php artisan make:migration create_tasks_table

The name starts with create, followed by the plural table name, followed by table. Laravel recognises this pattern and pre-fills Schema::create() with id() and timestamps(), the title column is added here for the example, and the generated comments have been removed:

database/migrations/2026_09_02_100000_create_tasks_table.php
<?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::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};
If your migrations do not look like this

Since Laravel 8, migrations are anonymous classes returned by return new class extends Migration. Older named migrations, of the class CreateTasksTable extends Migration kind, still work but are no longer generated. In the same way, $table->bigIncrements('id') has given way to $table->id(), and the up() and down() methods now declare a void return type.

If the table needs creation and update dates, $table->timestamps() adds them: leaving them out is what causes the “Unknown column ‘updated_at’” error.

Running the migration creates the table:

bash
php artisan migrate

Adding a column to an existing table

Once the schema is in place, the everyday Eloquent queries do the rest. Never edit a migration that has already run: your colleagues and your servers have played it, and they will not play it again. Create a new migration instead, naming the table with --table:

bash
php artisan make:migration add_notes_to_tasks_table --table=tasks

The generated file holds an empty Schema::table() in both directions. Fill them in: the column in up(), dropping it in down().

database/migrations/2026_09_02_194036_add_notes_to_tasks_table.php
public function up(): void
{
    Schema::table('tasks', function (Blueprint $table) {
        $table->text('notes')->nullable()->after('title');
    });
}

public function down(): void
{
    Schema::table('tasks', function (Blueprint $table) {
        $table->dropColumn('notes');
    });
}
bash
php artisan migrate
code
  2026_09_02_194036_add_notes_to_tasks_table .................... 47.43ms DONE

Two precautions are worth repeating here.

Make the column nullable, or give it a default value. On a table that already holds rows, a NOT NULL column with no default makes the migration fail: the database has nothing to put in the existing rows.

Always write the down(). That is what makes the migration reversible. A migration with no down() turns the smallest step backwards into a manual job.

after() does not work everywhere

Positioning a column with after() is specific to MySQL and MariaDB. On SQLite the instruction is accepted without error but ignored: the column is added at the end of the table. Checked on SQLite 3.46.1, where notes ends up after updated_at despite the after('title'). Column order has no functional effect.

Rolling back a migration that has already run

Start by looking at where you stand. migrate:status lists every migration with its batch and its state:

bash
php artisan migrate:status
code
  2026_09_02_100000_create_tasks_table ............................... [1] Ran
  2026_09_02_194036_add_notes_to_tasks_table ......................... Pending

A Pending migration has never been played: its file can be deleted straight away. A Ran migration has to be rolled back first.

bash
# rolls back the last migration only
php artisan migrate:rollback --step=1

# rolls back the whole last batch
php artisan migrate:rollback

The rollback runs the down() method. In the example above, the notes column does disappear from the table and the migration goes back to Pending. That is the moment, and not before, when you can delete the file.

Two neighbouring commands exist, to be kept for development:

bash
# rolls back everything, then replays everything
php artisan migrate:refresh

# drops every table, then replays everything
php artisan migrate:fresh
migrate:fresh wipes your data

migrate:fresh drops the tables, including those no migration manages. On a production server the command destroys the database, with no way of confirming once it has started. Keep it strictly for local development.

Deleting a migration

There is no make:migration --delete: you delete a migration by deleting its file, in database/migrations, the folder returned by database_path() among the application paths. The only question is whether it has already run.

It has never run (Pending state): delete the file, nothing else.

bash
rm database/migrations/2026_09_02_194036_add_notes_to_tasks_table.php

It has already run (Ran state): roll it back first, then delete the file.

bash
php artisan migrate:rollback --step=1
rm database/migrations/2026_09_02_194036_add_notes_to_tasks_table.php
If the migration has already reached another environment

Deleting the file does not remove the matching row from the migrations table of the databases where it has already run. Those environments keep the column, with no migration left to explain it. In that case, delete nothing: write a new migration that undoes the change. The principle is the same as with Git, where a revert commit beats rewriting history that has already been published.

Changing an existing column

To change a type, a length or nullability, redeclare the column and add change():

php
public function up(): void
{
    Schema::table('tasks', function (Blueprint $table) {
        $table->string('title', 500)->change();
    });
}

Good news for anyone coming back from an old version: the doctrine/dbal package, required for this operation for years, has not been needed since Laravel 11. Checked on Laravel 13.30.1, where change() works with no extra dependency.

change() rewrites the whole definition

Any attribute you do not repeat is lost. A ->nullable()->default('x') column altered by a plain ->string('title', 500)->change() becomes non-nullable again, with no default value. Redeclare the full definition every time.

Common errors

Editing a migration that has already run The other environments will not replay it and will keep the old schema. Always create a new migration.
NOT NULL column with no default value On a table that already holds rows, the migration fails: the database has nothing to write into the existing data. Add nullable() or default().
Forgetting the down() method The migration becomes irreversible, and the smallest step backwards turns into a manual job.
migrate:fresh in production The command drops every table, including those no migration manages, without asking.
after() ignored outside MySQL On SQLite the column is added at the end of the table, with no error. No functional effect, but the schema no longer matches what is written.
change() rewrites the whole definition Attributes you do not repeat are lost: a nullable column with a default value loses both unless you redeclare them.

LaravelMigrationsPHPSQL

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.