DevChalk

Scheduling & Commands

Krishan Kumar · · 9 min See the changes ↗

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.

CodeStart from the Mail & Templates code
Terminal window
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:

Terminal window
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
}
  • Category puts the command under tasks: in go run . artisan list.
  • Extend declares a --hours flag. IntFlag makes it a number, and Value sets its default. Usage is the help text that --help prints. ctx.OptionInt("hours") reads it in Handle.
  • The query finds open tasks with an assignee and a due date in the next hours hours. It calls carbon.Now() twice, because AddHours changes 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().Send sends 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 Handle returns an error, the command exits with status 1 from the terminal, and the scheduler writes the error to the log.
  • ctx.Table prints 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 collapsed lines
package mails
import (
"github.com/goravel/framework/contracts/mail"
"taskflow/app/models"
)
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 collapsed lines
func (receiver *DueSoon) Headers() map[string]string {
return map[string]string{}
}
func (receiver *DueSoon) Attachments() []string {
return []string{}
}
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/schedule"
"github.com/goravel/framework/foundation"
13 collapsed lines
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
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 collapsed lines
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
return map[contractsevent.Event][]contractsevent.Listener{
&events.TaskAssigned{}: {&listeners.NotifyAssignee{}, &listeners.EmailAssignee{}},
}
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
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()
}
  • DailyAt("09:00") runs the reminder at 09:00 every day.
  • The times follow timezone in config/app.go, which is UTC in TaskFlow.
  • The schedule has methods for other times, like EveryFiveMinutes(), Hourly(), Weekly(), or Cron("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:

Terminal window
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:

Terminal window
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 collapsed lines
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/models"
)
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 a done export whose download fails with a 500.
  • Exists comes before Delete, because Delete returns an error for a file that’s already gone.
  • The second query deletes pending exports 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:

Terminal window
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:

Terminal window
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 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsevent "github.com/goravel/framework/contracts/event"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/schedule"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/events"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/listeners"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithCommands(Commands).
WithSchedule(func() []schedule.Event {
return []schedule.Event{
facades.Schedule().Command("tasks:remind").DailyAt("09:00"),
facades.Schedule().Command("exports:prune").Daily(),
}
}).
54 collapsed lines
WithJobs(Jobs).
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
return map[contractsevent.Event][]contractsevent.Listener{
&events.TaskAssigned{}: {&listeners.NotifyAssignee{}, &listeners.EmailAssignee{}},
}
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
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()
}
MoreRunning 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;
  • --hours changes 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:

Terminal window
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.

The code for this partgit checkout part-18 See the changesBrowse the code ↗Download zip
Get new articles by email One email when a new part or article comes out.