Tutorial: Build Your Own Migrations Library

This guide will provide a general overview of how you can build your own migrations library using the Baleen CLI framework.

We’ll use Doctrine Migrations as inspiration for our examples, but keep in mind that you can create a migration library for virtually anything using Baleen.

Doctrine Intro

Since Doctrine Migrations will be our main example for the guide, its worth mentioning some basic things about how Doctrine works and what exactly we’re set to accomplish here.

Doctrine provides applications with a flexible database abstraction layer. In other words its the interface between an application and one or more databases. Among other things, Doctrine provides an ObjectManager as one of the entry points to most of the functionality. This “entry point” is an important concept to keep in mind when building a migration library.

What’s important under the scope of this guide is that the purpose of building a migrations library for Doctrine is to provide a mechanism to ensure the database schema - and potentially some data - remains consistent across different environments.

Overview

During the course of this tutorial we’ll accomplish the following key goals:

  1. Create the skeleton for our migrations library based in Baleen CLI.
  2. Create a Doctrine-specific Storage service.
  3. Create a Doctrine-specific abstract migration class that can be extended by concrete migrations.
  4. Link to the application: inject the application’s ObjectManager into that Migration class.
  5. Customize the configuration schema to offer options to configure the Storage and other aspects of our new library.

Getting Started

The first step of creating your own migrations library is to copy the skeleton of Baleen CLI into a new folder. The skeleton consists of these files:

  • bin/baleen: entry point for unix systems. All it does is include the bootstrap file.
  • bin/bootstrap.php: bootstrap script for your application.
  • config/defaults.php: default values for your configuration file.
  • config/providers.php: contains a list of providers that will be loaded during bootstrap.
  • src/Application: specifies you application’s name and version.

You can optionally also copy the test configuration files if you’d like to use a similar approach to testing.

Next initialize Composer (composer init) and include the following dependencies:

  • baleen/cli:dev-master
  • doctrine/dbal: if you’re building for another library then you’d reference that other library instead, of course.

Then rename bin/baleen to whatever makes sense for your new migrations library, e.g. bin/migrations - and add a reference to it in your composer.json file:

{
    "bin": {
        "bin/migrations"
    }
}

Finally, customize the src/Application.php file to use the name and version of your library.

After all of this you should already be able to test your binary by executing bin/migrations in your terminal and see a list of available commands.

