Importing orders, sending queued emails, refreshing a feed: plenty of small sites need a script that runs every minute or every few. On shared hosting you cannot edit the server's crontab, but the control panel almost always has a page that does the same thing for your own account.
In short
- 1Add a cron job in your control panel with
* * * * *and the full path to PHP and your script. - 2Build paths from
__DIR__, take a lock so runs never overlap, and send the output to a log. - 3If your host only allows every 15 minutes, have an outside service call the script over HTTPS with a secret.
Option A: Your control panel
This is free and runs on the same server as your site. Where to find it:
- cPanel: Advanced › Cron Jobs.
- Plesk: Websites & Domains › Scheduled Tasks, which can also run a PHP script without a command.
- DirectAdmin: Advanced Features › Cron Jobs.
Set the schedule to every minute: a * in all five fields, or pick it from the common settings. Then enter the command:
/usr/local/bin/php /home/USERNAME/cron/import.php > /dev/null 2>&1
Replace USERNAME with your account name and the path with where your script is. The part
> /dev/null 2>&1 throws the output away. Without it, cPanel emails you the output of every run, which is
1,440 emails a day at once a minute.
Some hosts do not allow every minute. A minimum of 5, 10 or 15 minutes on shared plans is common, and some hosts quietly change a shorter interval to their minimum. Check your host's help pages, or skip to option B.
Finding the path to PHP
Cron does not know which PHP version your site uses, so give it the full path. On many cPanel servers it is
/usr/local/bin/php. Servers with several versions often have one path per version, such as
/opt/cpanel/ea-php83/root/usr/bin/php for PHP 8.3. If you are unsure, your host's documentation or support
can tell you in one line. With SSH access you can also check:
# Where is PHP, and which version is it?
which php
php -v
Avoid running the script through wget or curl on your own site when the command line works: that
route goes through the web server, its time limit and any security rules in front of it.
Write the script for cron
Three things go wrong with scripts that work fine in a browser: relative paths, runs that overlap, and output nobody reads. This template handles all three.
<?php // Only from cron: refuse to run in a browser. if (PHP_SAPI !== 'cli') { http_response_code(403); exit; } // Paths from this file, because cron starts in a different directory. require __DIR__ . '/../public_html/config.php'; // One run at a time. A run that is still busy keeps the lock, // and the next one exits straight away. $lock = fopen(__DIR__ . '/import.lock', 'c'); if (!flock($lock, LOCK_EX | LOCK_NB)) { exit; } $log = __DIR__ . '/import.log'; try { $count = import_new_orders(); file_put_contents($log, date('c') . " ok $count\n", FILE_APPEND); } catch (Throwable $e) { file_put_contents($log, date('c') . ' error ' . $e->getMessage() . "\n", FILE_APPEND); exit(1); }
- Keep it out of
public_htmlwhen you can, so nobody can start it from a browser. Cron can run a file anywhere in your account. - Keep each run short. Process a batch and stop. The next run picks up the rest. Hosts often stop processes that run for a long time.
- Trim the log now and then, or write one file per day, so it does not grow forever.
Check that it runs
Look at the log after a few minutes: one line per run. That tells you it works today. It does not tell you when it stops next month because the host changed the PHP path, the account ran out of disk space or someone deleted the cron job, and cron itself never complains.
A heartbeat covers that. Add one line at the end of the script that calls a monitoring URL after a successful run. If the call stops arriving, you get an email.
// Tell CronEngine this run finished. A missed call means an email.
@file_get_contents('https://cronengine.com/ping/hb_YOUR_TOKEN');
Set the heartbeat in CronEngine to expect a call every minute, with a few minutes
of grace. Heartbeat pings do not count towards your monthly runs, and the free plan includes
2 heartbeats. If allow_url_fopen is off on your host, use
curl in PHP for the same call.
Option B: When every minute is not allowed
If your host's minimum is longer than you need, an outside service can call the script over HTTPS instead. The script then has to be reachable by URL, so it must check a secret before doing anything.
<?php // Called over HTTPS by a scheduler that sends this header. $secret = 'paste-a-long-random-string-here'; $given = $_SERVER['HTTP_X_CRON_TOKEN'] ?? ''; if (!hash_equals($secret, $given)) { http_response_code(403); exit; } // Keep going if the caller stops waiting. ignore_user_abort(true); require __DIR__ . '/../config.php'; // ... the same lock and work as above ... echo 'ok';
- A custom header, not the URL. A token in the query string ends up in access logs. A header such as
X-Cron-Tokendoes not, and it also avoids hosts that do not pass theAuthorizationheader on to PHP. - Web requests have a time limit. Often 30 seconds, set by
max_execution_timeand the host. Keep runs short, as above. - Answer with an error code on failure (
http_response_code(500)), so the scheduler records the run as failed.
In CronEngine, create a cron job with the script's URL, your schedule, and a request
header X-Cron-Token: paste-a-long-random-string-here. Press Test and you should get
ok back. Every run is kept with its status and response, and you get an email when the script starts failing.
Does it have to be every minute?
Every minute is 1,440 runs a day and 43,200 a month. For a control panel cron job that costs nothing extra, but it is worth asking how late the work may be:
| Task | Usually enough |
|---|---|
| Queued emails, notifications, webhooks to other systems | Every minute |
| Importing orders or stock, syncing with another app | Every 5 minutes |
| Feeds, caches, reports, cleanup | Every 15 to 60 minutes |
From outside, the number of runs is what you pay for. On CronEngine, the free plan's 3,000 runs a month cover one job every 15 minutes, every 5 minutes fits the Starter plan at €5.99 a month, and every minute needs the Business plan at €24.99. If your host allows every minute in the control panel, that plus a free heartbeat is the cheapest way to run often and still know when it stops.
Questions
Why does my cron job work when I open the script in a browser, but not from cron?
Cron runs the script with the command-line version of PHP, from a different working directory, and as your hosting user. Relative paths like include "config.php" then point somewhere else. Build paths from __DIR__, use the full path to PHP, and check the cron email or log for the actual error.
My host only allows a cron job every 15 minutes. What can I do?
Either run less often, which is enough for most tasks, or let an outside service call the script over HTTPS every minute. The script then has to be reachable by URL and protected with a secret, as shown in option B.
How do I stop the emails cron sends every minute?
cPanel emails the output of every run to the address under Cron Email. Add > /dev/null 2>&1 at the end of the command to discard the output, or remove the address. Log to a file instead if you still want a record.
What if the script takes longer than a minute?
Cron starts the next run anyway, and two copies then work on the same data. A lock at the start of the script, as in the example, makes the second run exit straight away until the first is done.
Is there a time limit for scripts run from cron?
Command-line PHP has no max_execution_time by default, but many shared hosts stop long-running processes themselves. Keep each run short and let the next run continue where it stopped.
Should the script be inside public_html?
Not for option A. Cron can run a file anywhere in your account, and keeping it outside public_html means nobody can start it from a browser. Only option B needs it reachable by URL, and then it must check a secret.
Spotted a mistake, or something that behaves differently for you? Tell us and we will update the guide.