PHP-разработчику cron нужен в первый же день после деплоя: рассылки, импорт прайсов, очистка кеша, сборка статистики, проверка очередей. Разберём, как правильно цеплять PHP-скрипты к крон-таблице на Linux-сервере, куда писать логи, как защититься от наложения двух запусков и почему скрипт, который работал «руками», в кроне молча падает.
Синтаксис строки crontab
Каждое задание — это одна строка из пяти полей времени и команды. Поля разделяются пробелами, звёздочка означает «любое значение».

Полезные синтаксические сокращения:
*/N— каждые N единиц (минут, часов и т.д.);1-5— диапазон (с понедельника по пятницу при поле dow);1,15,30— список конкретных значений;@reboot,@daily,@hourly— псевдонимы вместо пяти звёздочек.
Открыть свою crontab для редактирования: crontab -e. Посмотреть текущий список заданий: crontab -l. Удалить всё (осторожно): crontab -r.
Запуск PHP-скрипта: три рабочих варианта
В кроне у PHP нет окружения веб-сервера: ни $_SERVER['HTTP_HOST'], ни сессий, ни сборок Composer-autoload, если их не подцепил сам скрипт. Поэтому всегда указывайте абсолютные пути.
# 1. Прямой вызов CLI-интерпретатора PHP
*/10 * * * * /usr/bin/php /var/www/app/cron/queue.php
# 2. Запуск curl-ом веб-эндпоинта (если задача — HTTP-страница)
*/5 * * * * /usr/bin/curl -fsS --max-time 60 https://example.com/cron/check > /dev/null
# 3. Через wrapper-скрипт с переменными окружения
0 3 * * * /home/app/scripts/nightly.sh
Вариант с CLI предпочтителен: нет ограничений max_execution_time и memory_limit из php-fpm, нет таймаутов nginx, можно спокойно работать часами. Узнать путь к PHP: which php или command -v php.
Если на сервере несколько версий PHP (например, 8.1 и 8.3), используйте полный путь именно к той, что нужна проекту, иначе скрипт может стартовать на «не той» версии:
0 4 * * * /opt/php83/bin/php /var/www/app/artisan schedule:run
Логирование: куда уходят вывод и ошибки
По умолчанию cron шлёт весь stdout/stderr задачи письмом владельцу crontab — на shared-хостингах эти письма обычно никуда не доходят. Поэтому пишите вывод в файл сами.
# stdout → лог, stderr → тот же лог (2>&1), дозапись в конец (>>)
*/15 * * * * /usr/bin/php /var/www/app/cron/sync.php >> /var/log/app/sync.log 2>&1
# Разделить успехи и ошибки в разные файлы
0 * * * * /usr/bin/php /var/www/app/cron/report.php >> /var/log/app/report.log 2>> /var/log/app/report.err
Внутри PHP-скрипта тоже логируйтесь явно — не полагайтесь на display_errors:
<?php
// /var/www/app/cron/sync.php
declare(strict_types=1);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/app/php-cron.log');
error_reporting(E_ALL);
require __DIR__ . '/../vendor/autoload.php';
$started = microtime(true);
try {
$synced = (new App\Sync\PriceImport())->run();
fwrite(STDOUT, sprintf(
"[%s] sync ok: %d товаров за %.2f сек\n",
date('c'), $synced, microtime(true) - $started
));
} catch (\Throwable $e) {
fwrite(STDERR, sprintf(
"[%s] sync FAIL: %s (%s:%d)\n",
date('c'), $e->getMessage(), $e->getFile(), $e->getLine()
));
exit(1);
}
Несколько правил, которые экономят часы отладки:
- пишите ISO-8601 дату в каждую строку лога — без неё трудно сопоставить с метриками;
- возвращайте ненулевой
exit-код при ошибке — это позволит мониторингу детектить падения; - не используйте
echoвперемешку сprint_rв проде — для отладки лучше отдельный verbose-флаг.
Защита от наложения: lock-файл
Классическая проблема: задача запускается каждую минуту, а один запуск длится 90 секунд. Без блокировки через минуту стартует второй процесс параллельно с первым, и они начинают друг другу мешать — гонки по БД, дубли отправленных писем, перерасход памяти.
Решение в bash — утилита flock:
# -n: не ждать, если занято — сразу выйти
# -x: эксклюзивный лок
* * * * * /usr/bin/flock -n /tmp/queue.lock /usr/bin/php /var/www/app/cron/queue.php >> /var/log/app/queue.log 2>&1
Если предпочитаете держать блокировку внутри PHP (например, лог из скрипта же), используйте flock() на дескрипторе:
<?php
$lockFile = '/tmp/queue.lock';
$fp = fopen($lockFile, 'c');
if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "Уже запущен другой экземпляр\n");
exit(0); // exit 0 — это не ошибка, просто пропускаем тик
}
// Записываем PID — пригодится для диагностики
ftruncate($fp, 0);
fwrite($fp, (string) getmypid());
try {
// Полезная работа: обработка очереди
(new App\Queue\Worker())->processBatch(100);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
}
Использовать в качестве lock-файла нужно тот же путь, что и сам скрипт не пересоздаёт под собой — иначе unlink в одном процессе уничтожит блокировку другого. Безопаснее: открывать с 'c' (создать если нет), удалять только при чистом выходе.
Окружение cron: PATH, переменные, кодировки
Cron запускает задание в минимальном окружении. В нём почти ничего нет: PATH урезан до /usr/bin:/bin, нет переменных из ~/.bashrc, нет LANG. Это причина большинства «у меня всё работает, а в кроне падает».
Задавайте переменные явно в начале crontab:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=ru_RU.UTF-8
MAILTO=""
# Дальше идут задачи
*/15 * * * * cd /var/www/app && /usr/bin/php artisan queue:work --once
Обратите внимание на cd перед командой: некоторые фреймворки (Laravel, Symfony) ожидают, что текущая директория — корень проекта. Альтернатива — флаг --working-dir Composer-команд или chdir() в начале PHP-скрипта.
Если скрипт читает .env-файл, проверьте, что путь к нему резолвится от рабочей директории, а не от хоум-каталога пользователя. Самый надёжный способ — путь через __DIR__:
<?php
// Корень проекта = на один уровень выше cron/
chdir(__DIR__ . '/..');
// Загружаем .env из корня
require_once 'vendor/autoload.php';
Dotenv\Dotenv::createImmutable(__DIR__ . '/..')->load();
Отладка: что делать, если задача «не сработала»
Алгоритм диагностики проблемного задания:
- Убедитесь, что сам cron-демон жив:
systemctl status cron(Debian/Ubuntu) илиsystemctl status crond(CentOS/RHEL). - Посмотрите системный лог cron:
grep CRON /var/log/syslogилиjournalctl -u cron --since "1 hour ago". Там видно, какие задания стартовали и от какого пользователя. - Запустите команду из crontab вручную из-под того же пользователя — большинство ошибок воспроизводится сразу:
sudo -u www-data bash -c 'cd /var/www/app && /usr/bin/php artisan ...'. - Проверьте права на лог-файл и его директорию: если у пользователя нет прав на запись, скрипт упадёт молча.
- Добавьте отладочный пинг в начало скрипта: запись текущего времени в отдельный файл. Если этот файл не появляется — задача вообще не запускалась (проблема в crontab), если появляется — ищите ошибку в самом PHP.
Полезная команда, чтобы убедиться, что crontab сохранён корректно:
# Показывает crontab текущего пользователя
crontab -l
# Crontab другого пользователя (нужен root)
sudo crontab -u www-data -l
# Системный crontab (расписания пакетов)
cat /etc/crontab
ls /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/
Чеклист: cron-задание, которое не сломается ночью
- Абсолютные пути — и к PHP, и к скрипту, и к логам. Никаких относительных
./script.php. - Перенаправление stdout и stderr в файл (
>> log 2>&1), чтобы вывод не терялся в почте. - Lock-файл через
flockилиflock()в PHP — если задача может занимать больше интервала. - Дата в каждой строке лога и ненулевой
exit-код при ошибке для мониторинга. - Явный
PATHиLANGв начале crontab, особенно если работаете с UTF-8 и внешними утилитами. - Ротация логов через
logrotate— иначе файл вырастет до десятков гигабайт за пару месяцев. - Мониторинг живости: deadman-эндпоинт (Healthchecks.io, UptimeRobot) или собственная метрика «время последнего успешного запуска».
Cron — крошечный инструмент, но именно на нём держится фоновая работа большинства PHP-проектов. Один раз настроенная по этим правилам схема избавит от ночных звонков о «не отправились письма» и «не сформировался отчёт».
