How to run Laravel's task scheduler without crontab

Laravel's task scheduler replaced a dozen crontab entries with one line of PHP. But that one * * * * * entry still assumes you have SSH access and a running cron daemon. Here's how to trigger schedule:run when you don't.

Start Free 20 checks free forever

If you've used it, you know how it feels: define your tasks in PHP, add one cron entry for schedule:run, and never think about crontab syntax again. But that single cron entry is the foundation everything else stands on. Without it, none of your scheduled tasks run.

On a VPS where you can SSH in and type crontab -e, that's a non-issue. But plenty of Laravel apps live on shared hosting where there's no terminal, inside Docker containers where cron isn't installed, or on platforms like Railway and Render that don't give you a crontab at all. We've deployed Laravel apps in all of these environments, and each one needed its own workaround.

What schedule:run actually does

Before looking at solutions, it helps to understand what php artisan schedule:run actually does. When you call it, Laravel looks at every task you've defined in routes/console.php (or app/Console/Kernel.php if you're on an older Laravel version). It checks each task's cron expression against the current time. If a task is due right now, Laravel runs it. If nothing is due, the command finishes in a fraction of a second.

Here's the important part: schedule:run needs to be called every minute. That's because Laravel only checks "is this task due right now?" at the moment the command runs. If you call it every five minutes instead, any task that was supposed to fire during those four skipped minutes just never happens. No error, no warning. It silently gets missed.

The standard crontab entry looks like this:

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

Every minute, cron fires the command, Laravel checks the schedule, runs what's due, exits. Simple. But what if you can't add this entry?

Expose schedule:run through an HTTP endpoint

The idea is straightforward: create a URL in your Laravel app that runs schedule:run when someone (or something) visits it. Then you just need any service that can hit that URL every minute. That service becomes your cron replacement.

// routes/web.php
use Illuminate\Support\Facades\Artisan;

Route::get('/cron/run-scheduler', function () {
    if (request()->header('X-Cron-Token') !== config('app.cron_token')) {
        abort(403);
    }

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

    return response('OK', 200);
})->name('cron.scheduler');

Add the token to your .env:

CRON_TOKEN=a-long-random-string-here

And reference it in config/app.php:

'cron_token' => env('CRON_TOKEN'),

That's the whole thing. Any service that sends a request to https://yourapp.com/cron/run-scheduler with the right X-Cron-Token header will trigger your scheduler. The token check is there so random visitors can't stumble onto this URL and fire all your tasks. Don't skip it.

Security considerations for HTTP endpoints

Token in URL parameters

Never use a GET parameter for the token — it leaks in server logs and browser history. Use a custom header or POST body.

No rate limiting

Set rate limiting on the route. Even with token auth, a misconfigured external service could hammer it.

CSRF middleware conflict

Exclude the route from CSRF middleware (already in web.php by default, so add it to the except array in VerifyCsrfToken) or move it to routes/api.php.

A sturdier version with logging

In production, you probably want to know when the scheduler was last triggered and whether it ran any tasks:

Route::match(['get', 'post'], '/cron/run-scheduler', function () {
    if (request()->header('X-Cron-Token') !== config('app.cron_token')) {
        abort(403);
    }

    $output = new \Symfony\Component\Console\Output\BufferedOutput;
    Artisan::call('schedule:run', [], $output);

    $result = $output->fetch();

    Log::channel('scheduler')->info('Scheduler triggered via HTTP', [
        'output' => trim($result),
        'ip' => request()->ip(),
    ]);

    return response($result, 200)
        ->header('Content-Type', 'text/plain');
});

This version saves everything schedule:run prints (which tasks ran, which were skipped) into a log file. It also records the IP address of whoever called it, which can help if something goes wrong later and you need to figure out what happened.

Trigger it with an external cron service

So now you have a URL that triggers your scheduler. But something needs to visit that URL every minute, automatically. That's what external cron services do: you give them a URL, tell them how often to call it (using a cron expression or a simple dropdown), add any headers you need, and they handle the rest.

