# Scheduling & Commands

> Daily reminders for tasks due soon and a cleanup of old exports.

Goravel series, part 18 of 19. By Krishan Kumar. Published 2026-10-30.
Source: https://devchalk.com/goravel/18-scheduling-commands
Code: https://github.com/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:

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

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

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

```diff lang="go" title="app/console/commands/remind_due_tasks.go" t1:sig=/"(tasks:remind)"/ t2:hours=/Name:[ ]+"(hours)"/ t2:hours=/OptionInt[(]("hours")[)]/
 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](https://www.goravel.dev/digging-deeper/artisan-console.html#writing-output) in the Goravel docs.

The email is a Mailable like the ones from [Mail & Templates](https://devchalk.com/goravel/17-mail-templates#a-mailable), with views that extend the same layout:

```go title="app/mails/due_soon.go" collapse={1-8} collapse={18-25}
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}
}

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{}
}
```

```html title="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 %}
```

```text title="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](https://devchalk.com/goravel/17-mail-templates#the-first-email), 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:

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

```bash
$ go run . artisan tasks:remind
╭─────────────────┬───────╮
│ Assignee        │ Tasks │
├─────────────────┼───────┤
│ ben@example.com │ 2     │
│ dmason@hart.org │ 1     │
╰─────────────────┴───────╯
```

Ben's email in Mailpit lists his two tasks:

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

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

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

```diff lang="go" title="bootstrap/app.go" collapse={1-11} collapse={15-27} collapse={40-87} t1:sig=/Command[(]"(tasks:remind)"[)]/ t3:cron=/(DailyAt[(]"09:00"[)])/
 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"),
+			}
+		}).
 		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()
 }
```

- `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](https://www.goravel.dev/digging-deeper/task-scheduling.html#schedule-frequency-options) list them all.

`schedule:list` shows each event as a cron expression, with the time left until its next run:

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

```text
 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](https://devchalk.com/goravel/16-queues-jobs-task-export#the-job) 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:

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

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

```go title="app/console/commands/prune_exports.go" collapse={1-14} t2:days=/Name:[ ]+"(days)"/ t2:days=/OptionInt[(]("days")[)]/
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:

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

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

```diff lang="go" title="bootstrap/app.go" collapse={1-31} collapse={37-90}
 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(),
 			}
 		}).
 		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()
 }
```

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:

```go
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`](https://github.com/krishankumar01/goravel-taskflow/blob/part-18/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:

```bash
$ 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](https://devchalk.com/goravel/17-mail-templates.md)
Next: [19. Docker & Deploy](https://devchalk.com/goravel/19-docker-deploy.md)
Series: [Goravel](https://devchalk.com/goravel.md)
