DevChalk

GoravelPart 7 of 20

Factories & Seeding

Fake users and tasks, seeded with one command

By Krishan Kumar5 min read

So far every row came from psql, typed by hand. That was fine for a few rows, but it is slow, and you type them all again each time the tables are rebuilt. A factory describes one fake row. A seeder uses factories to fill the database. By the end of this part, one command rebuilds the database and fills it with users and tasks.

CodeStart from the Relationships: Has Many code
Terminal window
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-06
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate

The user factory

Create a factory for users:

Terminal window
go run . artisan make:factory UserFactory
SUCCESS Factory created successfully

It creates database/factories/user_factory.go with an empty Definition. Fill it in, using gofakeit for the fake values:

database/factories/user_factory.go
package factories
import (
"github.com/brianvoe/gofakeit/v7"
"taskflow/app/facades"
)
type UserFactory struct {
}
// Definition Define the model's default state.
func (f *UserFactory) Definition() map[string]any {
password, _ := facades.Hash().Make("password")
return map[string]any{
"Name": gofakeit.Name(),
"Email": gofakeit.Email(),
"Password": password,
"Role": "member",
}
}
  • The keys are the model’s field names, like DueAt, not the column names, like due_at.
  • Every fake user gets the password password, hashed with the Hash facade.
  • The role is the plain string "member", not models.RoleMember. The model will import this package, and Go does not let two packages import each other.

Then run:

Terminal window
go mod tidy

The model finds its factory through a Factory method:

app/models/user.go
package models
import (
"github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
"taskflow/database/factories"
)
7 collapsed lines
type Role string
const (
RoleManager Role = "manager"
RoleMember Role = "member"
)
type User struct {
orm.Model
Name string `gorm:"size:255;not null" json:"name"`
Email string `gorm:"size:255;unique;not null" json:"email"`
Password string `gorm:"size:255;not null" json:"-"`
Role Role `gorm:"type:enum('manager','member');default:'member';not null" json:"role"`
CreatedTasks []Task `gorm:"foreignKey:CreatedBy" json:"created_tasks"`
AssignedTasks []Task `gorm:"foreignKey:AssignedTo" json:"assigned_tasks"`
}
func (u *User) Factory() factory.Factory {
return &factories.UserFactory{}
}

The task factory

Terminal window
go run . artisan make:factory TaskFactory
SUCCESS Factory created successfully

Fill in its Definition:

database/factories/task_factory.go
package factories
import (
"github.com/brianvoe/gofakeit/v7"
"github.com/goravel/framework/support/carbon"
)
type TaskFactory struct {
}
// Definition Define the model's default state.
func (f *TaskFactory) Definition() map[string]any {
title := gofakeit.RandomString([]string{
"Fix the login bug",
"Write the API docs",
"Review the pull request",
"Plan the next release",
"Update the dependencies",
"Add tests for tasks",
"Clean up old logs",
"Reply to support emails",
})
status := gofakeit.RandomString([]string{"pending", "in-progress", "done"})
due := carbon.Now().AddDays(gofakeit.Number(1, 30)).StartOfDay().SetHour(9)
return map[string]any{
"Title": title,
"Description": gofakeit.HackerPhrase(),
"Status": status,
"DueAt": carbon.NewDateTime(due),
}
}

Due dates are 1 to 30 days from today, at 9:00. Status is a plain string too, for the same reason as the role.

There is no CreatedBy or AssignedTo. Both need real users, so the seeder sets them.

Link the factory to the Task model:

app/models/task.go
package models
import (
"github.com/goravel/framework/contracts/database/factory"
"github.com/goravel/framework/database/orm"
"github.com/goravel/framework/support/carbon"
"taskflow/database/factories"
)
7 collapsed lines
type Status string
const (
StatusPending Status = "pending"
StatusInProgress Status = "in-progress"
StatusDone Status = "done"
)
type Task struct {
orm.Model
CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator"`
AssignedTo *uint `json:"assigned_to"`
Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text;not null" json:"description"`
Status Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"`
DueAt *carbon.DateTime `json:"due_at"`
}
func (t *Task) Factory() factory.Factory {
return &factories.TaskFactory{}
}

Code The code for every step of this part is in the tag part-07.

The user and task seeders

Make two seeders, one for users and one for tasks. Keeping them apart lets you run one alone, for example to add more tasks without touching the users:

Terminal window
go run . artisan make:seeder UserSeeder
SUCCESS Seeder created successfully
SUCCESS Seeder registered successfully
go run . artisan make:seeder TaskSeeder
SUCCESS Seeder created successfully
SUCCESS Seeder registered successfully

Like make:migration, make:seeder registers what it creates. It lists the seeders in a new bootstrap/seeders.go:

bootstrap/seeders.go
package bootstrap
import (
"github.com/goravel/framework/contracts/database/seeder"
"taskflow/database/seeders"
)
func Seeders() []seeder.Seeder {
return []seeder.Seeder{
&seeders.UserSeeder{},
&seeders.TaskSeeder{},
}
}

db:seed runs them in this order. You made UserSeeder first, so the users exist before the tasks. make:seeder also added WithSeeders(Seeders) to bootstrap/app.go:

