├── .gitignore ├── README.md ├── composer.json ├── resources └── views │ └── factory.blade.php └── src ├── Console └── GenerateCommand.php ├── TestFactoryHelperServiceProvider.php └── Types └── EnumType.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | .idea 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Laravel Test Factory Generator 2 | 3 | `php artisan generate:model-factory` 4 | 5 | This package will generate [factories](https://laravel.com/docs/master/database-testing#writing-factories) from your existing models so you can get started with testing your Laravel application more quickly. 6 | 7 | ### Example output 8 | 9 | #### Migration and Model 10 | ```php 11 | Schema::create('users', function (Blueprint $table) { 12 | $table->increments('id'); 13 | $table->string('name'); 14 | $table->string('username'); 15 | $table->string('email')->unique(); 16 | $table->string('password', 60); 17 | $table->integer('company_id'); 18 | $table->rememberToken(); 19 | $table->timestamps(); 20 | }); 21 | 22 | class User extends Model { 23 | public function company() 24 | { 25 | return $this->belongsTo(Company::class); 26 | } 27 | } 28 | ``` 29 | 30 | #### Factory Result 31 | 32 | ```php 33 | $factory->define(App\User::class, function (Faker\Generator $faker) { 34 | return [ 35 | 'name' => $faker->name, 36 | 'username' => $faker->userName, 37 | 'email' => $faker->safeEmail, 38 | 'password' => bcrypt($faker->password), 39 | 'company_id' => factory(App\Company::class), 40 | 'remember_token' => Str::random(10), 41 | ]; 42 | }); 43 | ``` 44 | 45 | 46 | ### Install 47 | 48 | Require this package with composer using the following command: 49 | 50 | ```bash 51 | composer require --dev mpociot/laravel-test-factory-helper 52 | ``` 53 | 54 | ### Usage 55 | 56 | To generate multiple factories at once, run the artisan command: 57 | 58 | `php artisan generate:model-factory` 59 | 60 | This command will find all models within your application and create test factories. By default, this will not overwrite any existing model factories. You can _force_ overwriting existing model factories by using the `--force` option. 61 | 62 | To generate a factory for specific model or models, run the artisan command: 63 | 64 | `php artisan generate:model-factory User Team` 65 | 66 | By default, this command will search under the `app` folder for models. If your models are within a different folder, for example `app/Models`, you can specify this using `--dir` option. In this case, run the artisan command: 67 | 68 | `php artisan generate:model-factory --dir app/Models -- User Team` 69 | 70 | ### License 71 | 72 | The Laravel Test Factory Helper is free software licensed under the MIT license. 73 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mpociot/laravel-test-factory-helper", 3 | "description": "Generate Laravel test factories from your existing models", 4 | "keywords": ["Laravel", "Tests", "Factory"], 5 | "license": "MIT", 6 | "require": { 7 | "php": "^7.2.5", 8 | "illuminate/support": "^6.0|^7.0", 9 | "illuminate/console": "^6.0|^7.0", 10 | "illuminate/filesystem": "^6.0|^7.0", 11 | "doctrine/dbal": "^2.9" 12 | }, 13 | "autoload": { 14 | "psr-4": { 15 | "Mpociot\\LaravelTestFactoryHelper\\": "src/" 16 | } 17 | }, 18 | "extra": { 19 | "laravel": { 20 | "providers": [ 21 | "Mpociot\\LaravelTestFactoryHelper\\TestFactoryHelperServiceProvider" 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/views/factory.blade.php: -------------------------------------------------------------------------------- 1 | /* @@var $factory \Illuminate\Database\Eloquent\Factory */ 2 | 3 | use Faker\Generator as Faker; 4 | 5 | $factory->define({{ $reflection->getName() }}::class, function (Faker $faker) { 6 | return [ 7 | @foreach($properties as $name => $property) 8 | '{{$name}}' => {!! $property !!}, 9 | @endforeach 10 | ]; 11 | }); 12 | -------------------------------------------------------------------------------- /src/Console/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | files = $files; 68 | $this->view = $view; 69 | } 70 | 71 | /** 72 | * Execute the console command. 73 | * 74 | * @return void 75 | */ 76 | public function handle() 77 | { 78 | Type::addType('customEnum', EnumType::class); 79 | $this->dir = $this->option('dir'); 80 | $this->force = $this->option('force'); 81 | 82 | $models = $this->loadModels($this->argument('model')); 83 | 84 | foreach ($models as $model) { 85 | $filename = 'database/factories/' . class_basename($model) . 'Factory.php'; 86 | 87 | if ($this->files->exists($filename) && !$this->force) { 88 | $this->line('Model factory exists, use --force to overwrite: ' . $filename); 89 | 90 | continue; 91 | } 92 | 93 | $result = $this->generateFactory($model); 94 | 95 | if ($result === false) { 96 | continue; 97 | } 98 | 99 | $written = $this->files->put($filename, $result); 100 | if ($written !== false) { 101 | $this->line('Model factory created: ' . $filename); 102 | } else { 103 | $this->line('Failed to create model factory: ' . $filename); 104 | } 105 | } 106 | } 107 | 108 | 109 | /** 110 | * Get the console command arguments. 111 | * 112 | * @return array 113 | */ 114 | protected function getArguments() 115 | { 116 | return array( 117 | array('model', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Which models to include', array()), 118 | ); 119 | } 120 | 121 | /** 122 | * Get the console command options. 123 | * 124 | * @return array 125 | */ 126 | protected function getOptions() 127 | { 128 | return [ 129 | ['dir', 'D', InputOption::VALUE_OPTIONAL, 'The model directory', $this->dir], 130 | ['force', 'F', InputOption::VALUE_NONE, 'Overwrite any existing model factory'], 131 | ]; 132 | } 133 | 134 | protected function generateFactory($model) 135 | { 136 | $output = 'properties = []; 139 | if (!class_exists($model)) { 140 | if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) { 141 | $this->error("Unable to find '$model' class"); 142 | } 143 | return false; 144 | } 145 | 146 | try { 147 | // handle abstract classes, interfaces, ... 148 | $reflectionClass = new \ReflectionClass($model); 149 | 150 | if (!$reflectionClass->isSubclassOf('Illuminate\Database\Eloquent\Model')) { 151 | return false; 152 | } 153 | 154 | if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) { 155 | $this->comment("Loading model '$model'"); 156 | } 157 | 158 | if (!$reflectionClass->IsInstantiable()) { 159 | // ignore abstract class or interface 160 | return false; 161 | } 162 | 163 | $model = $this->laravel->make($model); 164 | 165 | $this->getPropertiesFromTable($model); 166 | $this->getPropertiesFromMethods($model); 167 | 168 | $output .= $this->createFactory($model); 169 | } catch (\Exception $e) { 170 | $this->error("Exception: " . $e->getMessage() . "\nCould not analyze class $model."); 171 | } 172 | 173 | return $output; 174 | } 175 | 176 | 177 | protected function loadModels($models = []) 178 | { 179 | if (!empty($models)) { 180 | return array_map(function ($name) { 181 | if (strpos($name, '\\') !== false) { 182 | return $name; 183 | } 184 | 185 | return str_replace( 186 | [DIRECTORY_SEPARATOR, basename($this->laravel->path()) . '\\'], 187 | ['\\', $this->laravel->getNamespace()], 188 | $this->dir . DIRECTORY_SEPARATOR . $name 189 | ); 190 | }, $models); 191 | } 192 | 193 | 194 | $dir = base_path($this->dir); 195 | if (!file_exists($dir)) { 196 | return []; 197 | } 198 | 199 | return array_map(function (\SplFIleInfo $file) { 200 | return str_replace( 201 | [DIRECTORY_SEPARATOR, basename($this->laravel->path()) . '\\'], 202 | ['\\', $this->laravel->getNamespace()], 203 | $file->getPath() . DIRECTORY_SEPARATOR . basename($file->getFilename(), '.php') 204 | ); 205 | }, $this->files->allFiles($this->dir)); 206 | } 207 | 208 | /** 209 | * Load the properties from the database table. 210 | * 211 | * @param \Illuminate\Database\Eloquent\Model $model 212 | */ 213 | protected function getPropertiesFromTable($model) 214 | { 215 | $table = $model->getConnection()->getTablePrefix() . $model->getTable(); 216 | $schema = $model->getConnection()->getDoctrineSchemaManager($table); 217 | $databasePlatform = $schema->getDatabasePlatform(); 218 | $databasePlatform->registerDoctrineTypeMapping('enum', 'customEnum'); 219 | 220 | $platformName = $databasePlatform->getName(); 221 | $customTypes = $this->laravel['config']->get("ide-helper.custom_db_types.{$platformName}", array()); 222 | foreach ($customTypes as $yourTypeName => $doctrineTypeName) { 223 | $databasePlatform->registerDoctrineTypeMapping($yourTypeName, $doctrineTypeName); 224 | } 225 | 226 | $database = null; 227 | if (strpos($table, '.')) { 228 | list($database, $table) = explode('.', $table); 229 | } 230 | 231 | $columns = $schema->listTableColumns($table, $database); 232 | 233 | if ($columns) { 234 | foreach ($columns as $column) { 235 | $name = $column->getName(); 236 | if (in_array($name, $model->getDates())) { 237 | $type = 'datetime'; 238 | } else { 239 | $type = $column->getType()->getName(); 240 | } 241 | if (!($model->incrementing && $model->getKeyName() === $name) && 242 | $name !== $model::CREATED_AT && 243 | $name !== $model::UPDATED_AT 244 | ) { 245 | if (!method_exists($model, 'getDeletedAtColumn') || (method_exists($model, 'getDeletedAtColumn') && $name !== $model->getDeletedAtColumn())) { 246 | $this->setProperty($name, $type, $table); 247 | } 248 | } 249 | } 250 | } 251 | } 252 | 253 | 254 | /** 255 | * @param \Illuminate\Database\Eloquent\Model $model 256 | */ 257 | protected function getPropertiesFromMethods($model) 258 | { 259 | $methods = get_class_methods($model); 260 | 261 | foreach ($methods as $method) { 262 | if (!Str::startsWith($method, 'get') && !method_exists('Illuminate\Database\Eloquent\Model', $method)) { 263 | // Use reflection to inspect the code, based on Illuminate/Support/SerializableClosure.php 264 | $reflection = new \ReflectionMethod($model, $method); 265 | $file = new \SplFileObject($reflection->getFileName()); 266 | $file->seek($reflection->getStartLine() - 1); 267 | $code = ''; 268 | while ($file->key() < $reflection->getEndLine()) { 269 | $code .= $file->current(); 270 | $file->next(); 271 | } 272 | $code = trim(preg_replace('/\s\s+/', '', $code)); 273 | $begin = strpos($code, 'function('); 274 | $code = substr($code, $begin, strrpos($code, '}') - $begin + 1); 275 | foreach (['belongsTo'] as $relation) { 276 | $search = '$this->' . $relation . '('; 277 | if ($pos = stripos($code, $search)) { 278 | $relationObj = $model->$method(); 279 | if ($relationObj instanceof Relation) { 280 | $this->setProperty($relationObj->getForeignKeyName(), 'factory(' . get_class($relationObj->getRelated()) . '::class)'); 281 | } 282 | } 283 | } 284 | } 285 | } 286 | } 287 | 288 | /** 289 | * @param string $name 290 | * @param string|null $type 291 | */ 292 | protected function setProperty($name, $type = null, $table = null) 293 | { 294 | if ($type !== null && Str::startsWith($type, 'factory(')) { 295 | $this->properties[$name] = $type; 296 | 297 | return; 298 | } 299 | 300 | $fakeableTypes = [ 301 | 'enum' => '$faker->randomElement(' . $this->enumValues($table, $name) . ')', 302 | 'string' => '$faker->word', 303 | 'text' => '$faker->text', 304 | 'date' => '$faker->date()', 305 | 'time' => '$faker->time()', 306 | 'guid' => '$faker->word', 307 | 'datetimetz' => '$faker->dateTime()', 308 | 'datetime' => '$faker->dateTime()', 309 | 'integer' => '$faker->randomNumber()', 310 | 'bigint' => '$faker->randomNumber()', 311 | 'smallint' => '$faker->randomNumber()', 312 | 'decimal' => '$faker->randomFloat()', 313 | 'float' => '$faker->randomFloat()', 314 | 'boolean' => '$faker->boolean' 315 | ]; 316 | 317 | $fakeableNames = [ 318 | 'city' => '$faker->city', 319 | 'company' => '$faker->company', 320 | 'country' => '$faker->country', 321 | 'description' => '$faker->text', 322 | 'email' => '$faker->safeEmail', 323 | 'first_name' => '$faker->firstName', 324 | 'firstname' => '$faker->firstName', 325 | 'guid' => '$faker->uuid', 326 | 'last_name' => '$faker->lastName', 327 | 'lastname' => '$faker->lastName', 328 | 'lat' => '$faker->latitude', 329 | 'latitude' => '$faker->latitude', 330 | 'lng' => '$faker->longitude', 331 | 'longitude' => '$faker->longitude', 332 | 'name' => '$faker->name', 333 | 'password' => 'bcrypt($faker->password)', 334 | 'phone' => '$faker->phoneNumber', 335 | 'phone_number' => '$faker->phoneNumber', 336 | 'postcode' => '$faker->postcode', 337 | 'postal_code' => '$faker->postcode', 338 | 'remember_token' => 'Str::random(10)', 339 | 'slug' => '$faker->slug', 340 | 'street' => '$faker->streetName', 341 | 'address1' => '$faker->streetAddress', 342 | 'address2' => '$faker->secondaryAddress', 343 | 'summary' => '$faker->text', 344 | 'url' => '$faker->url', 345 | 'user_name' => '$faker->userName', 346 | 'username' => '$faker->userName', 347 | 'uuid' => '$faker->uuid', 348 | 'zip' => '$faker->postcode', 349 | ]; 350 | 351 | if (isset($fakeableNames[$name])) { 352 | $this->properties[$name] = $fakeableNames[$name]; 353 | 354 | return; 355 | } 356 | 357 | if (isset($fakeableTypes[$type])) { 358 | $this->properties[$name] = $fakeableTypes[$type]; 359 | 360 | return; 361 | } 362 | 363 | $this->properties[$name] = '$faker->word'; 364 | } 365 | 366 | public static function enumValues($table, $name) 367 | { 368 | if ($table === null) { 369 | return "[]"; 370 | } 371 | 372 | $type = DB::select(DB::raw('SHOW COLUMNS FROM ' . $table . ' WHERE Field = "' . $name . '"'))[0]->Type; 373 | 374 | preg_match_all("/'([^']+)'/", $type, $matches); 375 | 376 | $values = isset($matches[1]) ? $matches[1] : array(); 377 | 378 | return "['" . implode("', '", $values) . "']"; 379 | } 380 | 381 | 382 | /** 383 | * @param string $class 384 | * @return string 385 | */ 386 | protected function createFactory($class) 387 | { 388 | $reflection = new \ReflectionClass($class); 389 | 390 | $content = $this->view->make('test-factory-helper::factory', [ 391 | 'reflection' => $reflection, 392 | 'properties' => $this->properties, 393 | ])->render(); 394 | 395 | return $content; 396 | } 397 | 398 | } 399 | -------------------------------------------------------------------------------- /src/TestFactoryHelperServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom($viewPath, 'test-factory-helper'); 27 | } 28 | 29 | /** 30 | * Register the service provider. 31 | * 32 | * @return void 33 | */ 34 | public function register() 35 | { 36 | $this->app->bind('command.test-factory-helper.generate', 37 | function ($app) { 38 | return new GenerateCommand($app['files'], $app['view']); 39 | } 40 | ); 41 | 42 | $this->commands('command.test-factory-helper.generate'); 43 | } 44 | 45 | /** 46 | * Get the services provided by the provider. 47 | * 48 | * @return array 49 | */ 50 | public function provides() 51 | { 52 | return array('command.test-factory-helper.generate'); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/Types/EnumType.php: -------------------------------------------------------------------------------- 1 |