Part 4 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-04.
TaskFlow reads requests, but it has nowhere to keep anything yet. In this part we create the users table from the database design. We write a model, and Goravel writes the migration from it.
Start from the Reading Request Data code:
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-03
cp .env.example .env
go run . artisan key:generate
docker compose up -d
The model
A model is a Go struct that stands for one table. Create one for users:
$ go run . artisan make:model User
SUCCESS Model created successfully
This creates app/models/user.go:
app/models/user.go
package models
import (
"github.com/goravel/framework/database/orm"
)
type User struct {
orm.Model
}
orm.Model gives every model an id and two timestamps: when the row was created and when it was last updated. Goravel fills the timestamps for you when a row is saved. A model named User uses a table named users.
Now add the fields from the design:
app/models/user.go
package models
import (
"github.com/goravel/framework/database/orm"
)
+type Role string
+
+const (
+ RoleManager Role = "manager"
+ RoleMember Role = "member"
+)
+
type User struct {
orm.Model
+ Name string `gorm:"size:255;not null"`
+ Email string `gorm:"size:255;unique;not null"`
+ Password string `gorm:"size:255;not null"`
+ Role Role `gorm:"type:enum('manager','member');default:'member';not null"`
}
-
Emailis unique, so two users can never share an email. -
Rolehas its own type with two values, manager and member. If no role is given, the database uses member. - In code you write
models.RoleManagerinstead of the string"manager". If you mistypeRoleManager, the build fails and tells you. If you mistype"manager", it builds fine and the bug shows up later.
The GORM tags after each field describe its column: size:255 is the maximum length, and not null says it must have a value. Password will hold a hashed password, never the real one. We hash it when we build register and login.
Goravel does not create or change tables from these tags while the app runs. The tags only matter when make:migration reads them to write a migration.
Other tags make:migration understands:
Here is a model that tries a different tag on each field. It is only an example, so do not add it to TaskFlow:
app/models/probe.go
type Probe struct {
orm.Model
Title string `gorm:"size:100"`
Body string `gorm:"type:text"`
Nick *string `gorm:"size:50"`
Points int `gorm:"default:0"`
Done bool `gorm:"default:false"`
Code string `gorm:"size:20;uniqueIndex"`
Status string `gorm:"size:20;index"`
Note string `gorm:"size:255;comment:shown to the user"`
DueAt *string `gorm:"column:due_on;size:10"`
Price float64 `gorm:"type:decimal;precision:10;scale:2"`
Meta string `gorm:"migration:json"`
}
And what make:migration -m Probe wrote for it:
return facades.Schema().Create("probes", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.String("title", 100)
table.Text("body")
table.String("nick", 50).Nullable()
table.BigInteger("points").Default(0)
table.Boolean("done").Default(false)
table.String("code", 20)
table.String("status", 20)
table.String("note", 255).Comment("shown to the user")
table.String("due_on", 10).Nullable()
table.Decimal("price").Places(2).Total(10)
table.Json("meta")
table.Unique("code").Name("idx_probes_code")
table.Index("status").Name("idx_probes_status")
})
- A pointer field, like
*string, makes a column nullable. Every other column is not null, with or without anot nulltag. - Give a decimal its size with
precisionandscale. If you writetype:decimal(10,2)instead, the size is lost. -
uniqueIndexworks likeunique, but the index is namedidx_<table>_<column>instead of<table>_<column>_unique. -
migration:takes the name of a schema builder method, somigration:jsoncallstable.Json.
The migration
A migration is a Go file with two methods: Up makes a change to the database, and Down undoes it. You could create the table by hand in Postgres, but then it would only exist on your machine. A migration lives in the project with the code, so every copy of TaskFlow gets the same table.
Goravel can write that file for you from the model. -m User tells it which model to read. If you run it now, it fails:
$ go run . artisan make:migration create_users_table -m User
ERROR create migration failed: model User not found in registered models
Goravel only knows the models you tell it about, because Go cannot look up a type by its name while the program runs. Register User in bootstrap/app.go:
bootstrap/app.go
package bootstrap
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().
WithMigrations(Migrations).
// ... 4 lines not shown
WithProviders(Providers).
WithConfig(config.Boot).
+ WithCallback(func() {
+ facades.Schema().Extend(schema.Extension{
+ Models: []any{models.User{}},
+ })
+ }).
Create()
}
WithCallback takes a function that runs during startup, after the facades are ready. Inside it, Extend adds User to the models Goravel knows. Run the command again:
$ go run . artisan make:migration create_users_table -m User
SUCCESS Created Migration: create_users_table
SUCCESS Migration registered successfully
It writes the migration and adds it to the list in bootstrap/migrations.go. The file name, the struct and the signature include the time you ran the command, so yours will have a different number:
bootstrap/migrations.go
// ... 8 lines not shown
func Migrations() []schema.Migration {
return []schema.Migration{
&migrations.M20210101000001CreateJobsTable{},
+ &migrations.M20261022221906CreateUsersTable{},
}
}
This is what Goravel wrote:
database/migrations/20261022221906_create_users_table.go
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"taskflow/app/facades"
)
type M20261022221906CreateUsersTable struct{}
// Signature The unique signature for the migration.
func (r *M20261022221906CreateUsersTable) Signature() string {
return "20261022221906_create_users_table"
}
// Up Run the migrations.
func (r *M20261022221906CreateUsersTable) Up() error {
if !facades.Schema().HasTable("users") {
return facades.Schema().Create("users", func(table schema.Blueprint) {
table.TimestampTz("created_at").Nullable()
table.TimestampTz("updated_at").Nullable()
table.BigIncrements("id")
table.String("name", 255)
table.String("email", 255)
table.String("password", 255)
table.Enum("role", []any{"manager", "member"}).Default("member")
table.Unique("email")
})
}
return nil
}
// Down Reverse the migrations.
func (r *M20261022221906CreateUsersTable) Down() error {
return facades.Schema().DropIfExists("users")
}
Up only creates the table if it is not there yet, and Down drops it.
Every column comes from the model:
-
created_at,updated_atandidcome fromorm.Model. - The unique email became
table.Unique("email"). - The role became
table.Enum, with member as its default.
Only the two timestamps have Nullable(). Every other column is not null already, so the not null tags change nothing here. They only matter on a pointer field, which would otherwise be nullable. We keep them because they show that each column needs a value.
Run the migration
If Postgres is not running, start it with docker compose up -d. Then run the migrations:
$ go run . artisan migrate
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
SUCCESS Migration success
The jobs migration runs too. It came with the Queue facade we picked when we created the project, and it creates two tables, jobs and failed_jobs. Our queue runs on Redis, so it never uses jobs, but Goravel still records failed jobs in failed_jobs.
Its file and struct say 20210101000001, but it reports itself as 20210101000002. That is in the file Goravel generated, and it does no harm.
To see what has run:
$ go run . artisan migrate:status
Migration name .............................................. Batch / Status
20210101000002_create_jobs_table ................................... [1] Ran
20261022221906_create_users_table .................................. [1] Ran
Each migrate run is one batch, numbered one higher than the last. Both of ours ran in batch 1. To see the table itself:
$ go run . artisan db:table users
public.users ...............................................................
Columns .................................................................. 7
Size .............................................................. 0.023 MB
Column ................................................................ Type
created_at timestamptz, nullable ............... timestamp(0) with time zone
updated_at timestamptz, nullable ............... timestamp(0) with time zone
id autoincrement, int8 ............ nextval('users_id_seq'::regclass) bigint
name varchar ........................................ character varying(255)
email varchar ....................................... character varying(255)
password varchar .................................... character varying(255)
role varchar ............ 'member'::character varying character varying(255)
Index ......................................................................
users_email_unique email ..................................... btree, unique
users_pkey id ....................................... btree, unique, primary
Gotcha
db:tablesaysroleisvarchar, not an enum. On Postgres, Goravel stores an enum as a varchar column with a check that only lets the allowed values in. Try a role that is not allowed:$ docker compose exec postgres psql -U goravel -d taskflow \ -c "INSERT INTO users (name, email, password, role) VALUES ('Ana', 'ana@example.com', 'x', 'admin');" ERROR: new row for relation "users" violates check constraint "users_role_check"
Rolling back a migration
When a migration is wrong, you roll it back, fix it, and run it again. migrate:rollback runs Down for the last batch:
$ go run . artisan migrate:rollback
INFO Rolling back migration
INFO Rolled back: 20261022221906_create_users_table
INFO Rolled back: 20210101000002_create_jobs_table
SUCCESS Migration rollback success
Both migrations were undone, not only the users one. A rollback undoes the whole last batch, and both migrations ran in batch 1. migrate:status now shows both as pending:
$ go run . artisan migrate:status
Migration name .............................................. Batch / Status
20210101000002_create_jobs_table ................................... Pending
20261022221906_create_users_table .................................. Pending
To undo only the last migration, add --step=1.
Run migrate again and both are back:
$ go run . artisan migrate
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
SUCCESS Migration success
TaskFlow now has a users table, created from its model. There is no table for tasks yet.
Previous: 03. Reading Request Data
Next: 05. Relationships: Belongs To
Series: Goravel
Top comments (0)