Buy

When we deploy, our migrations run! Woohoo! Yep, we can just generate migrations and everything happens automatically on deploy.

Making a Schema Change

Oooooooh, but there's a catch! Open src/AppBundle/Entity/Video.php. This entity has a field called image:

99 lines src/AppBundle/Entity/Video.php
... lines 1 - 10
class Video
{
... lines 13 - 28
/**
* @var string
*
* @ORM\Column(type="string", length=255)
*/
private $image;
... lines 35 - 66
/**
* @return string
*/
public function getImage()
{
return $this->image;
}
/**
* @param string $image
*/
public function setImage($image)
{
$this->image = $image;
}
... lines 82 - 97
}

Ya know what? I'd rather call that poster, because it's the poster image for this video.

Because the annotation doesn't have a name option, renaming the property means that the column will be renamed in the database. And that means... drum roll... we need a migration!

But first, we also need to update a few parts of our code, like our fixtures. I'll search for image: and replace it with poster::

32 lines src/AppBundle/DataFixtures/ORM/video.yml
AppBundle\Entity\Video:
video_1:
... line 3
poster: images/cowbell.png
... lines 5 - 7
video_2:
... line 9
poster: images/dandelion.png
... lines 11 - 13
video_3:
... line 15
poster: images/bovine1.png
... lines 17 - 19
video_4:
... line 21
poster: images/bovine2.png
... lines 23 - 25
video_5:
... line 27
poster: images/milkjug.png
... lines 29 - 32

Then, open a template: app/Resources/views/default/index.html.twig. Search for .image:

53 lines app/Resources/views/default/index.html.twig
... lines 1 - 2
{% block content %}
<div class="video-content-container">
... lines 5 - 22
<div class="video-box">
... lines 24 - 27
{% if tags %}
{% for video in videos %}
<a href="#video-{{ video.id }}">
<div class="video-container">
<img class="video-image" src="{{ asset(video.image) }}">
... lines 33 - 36
</div>
</a>
{% endfor %}
... lines 40 - 48
{% endif %}
</div>
</div>
{% endblock %}

Ah, yes! Change that to .poster:

53 lines app/Resources/views/default/index.html.twig
... lines 1 - 2
{% block content %}
<div class="video-content-container">
... lines 5 - 22
<div class="video-box">
... lines 24 - 27
{% if tags %}
{% for video in videos %}
<a href="#video-{{ video.id }}">
<div class="video-container">
<img class="video-image" src="{{ asset(video.poster) }}">
... lines 33 - 36
</div>
</a>
{% endfor %}
... lines 40 - 48
{% endif %}
</div>
</div>
{% endblock %}

Brilliant! All we need to do now is write a migration to rename that column. Easy! Switch to your local terminal and run:

./bin/console doctrine:migrations:diff

Go check it out in app/DoctrineMigrations. Wow... it's actually perfect:

ALTER TABLE video CHANGE image (to) poster...

35 lines app/DoctrineMigrations/Version20170927100553.php
... lines 1 - 7
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20170927100553 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE video CHANGE image poster VARCHAR(255) NOT NULL');
}
... lines 23 - 33
}

Doctrine is smart enough to know that we should rename the column instead of dropping the old column and adding a new one.

Dangerous Deploy Ahead!

Great! Let's deploy! Right!? Sure... if you want to take your site down for a minute or two! Can you see the problem? If we deploy now, this migration will run... and about 1 minute later, the deploy will finish and the new code will be used. The problem is during that period. As soon as this migration executes, the image column will be gone... but the live site will still try to use it! That's a huge problem.

Nope, we need to be smarter: we need to write safe migrations. Here's the idea: only write migrations that add new things & never write migrations that remove things... unless that thing is not being used at all by the live site.

Writing Safe Migrations

This creates a slightly different workflow... with two deploys. For the first deploy, change the migration: ALTER TABLE video ADD poster:

39 lines app/DoctrineMigrations/Version20170927100553.php
... lines 1 - 10
class Version20170927100553 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
//$this->addSql('ALTER TABLE video CHANGE image poster VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE video ADD poster VARCHAR(255) NOT NULL');
... line 23
}
... lines 25 - 37
}

We're not going to remove the image column yet. But now, we do need to migrate the data: UPDATE video SET poster = image:

39 lines app/DoctrineMigrations/Version20170927100553.php
... lines 1 - 10
class Version20170927100553 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
//$this->addSql('ALTER TABLE video CHANGE image poster VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE video ADD poster VARCHAR(255) NOT NULL');
$this->addSql('UPDATE video SET poster = image');
}
... lines 25 - 37
}

Honestly, I usually don't worry about the down()... I've actually never rolled back a deploy before. But, let's update it to be safe: SET image = poster, and then ALTER TABLE to drop poster:

39 lines app/DoctrineMigrations/Version20170927100553.php
... lines 1 - 10
class Version20170927100553 extends AbstractMigration
{
... lines 13 - 25
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
//$this->addSql('ALTER TABLE video CHANGE poster image VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci');
$this->addSql('UPDATE video SET image = poster');
$this->addSql('ALTER TABLE video DROP poster');
}
}

This is a safe migration. First, try it locally:

./bin/console doctrine:migrations:migrate

Perfect! And now... deploy! Right? No! Stop that deploy! If you deploy now... well... you're not going to deploy anything. We have not committed or pushed our changes yet!

This is actually the first time that we've made changes to our code, and that's why this is the first time we've needed to worry about this. Commit the changes and run:

git push origin master

Now deploy:

ansible-playbook ansible/deploy.yml -i ansible/hosts.ini --ask-vault-pass

Type in beefpass and deploy to master. If you watch closely, the migration task should show as changed... because it is running one migration.

The site still works with no downtime.

Removing Columns/Tables

What about the extra image column that's still in the database? Now that it's not being used at all on production, it's safe to remove on a second deploy. Run:

./bin/console doctrine:migrations:diff

This time it perfectly sees the DROP:

35 lines app/DoctrineMigrations/Version20170927102503.php
... lines 1 - 10
class Version20170927102503 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE video DROP image');
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE video ADD image VARCHAR(255) NOT NULL');
}
}

Commit this new file and push:

git add .
git commit -m "Removing unused column"
git push origin master

Deploy!

ansible-playbook ansible/deploy.yml -i ansible/hosts.ini --ask-vault-pass

This time, when the image column is removed, the production code is already not using it.

The Edge Case: Updated Data

There is still one edge-case problem. On the first deploy, we used an UPDATE statement to set poster = image. That makes those columns identical:

39 lines app/DoctrineMigrations/Version20170927100553.php
... lines 1 - 10
class Version20170927100553 extends AbstractMigration
{
... lines 13 - 15
public function up(Schema $schema)
{
... lines 18 - 22
$this->addSql('UPDATE video SET poster = image');
}
... lines 25 - 37
}

But, for then next few seconds, the production code is still using the old image column. That's fine... unless people are making changes to its data! Any changes made to image during this period will be lost when the new production code stops reading that column.

If you have this problem, you're going to need to be a little bit more intelligent, and potentially run another UPDATE statement immediately after the new code becomes live.

Ok! Our final migration ran, the deploy finished and the site still works... with no downtime.

Next! Let's share files... and make our deploy faster!

Leave a comment!