Setup takes about two minutes:

  1. Create a new job in the service with URL https://yourapp.com/cron/run-scheduler
  2. Set the schedule to * * * * * (every minute)
  3. Add a custom header: X-Cron-Token: your-secret-token
  4. Set the expected response to HTTP 200

Every minute, the service sends a request to your app, which triggers schedule:run, which runs whatever tasks are due. If your app is down or returns an error, most services log the failure and can send you an alert. That's basic monitoring thrown in for free.

The nice thing about this approach: it works everywhere. Shared hosting, Docker, Heroku, Railway, Render, even a Raspberry Pi running Nginx. If your app has a public URL, it can be scheduled this way.

Docker: schedule:work replaces cron

Docker containers don't come with cron installed. You could add it, but it goes against the Docker philosophy of running one process per container. Laravel has a cleaner answer.

Since Laravel 8, there's a command called schedule:work. It does the same thing cron does (checks the schedule every minute, fires tasks that are due), but it runs as a regular PHP process instead of relying on the system's cron daemon:

php artisan schedule:work

You start it, and it just sits there, checking the schedule every minute. In a Docker setup, the common pattern is to run it as a separate container next to your web server. Same code, same image, just a different startup command:

# docker-compose.yml
services:
  app:
    build: .
    command: php artisan serve --host=0.0.0.0 --port=8000
    # ...

  scheduler:
    build: .
    command: php artisan schedule:work
    restart: unless-stopped
    depends_on:
      - app

The scheduler container does nothing but run schedule:work. If it crashes, Docker automatically restarts it thanks to the restart: unless-stopped setting.

One thing worth knowing: because schedule:work keeps the PHP process alive forever, memory usage can creep up over time. If your app has any memory leaks (and many do without realizing it), the process slowly grows until it gets killed. We ran into this on a project where Eloquent observers kept holding references they should have released. The fix is simple: set a memory limit so Docker restarts the container when it gets too heavy:

  scheduler:
    build: .
    command: php artisan schedule:work
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M

If you're on Kubernetes, the idea is the same: either run schedule:work in a separate Deployment, or set up a Kubernetes CronJob that calls schedule:run every minute. Both work fine. We go deeper into Docker-specific patterns in the Docker cron guide.

Managed platforms handle it for you

If you use Laravel Forge, Ploi, or Laravel Cloud to manage your servers, good news: the scheduler is set up automatically when you deploy. Forge adds the * * * * * crontab entry as part of its server setup process. Ploi does the same. You don't need to do anything.

Just double-check it's still there after server migrations or OS upgrades. Cron entries can quietly disappear. In Forge, look under Server \u2192 Scheduler. In Ploi, check Server \u2192 Cron Jobs. Took us an embarrassing two days to figure out that a Forge server rebuild had wiped the scheduler entry. Everything looked fine until a client asked why their weekly reports stopped showing up.

Laravel Vapor works differently because it's serverless (runs on AWS Lambda). There's no server sitting there between requests, so there's no crontab to configure. Instead, Vapor uses AWS CloudWatch Events to automatically call schedule:run every minute via a Lambda function. You don't set this up yourself: it just works when you deploy.

One catch with Vapor: AWS Lambda kills any process that runs longer than 15 minutes. If you have a scheduled task that takes longer (a big data import, a heavy report), don't run it directly in the scheduler. Dispatch it to an SQS queue instead, so it runs as a separate job with its own timeout.

Shared hosting: use the control panel

Most shared hosting providers (cPanel, Plesk, DirectAdmin) have a cron job interface in their control panel. You don't need SSH access. The cPanel path is usually:

cPanel \u2192 Advanced \u2192 Cron Jobs

Set the interval to "Once Per Minute" (or * * * * * if entering manually) and the command to:

cd /home/username/public_html && /usr/local/bin/php artisan schedule:run >> /dev/null 2>&1

