# 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 short

- On Brewless there is no crontab line running schedule:run every minute; Scaleway crons call the worker container instead.
- With jobs on Scaleway Queues, Brewless reads the schedule from your application on every deploy and creates a cron only for the moments a task is due.
- Scaleway's crons run in UTC, so a task in another time zone, or one a five-field cron cannot copy, has the scheduler called every minute, which never misses a task.
- A task that runs every minute wakes the worker, and a serverless database, every minute, so it decides whether your environment can ever sleep.
- scheduler false in brewless.yml leaves the schedule out from the next deploy on, while the queue is still worked.

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:

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

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

```php 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](/blog/laravel-queues-without-a-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.

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

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

```yaml 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](https://docs.brewless.eu/deployments/rollbacks) takes along what `brewless.yml` asked of the release it goes back to, the scheduler included.

The full reference is [Queues and schedule](https://docs.brewless.eu/running/queues-and-schedule), and every key of the file is on [brewless.yml](https://docs.brewless.eu/reference/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.

Source: https://test.brewless.eu/blog/laravel-scheduler-without-cron
