Migrate Laravel factories to class factories
Tags: laravel, testing
Published: 2021-07-12 19:44:48

I had been postponing the migration of my tests factories to the not so new now class based factories, well the day came, I needed some of the new factories features, but migrating hundreds of files was a tedious task, so I decided to spent an absurd amount of time flexing my not so good regex skills to make an script to automate that one off task. And now I'm sharing it with you:

```php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use File;

class MigrateToNewFactories extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'factories:new';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    private $modelsForFactory;

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        // move the database/factories  to the database/factories-old directorie
        // if you have a ModelFactory.php file with multiple factories, move it to the database directory
        $this->modelsForFactory = collect();
        $this->recreateFactories();
        $this->factoryFunction('tests');
        $this->factoryFunction('database/factories');

        return 0;
    }

    public function factoryFunction($dir)
    {
        $files = \File::allFiles(base_path($dir));
        foreach ($files as $file) {
            $path = $file->getPathname();
            if (Str::of($path)->endsWith('php')) {
                $this->fixFactoryFunction($file->getPathname());
            }
        }
    }

    public function fixFactoryFunction($path)
    {
        $file = \File::get($path);
        $regex = "/factory\(('(.+)'|\"(.+)\"|(.+)::class)(, *?(\d+))?\)/";
        $fixed = preg_replace_callback($regex, function ($matches) {
            $matches = collect($matches);

            if (count($matches) <= 5) {
                $modelClassNameMatch = $matches->last();
                $modelClassName = $this->indetifyRelativeOrAbsoluteModelClassName($modelClassNameMatch);

                return $modelClassName . '::factory()';
            }
            $matches = $matches->filter(fn($item) => !empty($item))->values();
            $this->info($matches);

            $modelClassNameMatch = $matches->get($matches->count() - 3);
            $modelClassName = $this->indetifyRelativeOrAbsoluteModelClassName($modelClassNameMatch);

            return $modelClassName . '::factory()->count(' . collect($matches)->last() . ')';

        }, $file);
        \File::put($path, $fixed);
        $this->info("Done $path");
    }

    private function recreateFactories()
    {
        $files = \File::allFiles(base_path('database/factories-old'));
        foreach ($files as $file) {
            $modelName = Str::of($file->getFilename())->replace('Factory.php', '');
            $this->createModelFactory($modelName);
        }

        $this->getModelFactoriesFromModelFactoryFile();
    }

    private function getModels($path)
    {
        $file = \File::get($path);
        $regex = "/create\((.*\W)?(\w+)::class/";
        preg_replace_callback($regex, function ($matches) {
            $this->modelsForFactory->push(collect($matches)->last());
        }, $file);
        $regex2 = '/.*\W(\w+)::factory\(/';
        preg_replace_callback($regex2, function ($matches) {
            $this->modelsForFactory->push(collect($matches)->last());
        }, $file);
    }

    private function createModelFactory(mixed $model)
    {
        $factoryClassName = "\\Database\\Factories\\{$model}Factory";
        if (!class_exists($factoryClassName)) {
            $this->getFactoryBodyFromFile($model);
            $this->addHasFactoryToModel($model);
        }
    }

    private function getFactoryBodyFromFile(mixed $model)
    {
        $path = database_path('factories-old/' . $model . 'Factory.php');
        $fileContents = File::get($path);
        $regex = "/\)\s*\{(.*)\}\);/s";
        $matches = null;
        preg_match_all($regex, $fileContents, $matches);
        $this->info('collect($matches)');
        $this->info($model);
        $returnCode = $matches[1][0];
        $returnCode = preg_replace('/\$faker/', '$this->faker', $returnCode);
        $modelClassName = $this->inferModelClassName($model);
        $factoryClassName = "{$model}Factory";
        $factoryClassCode = sprintf($this->template(), $modelClassName, $factoryClassName, $model . '::class', $returnCode);
        $this->info($path);
        File::put(database_path('factories/' . $model . 'Factory.php'), $factoryClassCode);
    }

    private function getModelFactoriesFromModelFactoryFile()
    {
        $path = database_path('/ModelFactory.php');
        $fileContents = File::get($path);
        $factories = Str::of($fileContents)->split('/\$factory->/');
        foreach ($factories as $key => $factory) {

            // skip the start of the code
            if ($key == 0) continue;
            $modelMatches = null;
            $modelRegex = "/define\((.+),/";
            preg_match($modelRegex, $factory, $modelMatches);

            $classNameCall = Str::of($modelMatches[1])->split('/\\\/')->last();
            $model = Str::of($classNameCall)->split('/::/')->first();

            $returnCodeMatches = null;
            $returnCoderegex = "/\)\s*\{(.*)\}\);/s";
            preg_match($returnCoderegex, $factory, $returnCodeMatches);

            $returnCode = $returnCodeMatches[1];
            $returnCode = preg_replace('/\$faker/', '$this->faker', $returnCode);
            $factoryClassName = "{$model}Factory";
            $factoryClassCode = sprintf($this->template(), $this->inferModelClassName($model), $factoryClassName, $model . '::class', $returnCode);
            $factoryPath = database_path('factories/' . $model . 'Factory.php');
            if (File::exists($factoryPath)) {
                throw new \Exception("The factory already exists " . $model);
            }
            File::put($factoryPath, $factoryClassCode);
            $this->addHasFactoryToModel($model);
        }
    }

    private function template()
    {
        $text = <<<STR
<?php

namespace Database\Factories;

use %s;
use Illuminate\Database\Eloquent\Factories\Factory;

class %s extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected \$model = %s;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        %s
    }
}

STR;

        return $text;
    }

    private function inferModelClassName($model)
    {
        $modelClassName = "App\\" . $model;
        if (!class_exists($modelClassName)) {
            $modelClassName = "App\\Models\\" . $model;
        }

        return $modelClassName;
    }

    private function addHasFactoryToModel(mixed $model)
    {
        $modelClassName = "App\\" . $model;
        $modelFilePath = app_path($model . '.php');
        if (!class_exists($modelClassName)) {
            $modelFilePath = app_path('Models/' . $model . '.php');
        }
        $contents = File::get($modelFilePath);
        if (!Str::of($contents)->contains('HasFactory')) {
            $contents = preg_replace('/use Illuminate\\\Database\\\Eloquent\\\Model;/', "use Illuminate\Database\Eloquent\Model; \nuse Illuminate\Database\Eloquent\Factories\HasFactory;", $contents);
            $contents = preg_replace_callback("/\{(.*)\}/s", fn($matched) => "{\n    use HasFactory;" . $matched[1] . "\n}", $contents);
//        $this->info($contents);
            File::put($modelFilePath, $contents);
        }
    }

    private function indetifyRelativeOrAbsoluteModelClassName($modelClassNameMatch)
    {
        $modelClassName = Str::of($modelClassNameMatch);
        $splittedClassName = $modelClassName->split('/\\\/')->filter(fn($part) => $part !== "");
        if ($splittedClassName->count() > 1) {
            $modelClassName = $modelClassName->start('\\');
        }

        return $modelClassName;
    }

}

```