bootstrap/app.go
package bootstrap
11 collapsed lines
import (
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/foundation"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithSeeders(Seeders).
WithMigrations(Migrations).
11 collapsed lines
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
}).
Create()
}

Fill in Run in database/seeders/user_seeder.go:

database/seeders/user_seeder.go
package seeders
import (
"taskflow/app/facades"
"taskflow/app/models"
)
type UserSeeder struct {
}
// Signature The name and signature of the seeder.
func (s *UserSeeder) Signature() string {
return "UserSeeder"
}
// Run executes the seeder logic.
func (s *UserSeeder) Run() error {
var manager models.User
err := facades.Orm().Factory().Create(&manager, map[string]any{
"Name": "Ana",
"Email": "ana@example.com",
"Role": models.RoleManager,
})
if err != nil {
return err
}
var members []models.User
return facades.Orm().Factory().Count(3).Create(&members)
}

The first Create makes Ana, the manager. The map overrides what the factory would pick, so Ana always has the email ana@example.com. Her password is password, like every fake user’s. Count(3) then makes three members with fake names.

TaskSeeder can run alone, so it reads the users back from the database instead of getting them from UserSeeder. Fill in database/seeders/task_seeder.go:

database/seeders/task_seeder.go
package seeders
import (
"taskflow/app/facades"
"taskflow/app/models"
)
type TaskSeeder struct {
}
// Signature The name and signature of the seeder.
func (s *TaskSeeder) Signature() string {
return "TaskSeeder"
}
// Run executes the seeder logic.
func (s *TaskSeeder) Run() error {
var manager models.User
err := facades.Orm().Query().Where("role", models.RoleManager).FirstOrFail(&manager)
if err != nil {
return err
}
var members []models.User
err = facades.Orm().Query().Where("role", models.RoleMember).Find(&members)
if err != nil {
return err
}
for _, member := range members {
var tasks []models.Task
err = facades.Orm().Factory().Count(2).Create(&tasks, map[string]any{
"CreatedBy": manager.ID,
"AssignedTo": &member.ID,
})
if err != nil {
return err
}
}
var unassigned models.Task
return facades.Orm().Factory().Create(&unassigned, map[string]any{
"CreatedBy": manager.ID,
})
}
  • If there is no manager yet, FirstOrFail stops the seeder with record not found. First would hand back an empty user instead.
  • AssignedTo takes &member.ID because the field is a pointer.
  • One more task is made by Ana and assigned to nobody.

Running seeders with db:seed

db:seed runs every registered seeder:

Terminal window
go run . artisan db:seed
ERROR fail to run seeder: ERROR: duplicate key value violates unique constraint "users_email_unique" (SQLSTATE 23505)

It fails because Ana is still in the table from the earlier parts, and emails must be unique. A seeder only adds rows. It does not clear the tables first. If you started from the code at the top of this part, your tables were empty and it worked.

Resetting the database with migrate:fresh

migrate:fresh drops every table and runs all the migrations again, and --seed then runs the seeders. Run it either way, so your ids match the ones below:

Terminal window
go run . artisan migrate:fresh --seed
SUCCESS Dropped all tables successfully
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success
SUCCESS Database seeding completed successfully.
SUCCESS Migration fresh success

The tables now hold the seeded rows:

Terminal window
docker compose exec postgres psql -U goravel -d taskflow \
-c "SELECT id, name, email, role, left(password, 7) AS hash FROM users" \
-c "SELECT id, assigned_to, title, status, due_at::date AS due FROM tasks"
id | name | email | role | hash
----+----------------+----------------------------+---------+---------
1 | Ana | ana@example.com | manager | $2a$12$
2 | Darion Whitney | francis.hansen@jensen.name | member | $2a$12$
3 | Joany Silva | cpowell@richards.org | member | $2a$12$
4 | Cleora Grady | ruby.murphy3@harris.io | member | $2a$12$
(4 rows)
id | assigned_to | title | status | due
----+-------------+-------------------------+-------------+------------
1 | 2 | Add tests for tasks | in-progress | 2026-11-06
2 | 2 | Write the API docs | done | 2026-11-05
3 | 3 | Update the dependencies | done | 2026-10-25
4 | 3 | Fix the login bug | pending | 2026-11-15
5 | 4 | Update the dependencies | in-progress | 2026-10-28
6 | 4 | Write the API docs | in-progress | 2026-11-02
7 | | Reply to support emails | in-progress | 2026-11-15
(7 rows)

Your names, emails and dates are different, because they are random. The passwords start with $2a$12$, the start of a bcrypt hash, so they are real hashes now, not x.

Running one seeder

--seeder runs only the seeders you name, by the name their Signature returns. To get more tasks for the same users, run TaskSeeder alone:

Terminal window
go run . artisan db:seed --seeder=TaskSeeder
SUCCESS Database seeding completed successfully.
docker compose exec postgres psql -U goravel -d taskflow -c "SELECT count(*) FROM tasks"
count
-------
14
(1 row)

The same users got seven more tasks. Running UserSeeder alone would fail on Ana’s email, just like before. Run migrate:fresh --seed again whenever you want the starting data back.

Code

The code for this part is in the tag part-07.

See the changesBrowse the codeDownload zip