getNodi

Templates / Laravel scheduler

Laravel’s scheduler, without a crontab

Laravel’s scheduler is one cron entry that ticks every minute; the framework decides what is actually due. On a host that gives you no crontab, a token-guarded route and an external caller is the standard substitute.

The recipe

Request
POST https://{{domain}}/scheduler/run
Schedule
Every minute (* * * * *)
Headers
X-Scheduler-Token: {{secret}}encrypted
Timeout
55s
Attempts
3
Counts as success
2xx

You supply

Your domain
example.com

Host only — no https:// and no trailing slash.

Shared secret
s3cr3t-valuestored encrypted

Whatever your endpoint checks the caller against.

Why this job exists

Laravel inverts the usual arrangement. Rather than one crontab line per task, you register everything in the framework and add a single entry that runs schedule:run every minute. Laravel checks its own list on each tick and runs whatever is due. It is a good design — the schedule lives in version control next to the code it runs.

It assumes you have a crontab. On Vercel, on most shared hosting, and inside a container image with no init system, you do not. The scheduler simply never ticks, and every everyMinute, dailyAt and weeklyOn you registered quietly does nothing.

The substitute is a route that calls the scheduler, guarded by a shared secret, invoked once a minute from outside. This is not a workaround so much as the same cron entry with HTTP in the middle.

Setting it up

Add a route that runs the scheduler and is useless to anyone without the token. Compare with hash_equals rather than == so the check does not leak the secret through response timing:

routes/web.php

Route::post('/scheduler/run', function (Request $request) {
    $token = (string) $request->header('X-Scheduler-Token');

    if (! hash_equals((string) config('app.scheduler_token'), $token)) {
        abort(403);
    }

    Artisan::call('schedule:run');

    return response()->json(['output' => Artisan::output()]);
});

Put the token in .env as SCHEDULER_TOKEN and read it through config so it survives config:cache. Store it as a secret header on the job — secret headers are encrypted at rest and never rendered back into the form.

Checking it actually works

  • Run php artisan schedule:list to confirm the framework agrees about what should be running and when.
  • Watch the response body in the run history: schedule:run prints the tasks it dispatched, so a tick with work in it is visibly different from an idle one.
  • Add a temporary ->everyMinute() task that writes to the log, confirm it appears, then remove it.

Where it goes wrong

The tick must be every minute

Laravel’s schedule resolution comes from the tick, not from the task definition. Call it hourly and everyMinute becomes hourly — silently. If your plan floors you above one minute, only tasks coarser than that floor will behave as written.

A slow task holds the request open

schedule:run executes due tasks in-process, so a two-minute report keeps the HTTP request open for two minutes. Mark long tasks ->runInBackground() or dispatch them to a queue; otherwise the request times out and the run is recorded as failed even though the work started.

Overlapping runs

If a tick is still running when the next fires you get two overlapping schedulers. Add ->withoutOverlapping() to the tasks that cannot tolerate it — the framework handles the locking, and it is cheaper than trying to prevent it from the caller.

Questions

Is exposing a scheduler route a security risk?

It runs only tasks you registered, and the token gates it. The real risk is a weak token in a public repository — use a long random value and keep it in the environment, not in config committed to git.

What about queue workers?

Different problem. The scheduler decides what to dispatch; a worker consumes the queue. If you have no place to run a long-lived worker either, the queue drain recipe covers that half.

Does this work with Laravel Vapor or Forge?

Both give you a real scheduler already — Vapor wires it into its own infrastructure and Forge writes the crontab for you. Use this only where neither exists.

Running it here

This is a preset in the product, not an illustration. Pick it in the dashboard, fill in the 2 values above, and the job is created, scheduled and enabled — with retries, a record of every run showing status, latency and response, and an alert the first time one fails. Secret values are encrypted at rest and never rendered back.

The free plan runs 3 jobs hourly and needs no card. Where a recipe wants a finer cadence than your plan allows, it is slowed to the fastest schedule you are permitted rather than rejected.