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.
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
Never use a GET parameter for the token — it leaks in server logs and browser history. Use a custom header or POST body.
Set rate limiting on the route. Even with token auth, a misconfigured external service could hammer it.
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:
- Create a new job in the service with URL
https://yourapp.com/cron/run-scheduler - Set the schedule to
* * * * *(every minute) - Add a custom header:
X-Cron-Token: your-secret-token - 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:workin a separate container, or a CronJob resource callingschedule:run. - PaaS (Heroku, Railway, Render): HTTP endpoint + external cron service. These platforms support worker processes too, so
schedule:workas 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.