Two things catch people off guard on shared hosting. First, the path to the PHP binary is different on every host. It could be /usr/bin/php, /usr/local/bin/php, or something long like /opt/cpanel/ea-php83/root/usr/bin/php. If you have terminal access, run which php to find the right one. If not, ask your hosting provider.

Second, some hosts only let you run cron every 5 or 15 minutes, not every minute. That means tasks scheduled for the minutes in between just won't fire. If that's your situation, the HTTP endpoint approach from earlier is a better fit, because most hosts don't restrict how often an external service can visit a URL.

And if your host blocks cron entirely (some budget plans do), the HTTP endpoint is your only option. Point an external cron service at it and you're done.

Which approach fits your setup

Quick decision tree, based on what we've seen work across different deployments:

  • VPS with SSH access: standard crontab entry. Done.
  • Shared hosting with cPanel/Plesk: control panel cron job. If limited to 5+ minute intervals, use the HTTP endpoint instead.
  • Docker / Kubernetes: schedule:work in a separate container, or a CronJob resource calling schedule:run.
  • PaaS (Heroku, Railway, Render): HTTP endpoint + external cron service. These platforms support worker processes too, so schedule:work as a worker dyno/service also works.
  • Serverless (Vapor): automatic. Vapor handles it.
  • Managed (Forge, Ploi): automatic. Just verify after migrations.

The HTTP endpoint approach is the universal fallback. It works everywhere your app can receive a web request, regardless of the hosting environment. If you're unsure, start there.

Don't skip monitoring

Getting the scheduler to run is half the job. The other half is knowing it's still running a month from now. Servers restart. Containers crash. Tokens expire. Any of these can silently stop your scheduled tasks, and nothing will tell you about it unless you set up monitoring.

Laravel makes this easy with built-in heartbeat pings. You can tell any scheduled task to send a quick HTTP request to a monitoring service after it runs. If the ping stops arriving, the service alerts you. Laravel provides three methods for this: pingOnSuccess() (ping only if the task succeeded), pingOnFailure() (ping only if it failed), and pingBefore() (ping before it starts). We covered the full setup in the Laravel monitoring guide.

The simplest version adds one line per task:

Schedule::command('app:backup-database')
    ->dailyAt('02:00')
    ->pingOnSuccess('https://ping.watchcron.com/your-monitor-id');

If the external cron approach appeals to you and you'd rather not manage the HTTP endpoint yourself, Cloud Cron combines the trigger and the monitoring: it hits your URL on schedule and alerts you if the request fails or the endpoint goes silent. One moving part instead of two.

WatchCron monitors your scheduled tasks and alerts you on failures. Free plan includes 20 monitors with Slack, Telegram, email, and 19 other notification channels.

Free plan · 20 checks forever Start Free

Frequently asked

Yes. Use php artisan schedule:work (available since Laravel 8) to run the scheduler as a long-running foreground process. It checks and fires due tasks every minute without needing a cron daemon. This is the standard approach in Docker containers, Kubernetes, and PaaS platforms where crontab isn't available.

It's production-ready and officially supported by Laravel. The main concern is memory: since the process runs indefinitely, any memory leaks in your application accumulate over time. Set a memory limit on the process and use a process manager (supervisord, Docker restart policy, Kubernetes liveness probe) to restart it if it crashes or exceeds the limit.

Use a secret token passed as a custom HTTP header (not a URL parameter, which leaks in logs). Check the token in your route handler and return 403 if it doesn't match. Add rate limiting to prevent abuse, and consider IP whitelisting if your external cron service uses fixed IPs.

For the scheduler to work correctly, yes. Tasks with minute-level precision (like everyFiveMinutes() or hourlyAt(17)) depend on schedule:run being called every minute. If your hosting only allows every-5-minute cron intervals, tasks scheduled for specific minutes in between will be skipped silently.

Laravel checks whether a task has already run in the current minute using a cache-based mutex. Tasks with withoutOverlapping() won't run twice. Without that method, some tasks may execute again if the duplicate call happens before the first execution finishes. Using onOneServer() in multi-server setups prevents the same task from running on multiple instances.