At this point you might find it interesting that if you run any of those commands they will WORK, only that the functionality provided is the default Baleen CLI functionality. That’s because we haven’t customized any of the providers, and therefore all of the providers (:file:`config/providers.php) that are being loaded by the bootstrap file are still the default Baleen CLI providers.

Creating the Doctrine Storage Service

By default Baleen CLI stores versions into a .baleen_versions file. Since we want to version a database it would make sense to store the database version in the database itself. Doing that is easy. The goal for this step is to create a DoctrineStorage class that implements Baleen\Migrations\Storage\AbstractStorage and implements all missing functions (such as doFetchAll(), saveCollection(), etc.).

First create a Version entity for Doctrine. All it needs is an “id” field of the type “string”, unique and primary-key. That field will store the id of the version that has been migrated.

Note

The resulting entity class can be found in the Doctrine Migrations repository under lib/Entity/Version.

Then create the DoctrineStorage class. Extend AbstractStorage and implement the missing methods. You can use a constructor (or setters) to inject the dependencies it will need for its methods. For example you’ll want to inject the ObjectManager, a doctrine Repository, or possibly even both - depending on your needs. You can see a finished DoctrineStorage class in the same Github repository linked above under lib/Storage/DoctrineStorage.php.

Finally, all that’s left is to declare a service in the Container that will return DoctrineStorage under a predefined service name. The predefined service name can simply be referenced using the Services::STORAGE constant. Since a Storage object is meant to be stateless it can be declared as a singleton. Keep in mind the service factory must take care of injecting the Doctrine dependencies (Object Manager and/or Entity Repository) during instantiation.

To declare that service simply create a new Service Provider class and add it to the list of providers in config/providers.php - you can use any string as the key but “storage” is self-explanatory. You should also remove the reference to the default storage provider.

Note

The resulting Storage Provider class can be found in the Doctrine Migrations repository under file lib/Provider/StorageProvider, which works together with lib/Provider/DoctrineProvider to inject the Doctrine dependencies.

With the DoctrineStorage service in place would be able to use the migration commands just like you would with vanilla Baleen CLI, only that instead of saving migrated versions in a file they will be saved to a database. It won’t work just yet though.

Creating the Abstract Migration Class

Baleen Migrations provides a default migration class that all concrete migrations can extend: Baleen\Migrations\Migration\SimpleMigration. This simple, abstract migrations class extends Baleen\Migrations\Migration\MigrationInterface and also Baleen\Migrations\Migration\Capabilities\OptionsAwareInterface in order to provide “some” contextual information to the concrete migration implementations. However, in the context of Doctrine Migrations, SimpleMigration is not enough. We want concrete migrations to be able to easily access the ObjectManager, which we need to inject during or immediately after instantiation.

In order to make that easy, Baleen Migrations offers the ability to specify a “Migration Factory”. This special factory must extend Baleen\Migrations\Migration\Factory\FactoryInterface, and in Baleen CLI the factory can simply be offered as a service named after the Services::MIGRATION_FACTORY constant. If you read the previous section you should already understand how to do that (hint: you’ll want to replace the “repository” provider).

Note

The resulting Doctrine-specific MigrationFactory class can be found in the Doctrine Migrations repository at lib/Migration/MigrationFactory. And the provider can be found at lib/Providers/RepositoryProvider.

As for the abstract migration class itself, which we’ll simply name AbstractMigration`, it will require Doctrine's ``ObjectManager on the constructor and make it available to concrete migrations as a protected property.

Note

An example of the abstract migration class can also be found in the Doctrine Migrations repository at lib/Migration/AbstractMigration.

Linking to the Application

With the Storage and AbstractMigration classes in place and their respective services properly configured, there’s still one more thing to do before the migration commands can be executed: we have to find a way to pick up the application’s Object Manager and inject it to both the Storage service and the Migration Factory service. Regardless of how we approach this, we’ll need a bit of the end-user’s help.

The way Doctrine typically does this kind of integration (that is: for its other CLI tools) is by allowing the user to load the application’s Object Manager into a Console Helper (instance of Symfony\Component\Console\Helper\Helper) through a pre-defined integration file at the root of the project named cli-config.php. For more information about how the file can make the integration work refer to the Doctrine documentation.

So as far as we’re concerned all we need to do is check if that file exists, load it, and then add the resulting Console Helper to our Application class. The code for that can go in the bin/boostrap.php file or in a special provider. In DoctrineMigrations we opted for a more comprehensive approach, and the code for this particular integration approach can be found in lib/Providers/HelperSetProvider.php.

An alternative to this approach would be to offer users the possibility to configure access to the database inside the migration configuration file (the one created with the command bin/migrations init). We would then pick up the configuration and instantiate our own Object Manager based on it. But that approach requires more work from the end-user (e.g. maintain two separate doctrine configuration files for the same database), so it shouldn’t be the primary way to integrate. In Doctrine Migrations we chose to offer end-users the ability to choose freely which of the two approaches they prefer.

Once you’re able to access to the application’s Object Manager from within the application you can make it a service in the container. Offering it as a service will make your life easier because you can then adjust the definitions of the Storage and MigrationFactory services to receive the Object Manager service during instantiation.

Note

Its worth mentioning that building a migrations tool for a generic doctrine-based project is harder than building a similar tool for an application framework (like Laravel, Wordpress or Magento, for example). The reason is that application frameworks tend to be convention-driven, which means the “entry point” to their database and their models is a well-known convention. That’s not the case with configuraton-driven frameworks (like ZF2 or Symfony for example), which they tend to be harder to integrate with because their “entry point” is not a convention and is therefore essentially unknown until it gets configured by the user.

Custom Configuration Schema

If you followed the previous steps you’d notice that if at this point you issue the config:init command your generated configuration file will still have a “storage.file” option (used so the user can choose the name of the file used by FileStorage to store the list of migrated versions).

But we’re not using file storage anymore - we’re using Doctrine Storage - so that configuration option is obsolete. Let’s remove it.

You can easily replace the default configuration class (Config) by simply creating a new configuration class that extends ConfigInterface. Then register that class in the Container (in a similar way as indicated in previous steps) using the Services::CONFIG constant. And finally provide the default values for the configuration file by implementing the ConfigInterface::getDefaults function.

You can then supply a different “definition” (refer to the :php:ns:Symfony\Config` module for more information) by creating a new definition class and implementing the ConfigInterface::getDefinition function so that it returns your own definition. This custom definition would of course not include rules for the option we want to get rid of.

Next time you run the config:init command, the default values from your Config class will be validated against the new Definition, and then written into the file.

You can see examples of a custom configuration file and definition in the Doctrine Migrations repository inside the lib/Config folder.

Conclusion

In this guide we built a doctrine-specific migrations library that can be used by developers that use Doctrine in their project. Several other Baleen CLI features were not covered, but if you understood how you can use the Container and its providers to customize Baleen to your needs then this tutorial’s ultimate goal has been accomplished, and you should be in good shape to get started on your own.

Tip

If you followed this guide please help us improve it by submitting a pull-request with any changes that you think might be useful for future readers.