Laravel 8 Multiple Relationships for Factory

18.7k Views Asked by At

In Laravel 8 it is possible to quickly fill relationships with factories. However, I cannot figure out how to generate more than one relationship. How can I create a random or new relationship for each link using the new Laravel 8 syntax?

This factory syntax is only available in Laravel 8. https://laravel.com/docs/8.x/database-testing#factory-relationships

Problem

Consider the following relationship:

  • Each link belongs to a website and a post.
  • Both websites and posts can have many links.
<?php

class Post extends Model
{
    use HasFactory;

    function links()
    {
        return $this->hasMany(Link::class);
    }
}

class Website extends Model
{
    use HasFactory;

    function links()
    {
        return $this->hasMany(Link::class);
    }
}

class Link extends Model
{
    use HasFactory;

    function post()
    {
        return $this->belongsTo(Post::class);
    }

    function website()
    {
        return $this->belongsTo(Website::class);
    }
}


What I tried/want

What I tried below will only generate one model for all the links. How can I create a random or new relationship for each link using the new Laravel 8 syntax?

Link::factory()->count(3)->forPost()->forWebsite()->make()

=> Illuminate\Database\Eloquent\Collection {#4354
     all: [
       App\Models\Link {#4366
         post_id: 1,
         website_id: 1,
       },
       App\Models\Link {#4395
         post_id: 1, // return a different ID
         website_id: 1,
       },
       App\Models\Link {#4370
         post_id: 1, // return a different ID
         website_id: 1, // return a different ID
       },
     ],
   }
6

There are 6 best solutions below

0
On

It would be better if you play around with code. You will understand better.

$user = User::factory()
            ->has(Post::factory()->count(3), 'posts')
            ->create();

The above code will create three post for a single user. It will insert three post row and a user row. On the other hand the code below, seems three post will be inserted for user with name Jessica Aercher, that is it won't insert a user.

$posts = Post::factory()
            ->count(3)
            ->for(User::factory()->state([
                'name' => 'Jessica Archer',
            ]))
            ->create();
2
On
\App\Models\Category::factory(10)
->has(Product::factory()->count(10), 'products')
->create();
0
On

The laravel magic factory method for allows you to populate the database with one record from the foreign table. See link to documentation https://laravel.com/docs/8.x/database-testing#belongs-to-relationships

In your case, using forPost() and forWebsite() will allow you to populate the database with one id from the Post table and the Website table.

If you want to use different IDs use this syntax instead Link::factory()->count(3)->make()

0
On

Just add this to your LinkFactory:

  public function definition()
  {
    return [
        'post_id' => function () {
            return Post::factory()->create()->id;
        },

        .....
    ];
}

And now you can create new Post for each new Link:

Link::factory()->count(3)->create();//Create 3 links with 3 new posts

or attach new Links to existing Post:

Link::factory()->count(3)->create(['post_id' => Post::first()->id]); //create 3 links and 0 new posts
0
On

Had a similar problem and was only able to get it working when I attached within the afterCreating() on a single factory. This allows me to create/store the id of each model and then attach to the Link model

I'm choosing to start with WebsiteFactory but you can also start with PostFactory since those are the "highest parent" models. If you try to make a Link without the website_id and the post_id I believe you will get a error asking for both.

class WebsiteFactory extends Factory
{
    public function definition(){...}

    public function configure()
    {
       return $this->afterCreating( function (Website $website){
           // the website model is created, hence after-creating

           // attach Website to a new Post
           $post = Post::factory()->hasAttached($website)->create();
           
           // create new links to attach to both
           $links = Link::factory()->for($website)->for($post)->count(3)->create();

    });

You can leave PostFactory and LinkFactory as simple definition blocks (or add other stuff if you wanted). Now when you create a new Website via factory, it will create a new post and 3 new links. For example, you can now run

php artisan tinker

$w = Website::factory()->create();     // one website-one post-3 links
$ws = Website::factory()->count(5)->create();    // 5 website-5 post- 15 links

Check out the Factory Callbacks here (9.x docs, but they are in 8.x too):

https://laravel.com/docs/9.x/database-testing#factory-callbacks

0
On

In Laravel 9, you can use this macro:

// database/factoryMacros.php
<?php

namespace Database\Support;

use Illuminate\Database\Eloquent\Factories\BelongsToRelationship;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

/** @param Factory|Model $factory */
Factory::macro('hasParent', function (mixed $factory, string $relationship = null): self {
    return $this
        ->state(function () use ($factory, $relationship): array {
            $belongsTo = new BelongsToRelationship(
                factory: $factory,
                relationship: $relationship ?? guessBelongsToMethodName($factory),
            );

            return $belongsTo
                ->recycle(recycle: $this->recycle)
                ->attributesFor(model: $this->newModel());
        });
});

Factory::macro('hasChildren', fn (...$arguments): self => $this->has(...$arguments));

Factory::macro('hasChild', fn (...$arguments): self => $this->has(...$arguments));

/** @param Factory|Model $factory */
function guessBelongsToMethodName(mixed $factory): string
{
    $modelName = is_subclass_of($factory, Factory::class)
        ? $factory->modelName()
        : $factory::class;

    return Str::camel(class_basename($modelName));
}

Usage

Use the method hasParent($factory) instead of for($factory):

// Creates 3 Link, 3 Post, 3 Website
Link::factory()
  ->count(3)
  ->hasParent(Post::factory())
  ->hasParent(Website::factory())
  ->make();

You can also use hasChildren($factory) or hasChild($factory) instead of has for name consistency:

// Creates 3 Post, 3 Link
Post::factory()
  ->count(3)
  ->hasChild(Link::factory())
  ->make();

The syntax of the macros is the same as for and has.

You can explicitly define the relationship name, pass complex factory chains, pass a concrete model, and use it with recycle, for example.

Installation

  1. Add the file to your composer.json:
{
  ...
  "autoload": {
    "files": [
      "database/factoryMacros.php"
    ]
  }
}
  1. Run a composer dump-autoload to reload the composer file.

Alternatively, you can register the macro as a service or load it as a mixin.

PS: I intend to create a library for this in the future.

Tests

/**
 * Use "DatabaseEloquentFactoryTest.php" as base: 
 * https://github.com/laravel/framework/blob/de42f9987e01bfde50ea4a86becc237d9c8c5c03/tests/Database/DatabaseEloquentFactoryTest.php
 */
class FactoryMacrosTest extends TestCase
{
    function test_belongs_to_relationship()
    {
        $posts = FactoryTestPostFactory::times(3)
            ->hasParent(FactoryTestUserFactory::new(['name' => 'Taylor Otwell']), 'user')
            ->create();

        $this->assertCount(3, $posts->filter(function ($post) {
            return $post->user->name === 'Taylor Otwell';
        }));

        $this->assertCount(3, FactoryTestUser::all());
        $this->assertCount(3, FactoryTestPost::all());
    }
}

TL;DR;

In Laravel 9, it is not possible to achieve this. The for() uses a single model for all instances.

There's a PR to fix this behavior, but the PR was closed, and I'm not sure it will ever be implemented: https://github.com/laravel/framework/pull/44279