Part 14 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-14.

So far, a task in TaskFlow is only text. A bug report often needs more, like a screenshot of the error or a PDF of the spec. In this part, tasks get attachments: Ben uploads a screenshot to his task, downloads it and deletes it. Each file is saved on a disk, and Postgres keeps a row saying which task it belongs to and who uploaded it.

Start from the Feature Testing code:

git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-13 cp .env.example .env go run . artisan key:generate go run . artisan jwt:secret docker compose up -d go run . artisan migrate:fresh --seed docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test" go install github.com/air-verse/air@latest air 

Where the files go

Goravel saves files through facades.Storage(). It saves them to a disk. A disk is a named place for files, set up in config/filesystems.go. A new app comes with this config:

config/filesystems.go

// ... 10 lines not shown config.Add("filesystems", map[string]any{ // Default Filesystem Disk // ... 4 lines not shown "default": "local", // Filesystem Disks // ... 6 lines not shown "disks": map[string]any{ "local": map[string]any{ "driver": "local", "root": path.Storage("app"), }, "public": map[string]any{ "driver": "local", "root": path.Storage("app/public"), "url": config.Env("APP_URL", "").(string) + "/storage", }, }, }) } 
  • default is the disk facades.Storage() uses when the code doesn't name one. Here it is local. To use another disk, name it: facades.Storage().Disk("public").
  • Each disk has a driver. The driver decides where the files really go. The local driver keeps them in a folder on the server, its root. Both disks here use it: local saves to storage/app, and public to storage/app/public. Other drivers save files somewhere else, like Amazon S3 (see S3 and other cloud disks below).
  • public also has a url, for links to its files. It is meant for files anyone can open, like avatars. But no route serves that folder yet: routes/web.go only serves ./public.

Attachments should only reach the people working on the task. So TaskFlow keeps the local disk. No route serves storage/app, so a file only leaves the server when a handler sends it. And the handler sends it only when the policy allows it.

Make default come from .env, so the tests can use their own disk. Then add that testing disk:

config/filesystems.go

 // ... 14 lines not shown // by the framework. The "local" disk, as well as a variety of cloud // based disks are available to your application. Just store away! - "default": "local", + "default": config.Env("FILESYSTEM_DISK", "local"),   // Filesystem Disks // ... 6 lines not shown "disks": map[string]any{ "local": map[string]any{ "driver": "local", "root": path.Storage("app"), }, + "testing": map[string]any{ + "driver": "local", + "root": path.Storage("framework/testing/disks/local"), + },  "public": map[string]any{ "driver": "local", "root": path.Storage("app/public"), "url": config.Env("APP_URL", "").(string) + "/storage", }, }, }) } 

Add the setting to .env and .env.example:

.env

FILESYSTEM_DISK=local 

S3 and other cloud disks:

TaskFlow stays on the local disk in this series. In production, files usually go to cloud storage, so they are not lost when the server is replaced. Goravel has driver packages for Amazon S3, MinIO, Alibaba Cloud OSS and Tencent COS. For S3:

go run . artisan package:install github.com/goravel/s3 

This adds an s3 disk that reads AWS_ACCESS_KEY_ID, AWS_ACCESS_KEY_SECRET, AWS_REGION, AWS_BUCKET and AWS_URL from .env. It also sets default to "s3", replacing the FILESYSTEM_DISK line.

The handlers in this part only use facades.Storage(), so they work on S3 unchanged. On S3, TemporaryUrl gives the client a link that expires. On the local disk, it just returns Url.

The attachments table

Each file gets a row in Postgres. The row says which task the file belongs to and who uploaded it. It also keeps the file's original name, its place on the disk, its size and its type:

app/models/attachment.go

package models import ( "github.com/goravel/framework/database/orm" ) type Attachment struct { orm.Model TaskID uint `gorm:"not null" json:"task_id"` UploadedBy uint `gorm:"not null" json:"uploaded_by"` Name string `gorm:"size:255;not null" json:"name"` Path string `gorm:"size:255;not null" json:"-"` Size int64 `gorm:"not null" json:"size"` MimeType string `gorm:"size:255;not null" json:"mime_type"` } 

json:"-" keeps Path out of every response, like the password in Register & Login with JWT. Clients don't need to know where a file sits on the disk.

As in Relationships: Belongs To, make:migration -m only reads registered models, so register Attachment next to the others:

bootstrap/app.go

 // ... 34 lines not shown WithCallback(func() { facades.Schema().Extend(schema.Extension{ - Models: []any{models.User{}, models.Task{}}, + Models: []any{models.User{}, models.Task{}, models.Attachment{}},  })   // ... 29 lines not shown 

Then generate the migration from the model:

$ go run . artisan make:migration create_attachments_table -m Attachment 

The columns come from the model, but the foreign keys don't, so add them. CascadeOnDelete on task_id tells Postgres to delete a task's attachment rows when the task is deleted. uploaded_by gets a plain foreign key, like created_by on tasks:

