Laravel Recursive Migrations

Go to the homepage or look at my talks

It seems that you do not use an AdBlocker, I think you should! Ads are bad for our brains and the web doesn't need them. You can download uBlock here.

I’m currently developing the Quantic Telecom website with Laravel. My app folder is well-organized as my views and my resources but not my migrations because the Laravel php artisan migrate command doesn’t run nested migrations. My package thibaud-dauce/laravel-recursive-migrations will allow us to put migrations into sub-directories.

In this blog post, I will go through the development of this package. For information, I found two links speaking about this particular problem: on StackOverflow “Laravel running migrations on “app/database/migrations” folder recursively” and this issue on Github “[Proposal/Enhancement] Artisan CLI Migrate command does not go through all sub folders of the migrations folder.”

The goal of my package is not to create new commands like submigrate, submigrate:rollback… I want to keep the original names migrate, migrate:rollback and add an option --recursive which will look into all sub-directories.

When thinking about this problem, several solutions came into my mind:

Extending Illuminate\Database\Migrations\Migrator

The easiest approach is to extend the Illuminate\Database\Migrations\Migrator and override the getMigrationFiles() method. This method currently use the glob() method of the filesystem to get all files in the migration directory following the “*_*.php" pattern. Using the allFiles() recursive method instead should do the trick.

The main benefit of this approach is to act on all commands (migrate, rollback, reset…) with only one change because the Migrator is used by all of them. But the drawback is that the Migrator is a low level class, so it can’t access the command’s flags. So I will be forced to apply migrations recursively all the time.

Extending all commands

Extending the commands’ classes will allow me to override some of their methods and modify their behavior. It seems to be a good option for me. The only disadvantage is the fact that I need to create a new class for each of the migrations’ commands: MigrateCommand, RefreshCommand, ResetCommand, RollbackCommand and StatusCommand. But I can manage the duplicate code with some traits.

So, I will split the work into two traits. The first one will be in charged of fetching subdirectories and will be general (pure and not linked to Laravel or the migration system). The second one, the ugly one, will need to be add to a child of the Illuminate\Database\Console\Migrations\BaseCommand and will override the getMigrationPaths(), getOptions() and call() methods. I think it’s often a bad choice to force a trait into an inheritance tree, but I couldn’t find another solution…

My first trait: Subdirectories

This trait will be pure. It will fetch the sub-directories for a path. I could have done a service class injected by the container but I thought it will be over complicated for some business logic that will never change (sub-directories will always be sub-directories). A service class would have given more flexibility to the end user by allowing him to change or extend my implementation.

The first method of this trait will fetch all sub-directories in a path with the help of the Finder component:

/**
 * Fetch all subdirectories from a path.
 * The original path is included in the array.
 *
 * @param string $path
 * @return string[]
 */
protected function subdirectories($path)
{
    return Collection::make(Finder::create()->in($path)->directories())
        ->map(function (SplFileInfo $directory) {
            return $directory->getPathname();
        })
        ->prepend($path)
        ->values()
        ->toArray();
}

The second method will flatMap the result of the first method for multiple base paths. flatMap is a function which map all element in an array to an array (you get an array of array) and then merge all arrays into one.

/**
 * Fetch all subdirectories from an array of base paths.
 * The original paths are included in the array.
 *
 * @param string[] $paths
 * @return string[]
 */
protected function allSubdirectories($paths)
{
    return Collection::make($paths)
        ->flatMap(function ($path) {
            return $this->subdirectories($path);
        })
        ->toArray();
}

My second trait: RecursiveMigrationCommand

The goal of this trait is to provide new methods for the children of Illuminate\Database\Console\Migrations\BaseCommand. It will use the previously seen Subdirectories trait.

First, it will add the --recursive option. Nothing special here to the getOptions() method.

/**
 * Get the console command options.
 *
 * @return array
 */
protected function getOptions()
{
    return array_merge(parent::getOptions(), [
        ['recursive', 'r', InputOption::VALUE_NONE, 'Indicates if the migrations should be run recursively (nested directories).']
    ]);
}

Then, it will add the sub-directories to the migration paths if the flag is set. The allSubdirectories() method comes from the Subdirectories trait.

/**
 * Get all of the migration paths.
 *
 * @return array
 */
protected function getMigrationPaths()
{
    $paths = parent::getMigrationPaths();
    return $this->option('recursive') ? $this->allSubdirectories($paths) : $paths;
}

And finally, the last one, call(), is only required for the RefreshCommand. RefreshCommand does not contain any logic. It’s only a wrapper around the ResetCommand and the MigrateCommand. Internally, it calls the others commands with the call() method, passing its arguments like “step”, “database” or “path”… But in this implementation, it will not pass the “recursive” flag to the other commands. I need to set it if the command called is a migrate command:

/**
 * Call another console command.
 *
 * @param  string  $command
 * @param  array   $arguments
 * @return int
 */
public function call($command, array $arguments = [])
{
    if (starts_with($command, 'migrate')) {
        $arguments['--recursive'] = $this->option('recursive');
    }

    parent::call($command, $arguments);
}

New Commands

I also need to create my own commands for the MigrateCommand, RefreshCommand, ResetCommand, RollbackCommand and StatusCommand. All the implementations are the same thanks to the RecursiveMigrationCommand trait. For example, my MigrateCommand:

<?php declare(strict_types = 1);

namespace ThibaudDauce\LaravelRecursiveMigrations\Commands;

use ThibaudDauce\LaravelRecursiveMigrations\RecursiveMigrationCommand;
use Illuminate\Database\Console\Migrations\MigrateCommand as BaseMigrateCommand;

class MigrateCommand extends BaseMigrateCommand
{
    use RecursiveMigrationCommand;
}

Laravel integration

Last but not least, I need to create a service provider to bind the new commands in the container. The names of the commands in the container are simple, and I only need to extend the binding.

$this->app->extend('command.migrate', function () {
    return new MigrateCommand($this->app['migrator']);
});

$this->app->extend('command.migrate.rollback', function () {
    return new RollbackCommand($this->app['migrator']);
});

$this->app->extend('command.migrate.refresh', function () {
    return new RefreshCommand($this->app['migrator']);
});

$this->app->extend('command.migrate.reset', function () {
    return new ResetCommand($this->app['migrator']);
});

$this->app->extend('command.migrate.status', function () {
    return new StatusCommand($this->app['migrator']);
});

I didn’t found a way to override the first binding with bind() because the MigrationServiceProvider is deferred by the ConsoleSupportServiceProvider so it will register his binding after mines. If I also defer my service provider, I get a “Provider already exists!” exception.

So, I need to use the extend() method which is not really the correct one I think. The extend method will first resolve the previous binding and give it to my closure. In my service provider, I don’t care of the previous instance of the command since I create a new one (a new child) from scratch.

Conclusion

The project is missing PHPUnit tests even if I already try the commands by hand in a fresh Laravel project. But it’s available now on Packagist and on Git. Please open an issue if you find anything wrong in it.

I discover a lot about the migration system in Laravel during this little project, and I encourage you to dig in the source code of the framework: you’ll learn a lot!