Laravel's scheduler without a cron server

How Brewless runs Laravel's task scheduler on serverless containers. The schedule is read on deploy, and the worker is called only at the moments a task is due.

In this post
  1. How does Laravel's scheduler run without cron?
  2. Your schedule stays as it is
  3. Two ways the worker is called
  4. Time zones and what a cron cannot copy
  5. What everyMinute costs you
  6. withoutOverlapping and onOneServer
  7. Turning the scheduler off

Laravel's scheduler asks for one thing from the server: a cron entry that runs php artisan schedule:run every minute. On serverless containers there is no server to put that line on, and no process that stays running to call it.

Brewless calls the scheduler from outside instead. With the right setup it does better than every minute: it calls it only when a task is actually due. This post explains how that works, and what it means for the tasks you write.

How does Laravel's scheduler run without cron?

Crons at Scaleway call the worker container in your own project, and the brewless/laravel package runs one tick of the scheduler in that request, as schedule:run would. With jobs on Scaleway Queues, Brewless reads your schedule on every deploy and creates a cron only for the moments a task is due. Otherwise a trigger calls the worker every minute.

Your schedule stays as it is

Nothing in your application changes. You define tasks where you always did:

routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('invoices:send')->dailyAt('02:10');
Schedule::command('reports:refresh')->everyFifteenMinutes();
Schedule::job(new PruneExports)->hourly();

What changes is who calls schedule:run. Every environment has a worker container, next to the web container, that works both the queue and the schedule. A Scaleway cron posts to it, the package runs one scheduler tick, and the container can scale back to zero afterwards.

Two ways the worker is called

Which one your environment gets depends on where its jobs wait:

Jobs wait in The scheduler is called The queue is worked
Scaleway Queues At the moments your schedule has work As soon as a job is queued
The database Every minute, with the queue Every minute, with the scheduler

With Scaleway Queues: only when a task is due

When jobs wait in a queue at Scaleway Queues and the application runs brewless/laravel 0.4 or later, the queue calls the worker for jobs. The scheduler then needs a call only when a task is due.

On every deploy, Brewless asks the worker container for the moments of your schedule. The package boots the console, reads every scheduled event, and answers with its cron expression and its time zone. Brewless turns those into crons at Scaleway, one per distinct moment. For the schedule above:

Task Laravel expression Cron at Scaleway
invoices:send daily at 02:10 10 2 * * * 10 2 * * *
reports:refresh every fifteen minutes */15 * * * * */15 * * * *
PruneExports hourly 0 * * * * 0 * * * *

Nothing calls the worker in between. The worker sleeps, and a serverless database it talks to can sleep too.

The Schedule tab of the environment in the console lists these crons. A task you add to your schedule gets its moment with the next deploy, not before.

With the database queue: every minute

When jobs wait in the database, the worker has to look for them, so a trigger calls it every minute. Each call runs one scheduler tick and then works the queue. That is the classic schedule:run every minute, and it keeps the database awake all day.

Time zones and what a cron cannot copy

Scaleway's crons run in UTC and take five fields. Brewless copies a task's moment into a cron only when it can do so exactly: the task runs in UTC and its expression is plain numbers, *, /, , and -.

When any task in the schedule cannot be copied, the scheduler is called every minute instead. That never misses a task, because Laravel's own schedule:run decides on each tick what is due, but it gives up the sleeping.

The common case is the time zone. A task that names none gets app.schedule_timezone, or app.timezone when that is not set:

config/app.php
'timezone' => 'Europe/Amsterdam',

With that line and no schedule_timezone, no task is in UTC, and the whole schedule falls back to every minute. If you want the worker to sleep, set 'schedule_timezone' => 'UTC' in config/app.php and write the times in UTC, or give the few tasks that must follow local time their own ->timezone('Europe/Amsterdam') and accept that the scheduler is then called every minute.

Tip

Brewless reads the time zone each task reports. A task that is in UTC only because you wrote ->timezone('UTC') on it counts as UTC.

What everyMinute costs you

On a server, a task that runs every minute is free: the server runs anyway. On serverless containers, it is not:

routes/console.php
Schedule::command('sync:stock')->everyMinute();

That task gets a cron of * * * * *. The worker container starts every minute, the task opens a database connection every minute, and a serverless database never gets to sleep. You pay for a worker and a database that run all day, for a task that may find nothing to do most of the time.

Before you keep an everyMinute task, ask what it waits for:

  • It reacts to something your app does. Dispatch a job at that moment instead. With Scaleway Queues, the job reaches the worker straight away; see Laravel queues without a long-running worker.
  • It polls an outside service. Check whether that service can call a webhook instead, or whether every five or fifteen minutes is enough.
  • It really has to run every minute. Then keep it, and know the environment does not sleep.

withoutOverlapping and onOneServer

Both work as in Laravel: they take a lock in your application's default cache before a task runs.

routes/console.php
Schedule::command('reports:refresh')
    ->everyFifteenMinutes()
    ->withoutOverlapping(30)
    ->onOneServer();

Two things matter on serverless containers:

  • The cache has to be shared. Containers do not share a disk, so the file cache gives each container its own locks. Use the database or Redis as the default cache, as you should for sessions anyway.
  • Give withoutOverlapping an expiry. Laravel's default lock lasts 24 hours. If a task is cut off before it releases its lock, for example because it ran past the worker's timeout, the next runs are skipped until the lock expires. A number in minutes, a little above how long the task can take, keeps that short.

A task runs inside one call to the worker container, so it gets the worker's timeout from brewless.yml, 120 seconds by default. Longer work belongs in a queued job that the task dispatches, or give the worker more time:

brewless.yml
environments:
  production:
    worker:
      timeout: 300

Turning the scheduler off

Some environments should not run the schedule, such as a staging copy that must not send the nightly invoices:

brewless.yml
environments:
  staging:
    scheduler: false

From the next deploy on, the schedule is left out and the queue is still worked. scheduler: true puts it back; leaving the key out keeps what stands. A rollback takes along what brewless.yml asked of the release it goes back to, the scheduler included.

The full reference is Queues and schedule, and every key of the file is on brewless.yml.

Frequently asked questions

Do I need a cron entry for schedule:run on Brewless?

No. Brewless creates the crons at Scaleway that call the worker container, and the brewless/laravel package runs one tick of the scheduler when it is called. The schedule you define in routes/console.php stays as it is.

How does Brewless know when my scheduled tasks run?

On every deploy it asks the worker container for the moments of your schedule, as cron expressions with their time zone. With jobs on Scaleway Queues and brewless/laravel 0.4 or later, it creates one cron per distinct moment, so a task at 02:10 gets a cron at 02:10.

Why is my scheduler called every minute although no task runs every minute?

Either your jobs wait in the database, in which case a trigger calls the worker every minute to look for them, or a task cannot be copied into a UTC cron. A task in a time zone other than UTC, including the one in app.timezone, has the scheduler called every minute instead.

Do withoutOverlapping and onOneServer work on Brewless?

They work as in Laravel, through locks in your application's default cache. Use a cache every container shares, such as the database or Redis, not the file cache, which each container keeps on its own disk.

How do I turn the scheduler off for one environment?

Write scheduler false under that environment in brewless.yml and deploy. The schedule is left out from that deploy on, and the queue is still worked. scheduler true puts it back; leaving the key out keeps what stands.

All posts Read this post as markdown

Try it on your own app

Brewless deploys your Laravel app to Scaleway and bunny.net, in your own accounts. We open access to Laravel teams in small batches.