database/migrations/20261026070447_create_attachments_table.go

 // ... 16 lines not shown func (r *M20261026070447CreateAttachmentsTable) Up() error { if !facades.Schema().HasTable("attachments") { return facades.Schema().Create("attachments", func(table schema.Blueprint) { table.TimestampTz("created_at").Nullable() table.TimestampTz("updated_at").Nullable() table.BigIncrements("id") table.UnsignedBigInteger("task_id") table.UnsignedBigInteger("uploaded_by") table.String("name", 255) table.String("path", 255) table.BigInteger("size") table.String("mime_type", 255) + + table.Foreign("task_id").References("id").On("tasks").CascadeOnDelete() + table.Foreign("uploaded_by").References("id").On("users")  }) }   // ... 7 lines not shown 

Run it. db:table shows the two foreign keys. cascade on task_id means deleting a task deletes its rows:

$ go run . artisan migrate $ go run . artisan db:table attachments … Foreign Key .......................................... On Update / On Delete attachments_task_id_foreign task_id references id on tasks no action / cascade attachments_uploaded_by_foreign uploaded_by references id on users no action / no action 

A task has many attachments, so Task gets the other side, as in Relationships: Has Many:

app/models/task.go

 // ... 19 lines not shown Status enums.Status `gorm:"type:enum('pending','in-progress','done');default:'pending';not null" json:"status"` DueAt *carbon.DateTime `json:"due_at"` + Attachments []Attachment `json:"attachments,omitempty"`  }   func (t *Task) Factory() factory.Factory { return &factories.TaskFactory{} } 

omitempty leaves attachments out of the JSON when it is empty. Only the single-task response loads the files, so the task list doesn't send "attachments":null.

Who can attach files

The rules follow the task rules from Gates & Policies. Whoever works on a task can add and download its files. A member can only delete files they uploaded:

manager member
attach a file to a task yes own tasks only
download a file yes own tasks only
delete a file yes files they uploaded

Members still see which files a task has, because they can read every task. Only the task's assignee and managers can open them.

Ana is a manager, and Before already lets managers through. So the policy only decides for members:

app/policies/attachment_policy.go

