# Models & Migrations

> A users table, generated from the model.

Goravel series, part 4 of 20. By Krishan Kumar. Published 2026-10-23.
Source: https://devchalk.com/goravel/04-models-migrations
Code: https://github.com/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](https://devchalk.com/goravel/01-installation-project-architecture#the-database-design). First we write a model that describes a user. Then Goravel writes a migration from the model, and the migration creates the table.

Start from the Reading Request Data code:

```bash
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:

```bash
$ go run . artisan make:model User
 SUCCESS  Model created successfully
```

This creates `app/models/user.go`:

```go title="app/models/user.go"
package models

import (
	"github.com/goravel/framework/database/orm"
)

type User struct {
	orm.Model
}
```

[`orm.Model`](https://www.goravel.dev/orm/getting-started) 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:

```diff lang="go" title="app/models/user.go" t1:model="orm.Model" t2:email=/(Email) +string/ t3:role=/(Role) +Role +/
 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"`
 }
```

- `Email` is unique, so two users can never share an email.
- `Role` has its own type with two values, manager and member. If no role is given, the database uses member.
- In code you write `models.RoleManager` instead of the string `"manager"`. If you mistype `RoleManager`, the build fails and tells you. If you mistype `"manager"`, it builds fine and the bug shows up later.

The [GORM tags](https://gorm.io/docs/models.html#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:

```go title="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:

```go
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 a `not null` tag.
- Give a decimal its size with `precision` and `scale`. If you write `type:decimal(10,2)` instead, the size is lost.
- `uniqueIndex` works like `unique`, but the index is named `idx_<table>_<column>` instead of `<table>_<column>_unique`.
- `migration:` takes the name of a [schema builder](https://www.goravel.dev/database/migrations) method, so `migration:json` calls `table.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:

```bash
$ 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`:

```diff lang="go" title="bootstrap/app.go" collapse={17-20}
 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).
 		WithRouting(func() {
 			routes.Api()
 			routes.Web()
 		}).
 		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:

```bash
$ 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:

```diff lang="go" title="bootstrap/migrations.go" collapse={1-8}
 package bootstrap
 
 import (
 	"github.com/goravel/framework/contracts/database/schema"
 
 	"taskflow/database/migrations"
 )
 
 func Migrations() []schema.Migration {
 	return []schema.Migration{
 		&migrations.M20210101000001CreateJobsTable{},
+		&migrations.M20261022221906CreateUsersTable{},
 	}
 }
```

This is what Goravel wrote:

```go title="database/migrations/20261022221906_create_users_table.go" t1:model=/"(created_at)"/ t1:model=/"(updated_at)"/ t1:model=/BigIncrements."(id)"/ t2:email=/Unique."(email)"/ t3:role=/Enum."(role)"/
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_at` and `id` come from `orm.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.

> **Code** The code for every step of this part is in the tag [`part-04`](https://github.com/krishankumar01/goravel-taskflow/tree/part-04).

## Run the migration

If Postgres is not running, start it with `docker compose up -d`. Then run the migrations:

```bash
$ 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](https://devchalk.com/goravel/01-installation-project-architecture#create-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`.

To see what has run:

```bash
$ 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:

```bash t1:model=/^ *(created_at) / t1:model=/^ *(updated_at) / t1:model=/^ *(id) autoincrement/ t2:email="users_email_unique" t3:role=/^ *(role) varchar/
$ 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:table` says `role` is `varchar`, 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:
>
> ```bash
> $ 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:

```bash
$ 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:

```bash
$ 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:

```bash
$ go run . artisan migrate
  INFO    Running migration
  INFO    Running: 20210101000002_create_jobs_table
  INFO    Running: 20261022221906_create_users_table
 SUCCESS  Migration success
```

---

Previous: [03. Reading Request Data](https://devchalk.com/goravel/03-reading-request-data.md)
Next: [05. Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to.md)
Series: [Goravel](https://devchalk.com/goravel.md)
