Skip to content
Muhammet Şafak
tr
Framework & Library 4 min read

Laravel 8: Model Factories and the New Directory Structure

A deep dive into Laravel 8's class-based factory system and new directory layout, focused on generating test and seed data.


Laravel 8 shipped in September 2020, and the change that caught my attention most was the overhaul of model factories. The old factory system worked, but it was clunky: global functions, magic string references, no type safety. The new system moves all of that into a class-based structure.

I focused this post specifically on factories because generating test and seed data becomes increasingly important as projects grow. A well-designed factory setup makes that work genuinely sustainable.

Old vs. new: a side-by-side comparison

In Laravel 7 and earlier, a factory definition looked like this:

// database/factories/UserFactory.php (old style)
$factory->define(App\User::class, function (Faker\Generator $faker) {
    return [
        'name'     => $faker->name,
        'email'    => $faker->unique()->safeEmail,
        'password' => bcrypt('password'),
    ];
});

And you used it through a global function:

$user = factory(App\User::class)->create();

In Laravel 8, a factory is a class:

// database/factories/UserFactory.php
namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class UserFactory extends Factory
{
    protected $model = User::class;

    public function definition(): array
    {
        return [
            'name'     => $this->faker->name(),
            'email'    => $this->faker->unique()->safeEmail(),
            'password' => bcrypt('password'),
        ];
    }
}

And usage is through the model itself:

$user = User::factory()->create();

With the old system, your IDE had no idea what factory(App\User::class) would return — it was resolving a string class name at runtime. With the new system, User::factory() returns a typed object; the IDE sees the methods, autocomplete works. It sounds like a minor detail, but in large factory files this difference shows up in day-to-day writing speed.

State management

The most valuable feature of the new system is defining distinct states with the state method:

public function suspended(): static
{
    return $this->state(fn (array $attributes) => [
        'suspended_at' => now(),
        'suspended_reason' => 'Policy violation',
    ]);
}

public function admin(): static
{
    return $this->state(fn (array $attributes) => [
        'role' => 'admin',
    ]);
}

States are chainable:

// A suspended admin
$user = User::factory()->suspended()->admin()->create();

// Ten active users
$users = User::factory()->count(10)->create();

Without this structure, testing different states of the same model meant either writing separate factory files or manually passing attributes in every test. Both approaches make maintenance harder.

In real projects, factories shine brightest when it comes to related data. Creating a post that automatically creates a user along with it:

// PostFactory.php
public function definition(): array
{
    return [
        'user_id'    => User::factory(),
        'title'      => $this->faker->sentence(),
        'body'       => $this->faker->paragraphs(3, true),
        'published'  => true,
    ];
}

When another factory is provided as a value, Laravel resolves it automatically. In a test:

// A post belonging to an existing user
$post = Post::factory()->for($existingUser)->create();

// A post with comments
$post = Post::factory()
    ->has(Comment::factory()->count(5))
    ->create();

One pitfall to watch for: when using for() or has(), make sure factories don’t call each other recursively. For example, if PostFactory uses User::factory() and UserFactory uses Post::factory(), a call to Post::factory()->create() can spiral into an infinite loop. The fix is to pass an existing object via for() or to define the relationship in only one direction.

New directory structure

Laravel 8 introduced app/Models as the default directory for models. In previous versions, models lived directly under app/app/User.php, app/Post.php, and so on. It was a small thing, but it accumulated into clutter over time.

In the new structure, models live under app/Models/:

app/
  Models/
    User.php
    Post.php
    Comment.php
  Http/
    Controllers/
    Requests/

There’s no need to force-migrate existing projects to this layout; Laravel 8 is backward compatible. For new projects, this is now the natural home for your models.

Factories and seeders together

Using factories inside seeder files lets you stand up a full test environment quickly:

// database/seeders/DatabaseSeeder.php
public function run(): void
{
    User::factory()
        ->count(10)
        ->has(Post::factory()->count(5)->has(Comment::factory()->count(3)))
        ->create();
}

When this seeder runs, it creates 10 users, each with 5 posts, each post with 3 comments. One command: php artisan db:seed.

Compared to the old system, the difference is significant. Producing the same data previously meant writing a much longer seeder or setting up manual loops. The new system lets you express nested relationships in chainable syntax; when you read the seeder, the data shape is immediately obvious.


Laravel 8’s factory changes look like a small refactor on the surface, but they deliver a real payoff in practice. Type safety, IDE support, chainable state definitions — taken together, they make writing test data genuinely enjoyable. I see this update as a good signal: the framework is maturing and chipping away at its small inconsistencies.

Generating test data is half the work of writing a test. For a model with complex nested relationships, setting up data with the old system could sometimes take longer than writing the test itself. The new factory system removes that obstacle: the test tells you what it does, and the data setup is reduced to a few chained calls. This isn’t just about speed — readability improves too. What you’re testing takes center stage, not the mechanics of how you built the data.

Tags: #Laravel
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind