Part 18 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-18.
So far, everything in TaskFlow has started with a request: a client calls the API, and Goravel handles it. But some work has no request to start it. For example, nothing reminds assignees when a task is close to its due date. And export files pile up because nothing deletes them, while an export whose job fails all its tries stays pending forever. For work like this, we write a command and let the scheduler run it at a set time.
Start from the Mail & Templates code:
git clone https://github.com/krishankumar01/goravel-taskflow cd goravel-taskflow git checkout part-17 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 A command for reminders
An Artisan command is something we run with go run . artisan, like make:migration or package:install. The scheduler runs these too, so we are going to write the reminder as a command of our own, and make:command creates one:
$ go run . artisan make:command RemindDueTasks SUCCESS Console command created successfully SUCCESS Console command registered successfully It writes a placeholder command to app/console/commands and registers it in a new bootstrap/commands.go, so Artisan can find it. It also adds WithCommands(Commands) to bootstrap/app.go, which hands that list to the app:
bootstrap/commands.go
package bootstrap import ( "github.com/goravel/framework/contracts/console" "taskflow/app/console/commands" ) func Commands() []console.Command { return []console.Command{ &commands.RemindDueTasks{}, } } By default, make:command turns the name we gave it into a signature, app:remind-due-tasks, which is what we type after artisan. We give it a shorter one, tasks:remind, and fill in the empty Handle, which sends a Mailable we write next:
app/console/commands/remind_due_tasks.go
package commands import ( + "fmt" + "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/support/carbon" + + "taskflow/app/enums" + "taskflow/app/facades" + "taskflow/app/mails" + "taskflow/app/models" ) type RemindDueTasks struct { } -// Signature The name and signature of the console command. func (r *RemindDueTasks) Signature() string { - return "app:remind-due-tasks" + return "tasks:remind" } -// Description The console command description. func (r *RemindDueTasks) Description() string { - return "Command description" + return "Email each assignee their open tasks that are due soon" } -// Extend The console command extend. func (r *RemindDueTasks) Extend() command.Extend { - return command.Extend{Category: "app"} + return command.Extend{ + Category: "tasks", + Flags: []command.Flag{ + &command.IntFlag{ + Name: "hours", + Value: 24, + Usage: "remind about tasks due within this many hours", + }, + }, + } } -// Handle Execute the console command. func (r *RemindDueTasks) Handle(ctx console.Context) error { - + hours := ctx.OptionInt("hours") + + var tasks []models.Task + err := facades.Orm().Query().With("Assignee"). + Where("assigned_to IS NOT NULL"). + Where("status <> ?", enums.StatusDone). + Where("due_at BETWEEN ? AND ?", carbon.Now().StdTime(), carbon.Now().AddHours(hours).StdTime()). + OrderBy("due_at"). + Find(&tasks) + if err != nil { + return err + } + if len(tasks) == 0 { + ctx.Info("No tasks are due soon.") + return nil + } + + var assignees []uint + due := map[uint][]models.Task{} + for _, task := range tasks { + id := *task.AssignedTo + if _, ok := due[id]; !ok { + assignees = append(assignees, id) + } + due[id] = append(due[id], task) + } + + var rows [][]string + for _, id := range assignees { + assignee := *due[id][0].Assignee + if err := facades.Mail().Send(mails.NewDueSoon(assignee, due[id])); err != nil { + return err + } + rows = append(rows, []string{assignee.Email, fmt.Sprint(len(due[id]))}) + } + + ctx.Table([]string{"Assignee", "Tasks"}, rows) + return nil } -
Categoryputs the command undertasks:ingo run . artisan list. -
Extenddeclares a--hoursflag.IntFlagmakes it a number, andValuesets its default.Usageis the help text that--helpprints.ctx.OptionInt("hours")reads it inHandle. - The query finds open tasks with an assignee and a due date in the next
hourshours. It callscarbon.Now()twice, becauseAddHourschanges the date it's called on instead of returning a new one. - The loop groups the tasks by assignee, so each person gets one email that lists all their tasks.
-
Mail().Sendsends the email right away. The command runs outside any request, so nothing waits on it and there's no reason to queue the email. - When
Handlereturns an error, the command exits with status 1 from the terminal, and the scheduler writes the error to the log. -
ctx.Tableprints who got an email and how many tasks it listed. The console context also has prompts, progress bars and spinners, listed under writing output in the Goravel docs.
The email is a Mailable like the ones from Mail & Templates, with views that extend the same layout:
app/mails/due_soon.go
// ... 8 lines not shown type DueSoon struct { assignee models.User tasks []models.Task } func NewDueSoon(assignee models.User, tasks []models.Task) *DueSoon { return &DueSoon{assignee: assignee, tasks: tasks} } // ... 8 lines not shown func (receiver *DueSoon) Content() *mail.Content { return &mail.Content{ HtmlView: "due_soon.html", TextView: "due_soon.txt", With: map[string]any{ "Name": receiver.assignee.Name, "Tasks": receiver.tasks, }, } } func (receiver *DueSoon) Envelope() *mail.Envelope { return &mail.Envelope{ To: []string{receiver.assignee.Email}, Subject: "Your tasks due soon", } } func (receiver *DueSoon) Queue() *mail.Queue { return &mail.Queue{} } resources/mail/due_soon.html
{% extends "layout.html" %} {% block content %} <p>Hi {{ Name }},</p> <p>These tasks are due soon:</p> <ul> {% for task in Tasks %}<li><strong>{{ task.Title }}</strong>, due {{ task.DueAt.ToDateTimeString }}</li> {% endfor %}</ul> {% endblock %} resources/mail/due_soon.txt
{% autoescape off %}Hi {{ Name }}, These tasks are due soon: {% for task in Tasks %} - {{ task.Title }}, due {{ task.DueAt.ToDateTimeString }}{% endfor %}{% endautoescape %} To try it, we start from the same setup as Mail & Templates, with Ana's token in ANA and Ben as user 5. Then we give Ben a task due in three hours, one due tomorrow and one due in three days. due_at has to be in the future, so pick UTC times of your own around those:
$ curl -X POST http://localhost:3000/api/v1/tasks \ -H "Authorization: Bearer $ANA" \ -H "Content-Type: application/json" \ -d '{"title":"Fix the login page","description":"Due soon.","assigned_to":5,"due_at":"2026-10-28 22:00:00"}' {…,"id":8,…,"due_at":"2026-10-28 22:00:00"} The other two are the same request with "Write the release notes", due tomorrow, and "Plan the next sprint", due in three days. Only the first two are due in the next 24 hours. Seeded tasks get random due dates, so the table can list other members too:
$ go run . artisan tasks:remind ╭─────────────────┬───────╮ │ Assignee │ Tasks │ ├─────────────────┼───────┤ │ ben@example.com │ 2 │ │ dmason@hart.org │ 1 │ ╰─────────────────┴───────╯ Ben's email in Mailpit lists his two tasks:
Hi Ben, These tasks are due soon: - Fix the login page, due 2026-10-28 22:00:00 - Write the release notes, due 2026-10-29 15:00:00 With --hours 4, only the task due in three hours is left:
$ go run . artisan tasks:remind --hours 4 ╭─────────────────┬───────╮ │ Assignee │ Tasks │ ├─────────────────┼───────┤ │ ben@example.com │ 1 │ ╰─────────────────┴───────╯ The schedule
We don't want to run the reminder by hand every morning, so we let Goravel's scheduler run it. To use Goravel's scheduler, let's install its package first, the same way as mail:
$ go run . artisan package:install Schedule INFO Schedule depends on Log, Cache, they will be installed simultaneously SUCCESS Facade Schedule installed successfully package:install adds the facades.Schedule() facade and the schedule service provider. The schedule itself goes in WithSchedule in bootstrap/app.go, as a list of events. Each event names a command and says when it runs:
bootstrap/app.go
// ... 11 lines not shown "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/foundation" // ... 13 lines not shown func Boot() contractsfoundation.Application { return foundation.Setup(). WithCommands(Commands). + WithSchedule(func() []schedule.Event { + return []schedule.Event{ + facades.Schedule().Command("tasks:remind").DailyAt("09:00"), + } + }). WithJobs(Jobs). WithRules(Rules). WithSeeders(Seeders). WithMigrations(Migrations). // ... 48 lines not shown Create() } -
DailyAt("09:00")runs the reminder at 09:00 every day. - The times follow
timezoneinconfig/app.go, which is UTC in TaskFlow. - The schedule has methods for other times, like
EveryFiveMinutes(),Hourly(),Weekly(), orCron("0 9 * * 1-5")for an expression of our own. The Goravel docs list them all.
schedule:list shows each event as a cron expression, with the time left until its next run:
$ go run . artisan schedule:list 00 09 * * * artisan tasks:remind ................. Next Due: 13 hours after 00 09 * * * means minute 0 of hour 9, on every day of every month.
The scheduler starts with the app, next to the HTTP server and the queue worker, so go run . or air runs it with nothing else to start. To watch it run without waiting for 09:00, we change DailyAt("09:00") to EveryMinute() and restart the app. At the next full minute, the terminal running the app shows the scheduler waking up, then the command's table. The SUCCESS lines print because APP_DEBUG is true:
SUCCESS wake [now 2026-10-28 19:22:00.000082959 +0000 UTC] SUCCESS run [now 2026-10-28 19:22:00.000082959 +0000 UTC entry 1 next 2026-10-28 19:23:00 +0000 UTC] ╭─────────────────┬───────╮ │ Assignee │ Tasks │ ├─────────────────┼───────┤ │ ben@example.com │ 2 │ │ dmason@hart.org │ 1 │ ╰─────────────────┴───────╯ It runs again every minute after that, and each run sends Ben the same email, which is why the real schedule runs it once a day. After the test, change it back to DailyAt("09:00").
Goravel also has go run . artisan schedule:run, which starts a scheduler on its own in the terminal. We don't need it, because the app already starts one. Running both at once gives two schedulers, so each command runs twice and Ben gets every email twice.
Cleaning up exports
The export job from Queues & Jobs writes a file for every export, but nothing deletes the files or their rows. And when the job fails all three tries, the worker moves it to failed_jobs without touching the export, so the export stays pending and its download answers 409 forever.
To see an export get stuck, we replace storage/app/exports with a plain file, so the job can't write there, and then ask for an export:
$ mkdir -p storage/app && rm -rf storage/app/exports && touch storage/app/exports $ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":1,"user_id":1,"status":"pending"} $ sleep 13 $ curl http://localhost:3000/api/v1/exports/1/download -H "Authorization: Bearer $ANA" {"error":"The export is not ready yet."} In the terminal running the app, the job gave up ten seconds after it started, and export 1 stayed pending:
2026-10-28 19:22:58.237 export_tasks ............................... RUNNING 2026-10-28 19:23:08.253 export_tasks .......................... 3.526ms FAIL So we are going to write a second command, exports:prune, that cleans up both. make:command PruneExports creates it the same way as the reminder and adds it to bootstrap/commands.go:
app/console/commands/prune_exports.go
// ... 14 lines not shown type PruneExports struct { } func (r *PruneExports) Signature() string { return "exports:prune" } func (r *PruneExports) Description() string { return "Delete old exports and exports stuck in pending" } func (r *PruneExports) Extend() command.Extend { return command.Extend{ Category: "exports", Flags: []command.Flag{ &command.IntFlag{ Name: "days", Value: 7, Usage: "delete exports older than this many days", }, }, } } func (r *PruneExports) Handle(ctx console.Context) error { var old []models.Export err := facades.Orm().Query(). Where("created_at < ?", carbon.Now().SubDays(ctx.OptionInt("days")).StdTime()). Find(&old) if err != nil { return err } for _, export := range old { if export.Path != "" && facades.Storage().Exists(export.Path) { if err := facades.Storage().Delete(export.Path); err != nil { return err } } if _, err := facades.Orm().Query().Delete(&export); err != nil { return err } } stuck, err := facades.Orm().Query(). Where("status", enums.ExportPending). Where("created_at < ?", carbon.Now().SubHour().StdTime()). Delete(&models.Export{}) if err != nil { return err } ctx.Info(fmt.Sprintf("Deleted %d old exports and %d stuck in pending.", len(old), stuck.RowsAffected)) return nil } - The first query finds exports older than
--days, 7 by default. For each one, it deletes the file and then the row. Deleting only the file would leave adoneexport whose download fails with a 500. -
Existscomes beforeDelete, becauseDeletereturns an error for a file that's already gone. - The second query deletes
pendingexports older than an hour. The job's three tries take about ten seconds, so an export still pending after an hour is stuck.
To try it, we turn storage/app/exports back into a folder, so a second export can finish and write its file. Then SQL moves export 1 back two hours and export 2 back eight days:
$ rm storage/app/exports $ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA" {…,"id":2,"user_id":1,"status":"pending"} $ docker compose exec postgres psql -U goravel taskflow -c "update exports set created_at = now() - interval '2 hours' where id = 1; update exports set created_at = now() - interval '8 days' where id = 2" $ docker compose exec postgres psql -U goravel taskflow -c "select id, status, path, created_at from exports order by id" id | status | path | created_at ----+---------+---------------+------------------------ 1 | pending | | 2026-10-28 17:23:13+00 2 | done | exports/2.csv | 2026-10-20 19:23:13+00 The prune deletes the stuck export and the old one, file included:
$ go run . artisan exports:prune INFO Deleted 1 old exports and 1 stuck in pending. $ docker compose exec postgres psql -U goravel taskflow -c "select id, status, path, created_at from exports order by id" id | status | path | created_at ----+--------+------+------------ (0 rows) $ ls storage/app/exports To run the prune every night, we add it to the schedule with Daily(), which runs it at midnight:
bootstrap/app.go
// ... 31 lines not shown return []schedule.Event{ facades.Schedule().Command("tasks:remind").DailyAt("09:00"), + facades.Schedule().Command("exports:prune").Daily(), } }). // ... 54 lines not shown Running on more than one server:
Every copy of the app runs its own scheduler. TaskFlow runs as one copy, but with two, each command would run twice. OnOneServer stops that:
facades.Schedule().Command("tasks:remind").DailyAt("09:00").OnOneServer() Before an event runs, the scheduler takes a lock named after the command and the hour and minute it runs at, in the default cache store. Only the copy that gets the lock runs the command. TaskFlow's cache store is Redis, which every copy shares, so the lock works across them.
Tests
The tests in tests/feature/schedule_test.go fix the time with carbon.SetTestNow, then call each command with facades.Artisan().Call. They check that:
- the reminder lists only open tasks due within 24 hours;
-
--hourschanges that window; - the prune deletes old and stuck exports, and keeps recent and in-progress ones;
- the schedule has both cron expressions.
The reminder tests send real email, so Mailpit has to be running, like Postgres and Redis:
$ go test ./tests/... --env=.env.testing ? taskflow/tests [no test files] ok taskflow/tests/feature 1.901s ok taskflow/tests/unit 0.360s TaskFlow now emails each assignee their tasks due in the next day, every morning at 09:00. Every night it also deletes old exports and the ones stuck in pending.
Previous: 17. Mail & Templates
Next: 19. Docker & Deploy
Series: Goravel