package policies import ( "context" "taskflow/app/http/authuser" "taskflow/app/models" "github.com/goravel/framework/auth/access" contractsaccess "github.com/goravel/framework/contracts/auth/access" ) type AttachmentPolicy struct { } func NewAttachmentPolicy() *AttachmentPolicy { return &AttachmentPolicy{} } func (r *AttachmentPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response { user := authuser.From(ctx) task := arguments["task"].(*models.Task) if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID { return access.NewDenyResponse("You can only attach files to tasks assigned to you.") } return access.NewAllowResponse() } func (r *AttachmentPolicy) View(ctx context.Context, arguments map[string]any) contractsaccess.Response { user := authuser.From(ctx) task := arguments["task"].(*models.Task) if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID { return access.NewDenyResponse("You can only open files on tasks assigned to you.") } return access.NewAllowResponse() } func (r *AttachmentPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response { user := authuser.From(ctx) attachment := arguments["attachment"].(*models.Attachment) if user == nil || attachment.UploadedBy != user.ID { return access.NewDenyResponse("You can only delete files you uploaded.") } return access.NewAllowResponse() } 

Name the three abilities:

app/policies/abilities.go

 package policies   const ( ViewUsers = "view-users" CreateTask = "create-task" UpdateTask = "update-task" DeleteTask = "delete-task" + + CreateAttachment = "create-attachment" + ViewAttachment = "view-attachment" + DeleteAttachment = "delete-attachment"  ) 

And register them in bootstrap/app.go:

bootstrap/app.go

 // ... 63 lines not shown facades.Gate().Define(policies.UpdateTask, taskPolicy.Update) facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete) + + attachmentPolicy := policies.NewAttachmentPolicy() + facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create) + facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View) + facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)  }). Create() } 

Uploading a file

A file comes in a multipart/form-data request. It is what a browser form with a file input sends. Make a form request and a controller for it:

$ go run . artisan make:request StoreAttachmentRequest $ go run . artisan make:controller AttachmentController 

Then three routes under each task:

routes/api.go

 // ... 13 lines not shown taskController := controllers.NewTaskController() userController := controllers.NewUserController() + attachmentController := controllers.NewAttachmentController()   facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register) // ... 11 lines not shown router.Delete("tasks/{id}", taskController.Destroy)  + router.Post("tasks/{id}/attachments", attachmentController.Store) + router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show) + router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy) +  router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }) } 

The rules for a file

The form request checks the file before the handler saves it:

app/http/requests/store_attachment_request.go

package requests import ( "mime/multipart" "github.com/goravel/framework/contracts/http" ) type StoreAttachmentRequest struct { File *multipart.FileHeader `form:"file" json:"file"` } func (r *StoreAttachmentRequest) Authorize(ctx http.Context) error { return nil } func (r *StoreAttachmentRequest) Rules(ctx http.Context) map[string]any { return map[string]any{ "file": "required|file|max:5120|mimes:png,jpg,pdf,txt", } } 
  • File holds the upload. Its type is Go's *multipart.FileHeader. It has the file's name and size, and can open the file.
  • file passes only when a file came in the file field.
  • max:5120 is in kilobytes when the value is a file, so the limit is 5 MB. For a string, the same rule counts characters.
  • mimes:png,jpg,pdf,txt looks at the first bytes of the file to find its real type. A shell script renamed to photo.png still fails, because its first line shows it is a script. A plain text file passes with any name, because txt is on the list. Store then saves it as .txt. The extensions rule only checks the end of the name, so it would let the script through.

The handler

The handler loads the task, asks the policy, runs the rules, and only then saves the file:

app/http/controllers/attachment_controller.go

package controllers import ( "errors" "fmt" "mime" "os" "taskflow/app/facades" "taskflow/app/http/authuser" "taskflow/app/http/requests" "taskflow/app/models" "taskflow/app/policies" "github.com/goravel/framework/contracts/http" frameworkerrors "github.com/goravel/framework/errors" "github.com/goravel/framework/filesystem" ) type AttachmentController struct { // Dependent services } func NewAttachmentController() *AttachmentController { return &AttachmentController{ // Inject services } } func (r *AttachmentController) Store(ctx http.Context) http.Response { var task models.Task err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id")) if errors.Is(err, frameworkerrors.OrmRecordNotFound) { return ctx.Response().Json(404, map[string]any{ "error": "The task does not exist.", }) } if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not load the task.", }) } response := facades.Gate().WithContext(ctx).Inspect(policies.CreateAttachment, map[string]any{ "task": &task, }) if !response.Allowed() { return ctx.Response().Json(403, map[string]any{ "error": response.Message(), }) } var request requests.StoreAttachmentRequest validationErrors, err := ctx.Request().ValidateRequest(&request) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the file.", }) } if validationErrors != nil { return ctx.Response().Json(422, map[string]any{ "errors": validationErrors.All(), }) } file, err := filesystem.NewFileFromRequest(request.File) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the file.", }) } defer os.Remove(file.File()) size, err := file.Size() if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the file.", }) } mimeType, err := file.MimeType() if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the file.", }) } path, err := file.Store(fmt.Sprintf("attachments/%d", task.ID)) if err != nil { facades.Log().Error(err) return ctx.Response().Json(500, map[string]any{ "error": "Could not save the file.", }) } attachment := models.Attachment{ TaskID: task.ID, UploadedBy: authuser.From(ctx).ID, Name: file.GetClientOriginalName(), Path: path, Size: size, MimeType: mimeType, } if err := facades.Orm().WithContext(ctx).Query().Create(&attachment); err != nil { facades.Log().Error(err) if err := facades.Storage().Delete(path); err != nil { facades.Log().Error(err) } return ctx.Response().Json(500, map[string]any{ "error": "Could not save the file.", }) } return ctx.Response().Json(201, attachment) } // ... 94 lines not shown 
  • request.File is the upload the rules just checked. filesystem.NewFileFromRequest turns it into a Goravel file, which has Size, MimeType and Store.
  • Store saves the file under attachments/<task id>, with a random 40-character name. The extension comes from the file's content. If the type is unknown, it comes from the client's name. The client's name is never used in the path. So two files called login-error.png don't overwrite each other, and a name like ../../.env can't put a file outside the folder.
  • The row saves the client's file name from GetClientOriginalName. The download uses it later.
  • If the row can't be saved, the handler deletes the file, so a failed upload doesn't leave a file behind.

A file belongs to a task. So first, Ana creates a task for Ben. Then Ben uploads files to it. The setup is the same as in Gates & Policies:

  1. Reset the database with migrate:fresh --seed. The seeder adds Ana and three members.
  2. Register Ben. New accounts are members, and Ben gets the next id, 5.
  3. Log in as Ana and as Ben, and save each token in a variable, ANA and BEN.
  4. As Ana, create a task for Ben.
$ go run . artisan migrate:fresh --seed $ curl -X POST http://localhost:3000/api/v1/register \ -H "Content-Type: application/json" \ -d '{"name":"Ben","email":"ben@example.com","password":"secret123"}' {…,"id":5,"name":"Ben",…,"role":"member",…} $ curl -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" \ -d '{"email":"ana@example.com","password":"password"}' {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"} $ ANA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9… $ curl -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" \ -d '{"email":"ben@example.com","password":"secret123"}' {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"} $ BEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9… $ curl -X POST http://localhost:3000/api/v1/tasks Source:https://dev.to/krishankumar01/goravel-file-uploads-and-downloads-with-storage-llb

Related posts