Run CodeIgniter cron jobs from the CLI, never over HTTP. On CodeIgniter 4 that means a command class and php spark; on CodeIgniter 3 it means a controller guarded by is_cli(). A working crontab line looks like this — absolute paths for both the PHP binary and the project:
# CodeIgniter 4
0 2 * * * /usr/bin/php /var/www/myapp/spark digest:send >> /var/log/myapp-cron.log 2>&1
# CodeIgniter 3
0 2 * * * /usr/bin/php /var/www/myapp/index.php cron run >> /var/log/myapp-cron.log 2>&1
Before the mechanics: if you are on CI3 and PHP 8.4 or newer, read the PHP 8.5 section — there is a failure mode there that silently breaks HTTP status codes, and it is not theoretical.
Table of Contents
Is CodeIgniter 3 worth updating to, or off?
Off, but not urgently-panic off. Stating the facts rather than the vibe:
- The last CI3 release on Packagist is 3.1.13, published 2022-03-03. The official repository README calls it “the legacy version of the framework, intended for use with PHP 5.6+” and “in maintenance, receiving mostly just security updates.” The repo is not archived; its last push was 2024-07-15.
- There is no published CI3 end-of-life date, and claims of one are not traceable to an official announcement, so this page will not invent one. “Security fixes only, last release over four years ago” is the accurate description.
- The current release is CodeIgniter 4.7.4 (2026-07-07), requiring PHP 8.2 or newer. The CI4 docs state “PHP 8.5 requires CodeIgniter 4.7.0 or later” and “we only maintain the latest version.”
The practical trigger is PHP, not CodeIgniter. PHP 8.2 loses security support on 2026-12-31, so a CI3 app needs a PHP upgrade within months — and that is where CI3 stops being merely old.
CodeIgniter 3 on PHP 8.5: measure before you assume it is fine
CI3 3.1.13 does boot on PHP 8.5.8. It also emits a deprecation block for essentially every property assignment the framework makes. Running the stock welcome controller from the CLI:
$ php index.php welcome index
...
A PHP Error was encountered
Severity: 8192
Message: Creation of dynamic property CI_URI::$config is deprecated
Filename: /path/CodeIgniter-3.1.13/system/core/URI.php
Line Number: 102
...
Counting them on a single default request:
$ php index.php welcome index 2>&1 | grep -c "A PHP Error was encountered"
27
All 27 are PHP 8.2’s “Creation of dynamic property” deprecation, plus Constant E_STRICT is deprecated since 8.4. The page still renders — which is what makes it dangerous, because of the next part.
The deprecations break your HTTP status codes
CI3 renders those notices through its own error handler, which echoes them before your controller runs. Output means headers are sent, and once headers are sent no later status code sticks. A CLI-guard controller that correctly refuses a web request, served over php -S with the stock development environment:
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8123/index.php/cron/guarded
HTTP 200
It should be 403. Probing headers_sent() at the moment the guard fires names the culprit exactly:
headers_sent_at_guard_time=YES (/path/CodeIgniter-3.1.13/system/core/Exceptions.php:272)
That line is the echo in CI3’s own deprecation renderer. Suppressing deprecations in index.php restores correct behaviour — same request, same controller, one changed line:
// index.php, in the 'development' branch, replacing error_reporting(-1);
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8125/index.php/cron/guarded
HTTP 403
The consequence for cron specifically: monitoring, load balancers, and WAF rules that key on status codes will read a blocked request as 200 OK. Your guard still works — the job does not run — but nothing downstream can tell.
Why HTTP-triggered cron is a hole
Use a CLI check rather than wget. The reason is worth demonstrating. A cron controller with no guard is a public endpoint — two actions on the same CI3 controller, one guarded and one not, hit over HTTP:
$ curl -s http://127.0.0.1:8123/index.php/cron/unguarded
DANGER: nightly billing run executed
$ curl -s http://127.0.0.1:8123/index.php/cron/guarded
This controller can only be run from the command line.
Anyone who guesses the URL runs your nightly billing job, as often as they like. wget http://example.com/cron/run in a crontab is exactly this with the guard removed, and it also hands you the web server’s timeout and memory limit instead of the CLI’s — which is how long jobs end up half-finished.
Writing the CI3 cron controller safely
First, use is_cli(), not $this->input->is_cli_request(). The latter is a one-line wrapper carrying this docblock in system/core/Input.php:
@deprecated 3.0.0 Use is_cli() instead
It has been deprecated since 3.0.0. The real implementation in system/core/Common.php is:
function is_cli()
{
return (PHP_SAPI === 'cli' OR defined('STDIN'));
}
Second, do not read task rows from MySQL and pass them to shell_exec($row->command) unsanitised — that is arbitrary command execution for anyone with write access to that table, including via SQL injection elsewhere in the app. Put each job in its own controller method or command class and let crontab schedule it.
A CI3 guard using is_cli():
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Cron extends CI_Controller {
public function run()
{
if ( ! is_cli())
{
show_error('This controller can only be run from the command line.', 403);
}
// real work here
log_message('info', 'cron/run completed');
}
}
The CodeIgniter 4 way
CI4 replaces the guarded controller with a first-class command, so there is no HTTP route to protect at all. Create app/Commands/SendDigest.php:
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class SendDigest extends BaseCommand
{
protected $group = 'Cron';
protected $name = 'digest:send';
protected $description = 'Sends the nightly digest.';
public function run(array $params)
{
$count = 3; // real work goes here
CLI::write("Digest sent to {$count} recipients.", 'green');
log_message('info', 'digest:send completed for ' . $count . ' recipients');
return EXIT_SUCCESS;
}
}
It registers itself — no routes file, no config:
$ php spark digest:send
CodeIgniter v4.7.4 Command Line Tool - Server Time: 2026-08-09 20:00:37 UTC+00:00
Digest sent to 3 recipients.
Running the same CI4 request produced no deprecation output on PHP 8.5.8. spark also refuses to run under a CGI SAPI, so it cannot be reached the way a CI3 controller can:
if (str_starts_with(PHP_SAPI, 'cgi')) {
exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n");
}
Crontab syntax and the things that actually go wrong
Field order, from man 5 crontab:
field allowed values
----- --------------
minute 0-59
hour 0-23
day of month 1-31
month 1-12 (or names)
day of week 0-7 (0 or 7 is Sun, or use names)
Use absolute paths for everything. Cron runs with a minimal PATH that usually will not find your PHP, and the binary on PATH is often a symlink elsewhere — on this machine which php reports /opt/homebrew/bin/php while PHP_BINARY resolves to /opt/homebrew/Cellar/php/8.5.8/bin/php. Run php -r 'echo PHP_BINARY;' on the target server and use what it prints.
Redirect output or you will not know it failed. Appending >> /var/log/myapp-cron.log 2>&1 captures both streams; without it cron mails output to the local user, where it typically vanishes.
Check your log threshold before trusting your logs. A fresh CI4 install with no .env runs as production, where app/Config/Logger.php sets $threshold = (ENVIRONMENT === 'production') ? 4 : 9;. Level 4 is warning, so log_message('info', ...) is silently discarded. The command above ran successfully and wrote nothing:
$ php spark digest:send
Digest sent to 3 recipients.
$ ls writable/logs/
index.html
With CI_ENVIRONMENT = development in .env, the identical command logs:
$ ls writable/logs/
index.html log-2026-08-09.log
$ grep digest:send writable/logs/*.log
INFO - 2026-08-09 20:00:57 --> digest:send completed for 3 recipients
If you want info-level cron logging in production, raise the threshold explicitly rather than assuming it is on.
What was verified, and what was not
Everything with pasted output was executed on macOS 26.6 (arm64) with PHP 8.5.8: the 27 CI3 deprecation blocks, the 200-vs-403 status collapse and its headers_sent() cause, the guarded/unguarded HTTP requests against php -S, the is_cli_request() deprecation docblock, and the CI4 command, spark output, and logger threshold behaviour. manage-cron-jobs-codeigniter.sh reruns all of it: the script downloads CodeIgniter 3.1.13, builds a throwaway CodeIgniter 4 app, repeats the requests and the spark run above, and removes everything afterwards.
Not verified here: no crontab was installed and no job was run by a real cron daemon — the crontab lines and the PATH advice come from man 5 crontab and from running the commands by hand. CI3’s security-fix status is quoted from the project README rather than tested, and no CI3-to-CI4 migration was performed, so this page makes no claim about how much work that upgrade is for your codebase.