Writing a Migration by Hand

Fill in up() and down() yourself for anything the diff cannot see, and keep data changes in their own migration.

Syntaxpublic function up(Schema $schema): void / $this->addSql(string $sql, array $params = [])

The generated diff only knows about structure: tables, columns, indexes, constraints. It cannot know that the rows already in the table need changing too. For that you write the migration yourself. php bin/console doctrine:migrations:generate creates an empty class in the right place with the right name, and you supply the statements.

up and down

up(Schema $schema): void applies the change. down(Schema $schema): void undoes it, and is what runs for doctrine:migrations:execute --down. Both collect statements through $this->addSql(). If a change genuinely cannot be undone, call $this->throwIrreversibleMigrationException() in down() rather than leaving the method empty: an empty down() claims the rollback worked.

Bind every value

addSql() takes parameters as its second argument: $this->addSql('UPDATE article SET status = :s', ['s' => 'draft']). Use it. A value concatenated into the string breaks on an apostrophe and is a straightforward injection when the value came from anywhere but your keyboard.

Data changes go in their own migration

Keep one migration structural and the next one about data. Two reasons. A data migration is often slow, and you want to see which step is taking the time. And on MySQL, DDL commits implicitly, so a class that alters a table and then fails during the backfill leaves the column added, the rows untouched, and the version unrecorded.

Example

Example · php
<?php

// migrations/Version20260702091500.php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

// Data only. The migration that added article.status is a separate file.
final class Version20260702091500 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Backfill article.status from the legacy published_at column';
    }

    public function up(Schema $schema): void
    {
        // Values are bound, never concatenated into the SQL string.
        $this->addSql(
            'UPDATE article SET status = :published'
            . ' WHERE published_at IS NOT NULL AND status = :draft',
            ['published' => 'published', 'draft' => 'draft'],
        );

        // write() prints to the console while the migration runs.
        $this->write('Articles with a publication date are now marked published.');
    }

    public function down(Schema $schema): void
    {
        $this->addSql(
            'UPDATE article SET status = :draft WHERE status = :published',
            ['draft' => 'draft', 'published' => 'published'],
        );
    }
}

When to use it

  • Rename a column without losing the data the diff would drop
  • Backfill a new column from values already in the table
  • Split a legacy name field into first and last name across existing rows
  • Make a rollback fail loudly when the change genuinely cannot be undone

More examples

A migration that refuses to roll back

Reach for this whenever down() cannot truthfully restore the previous state. An honest exception fails loudly during a rollback attempt, which is far better than a down() that recreates an empty column and lets the deploy continue as if nothing was lost.

Example · php
<?php

// migrations/Version20260704101200.php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260704101200 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Drop the legacy article.published_at column';
    }

    public function up(Schema $schema): void
    {
        // Guards run before the collected SQL. abortIf stops the migration,
        // skipIf marks it as executed and moves on, warnIf only prints.
        $this->warnIf(true, 'published_at is dropped; its values are not recoverable.');

        $this->addSql('ALTER TABLE article DROP published_at');
    }

    public function down(Schema $schema): void
    {
        // The column can come back. The dates cannot.
        $this->throwIrreversibleMigrationException(
            'published_at was dropped after the status backfill; restore from a dump instead.'
        );
    }
}

Generating a blank migration and stepping it by hand

generate, unlike make:migration, never looks at the database, so it is the command for a data migration where there is nothing to diff. The two execute lines it prints are how you test a hand-written up() and down() pair against a scratch database before the file reaches anyone else.

Example · bash
$ php bin/console doctrine:migrations:generate

[notice] Generated new migration class to "/srv/app/migrations/Version20260702091500.php"

To run just this migration for testing purposes, you can use migrations:execute --up 'DoctrineMigrations\Version20260702091500'

To revert the migration you can use migrations:execute --down 'DoctrineMigrations\Version20260702091500'

Checking the queued SQL without touching the data

A dry run builds the statement list and prints it without executing it or recording the version. This is the fastest way to confirm that parameters are bound as you expect and that a long string concatenation produced the SQL you meant to write.

Example · bash
$ php bin/console doctrine:migrations:execute --up --dry-run \
      'DoctrineMigrations\Version20260702091500'

[notice] Migrating up to DoctrineMigrations\Version20260702091500
[notice] finished in 2.1ms, used 20M memory, 1 migrations executed, 1 sql queries

Discussion

  • Be the first to comment on this lesson.