У crontab Laravel-проєкту має бути один рядок: * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1. Усе інше - код. Раз на хвилину cron будить процес, той бутстрапить застосунок, дістає з контейнера обʼєкт Illuminate\Console\Scheduling\Schedule з усіма задачами, описаними в routes/console.php (Laravel 11+) або в методі schedule() класу app/Console/Kernel.php (Laravel 10 і нижче), і для кожної викликає isDue(): чи збігається cron-вираз задачі з поточною хвилиною в її таймзоні, чи підходить середовище, чи не діє режим обслуговування. Вирази парсить dragonmantank/cron-expression, а хелпери на кшталт dailyAt('03:10') або hourlyAt(5) просто складають той самий рядок із пʼяти полів. Виграш від такої схеми: розклад лежить у git, ревʼюється разом з кодом, видно через schedule:list, і деплой не вимагає лізти в crontab. Є й обмеження. Мінімальна гранулярність cron становить хвилину, тому під-хвилинні everySecond() і everyTenSeconds() (Laravel 11) зроблені інакше: процес schedule:run не виходить, а лишається живим до кінця хвилини, прокидаючись щосекунди. Через це під час релізу старий код може працювати ще до 60 секунд, і для цього існує schedule:interrupt.
Тепер про дублі. Cron нічого не знає про попередній запуск: він просто стартує новий процес щохвилини. Якщо задача з everyMinute() працює дві хвилини, о 12:01 запуститься друга копія, о 12:02 третя, і так до вичерпання пулу зʼєднань до БД. withoutOverlapping() вішає на задачу мутекс: перед виконанням фреймворк робить Cache::add() із ключем framework/schedule- плюс sha1($expression.$command), і якщо ключ уже існує, задача тихо пропускається. Знімається лок у блоці finally, а для задач із runInBackground() окремою командою schedule:finish, яку фреймворк дописує після основного процесу. Аргумент задає TTL у хвилинах, за замовчуванням 1440, тобто добу. Звідси й найчастіша аварія: якщо процес не дійшов до finally (kill -9, OOM-killer, ребут вузла посеред роботи), ключ висить добу, і задача не виконується взагалі. Тому TTL ставлять реалістичний, withoutOverlapping(15) для задачі з нормальним часом роботи у дві хвилини, а застряглі ключі прибирають командою schedule:clear-cache. Ще одна тонкість: ключ рахується з виразу і тексту команди, тож report:send --daily і report:send --weekly мають різні локи, а от дві однакові реєстрації одного й того ж рядка - один спільний. Для Schedule::call() ключ береться з name(), і без назви виклик withoutOverlapping() одразу кидає LogicException.
onOneServer() вирішує іншу задачу. Коли застосунок розкладений на три веб-вузли, cron стоїть на всіх трьох (інакше розклад падає разом з одним сервером), і о 03:10 три планувальники синхронно вирішують надіслати той самий звіт. Тут працює окремий мутекс: той самий mutexName(), але з дописаною поточною годиною-хвилиною. Хто першим зробив Cache::add(), той і виконує задачу цієї хвилини, решта її пропускають, а лок помирає сам разом із хвилиною. Умова працездатності одна, і на ній сиплються: кеш має бути спільним для всіх вузлів і підтримувати атомарні локи. Документація називає redis, memcached, database і dynamodb; файловий кеш не годиться не тому, що не вміє локів, а тому що в кожного вузла він свій, і три незалежні набори ключів дають три паралельні запуски при повній видимості «захисту» в коді. Перевірте й префікс кеша: два застосунки з однаковим APP_NAME на спільному Redis ділять простір ключів і можуть блокувати задачі один одного. Годинники теж мають бути зведені через NTP: вузол, який відстає на хвилину, бореться за інший ключ і теж вважає себе єдиним.
Окреме питання - що саме класти в розклад. Schedule::command('report:send') виконується синхронно, у процесі schedule:run, і не отримує нічого з інфраструктури черг: немає tries, backoff, retryUntil, немає запису в failed_jobs, немає видимості в Horizon. Впало на 800-му рядку з 1000 - наступна спроба за розкладом, тобто через добу. Плюс задачі тієї ж хвилини за замовчуванням ідуть послідовно, тому одна повільна команда зсуває всі наступні; runInBackground() розводить їх по окремих процесах, але доступний лише для command() і exec(), не для замикань. Schedule::job(new SyncVacancies, queue: 'sync') натомість лише кладе job у чергу за пів секунди, а повтори, затримки, таймаути й паралелізм бере на себе воркер. Поділ виходить простий: планувальник дає таймер і тригер, черга дає runtime. Дрібне прибирання кеша чи згортання логів лишається командою, а все, що ходить в зовнішній API або обробляє десятки тисяч рядків, стає job (часто тонкою командою, що диспатчить по job на кожну сутність). Мінус у цієї схеми теж є: планувальник відрапортує «успіх» одразу після диспатчу, тому мертвий воркер з боку розкладу виглядає як повний порядок, і черга потребує власного моніторингу. Захист від дублів там теж свій: мідлвара Illuminate\Queue\Middleware\WithoutOverlapping з ключем за сутністю або інтерфейс ShouldBeUnique з uniqueId(), бо withoutOverlapping() на job не поширюється.
Розклад тестується як звичайний код. Реєстрація задачі ламається так само легко, як будь-що інше, тому з неї і починають: беремо app(Schedule::class), під travelTo() питаємо dueEvents($this->app) і перевіряємо, що потрібна задача в списку є, а її expression і timezone ті, що очікували. Такий тест ловить і випадкове daily() замість dailyAt('03:10'), і рядок, який зник під час мержу. Саму команду тестують окремо, без планувальника: $this->artisan('vacancies:expire --days=30')->assertSuccessful() з фабриками, а якщо вона лише диспатчить job - Queue::fake() і assertPushed(). Третій тест найцінніший: прогнати команду двічі підряд і переконатися, що нічого не подвоїлось. Руками допомагають schedule:list (вираз, наступний запуск, таймзона) і schedule:test, яка дає вибрати задачу й виконати її негайно поза розкладом. Межі цієї конструкції називайте самі: withoutOverlapping() і onOneServer() прибирають типовий випадок, але обидва спираються на кеш і обидва переживають аварію процесу гірше, ніж хотілося б, а ручний php artisan у SSH-сесії не бере мутекса взагалі. Єдина справжня гарантія проти подвійного нарахування - ідемпотентність самої операції: унікальний індекс на пару «сутність + період», updateOrCreate замість create, позначка «оброблено» в транзакції. Останнє, про що зазвичай забувають: тиша. Без onFailure(), pingOnFailure(), підписки на ScheduledTaskFailed і зовнішнього dead man's switch зламаний cron виглядає точно так само, як cron, у якого все добре.
// routes/console.php (Laravel 11+); у Laravel 10 те саме в app/Console/Kernel.php
use Illuminate\Support\Facades\Schedule;
// Важку роботу планувальник лише ставить у чергу: повтори й failed_jobs - у воркера
Schedule::job(new RecalculateSalaryMedians, queue: 'stats')
->dailyAt('03:10')
->timezone('Europe/Kyiv');
// Команда виконується всередині процесу schedule:run, тому її стережуть локами
Schedule::command('vacancies:expire --days=30')
->hourlyAt(5)
->withoutOverlapping(15) // лок у кеші; 15 хвилин замість дефолтних 1440
->onOneServer() // хвилину забирає один вузол кластера
->runInBackground() // не блокує решту задач цієї хвилини
->onFailure(fn () => Log::channel('slack')->error('vacancies:expire упала'));
// Для замикання ключ мутекса рахується з name(), а не з тексту команди:
// без name() виклик withoutOverlapping() кине LogicException
Schedule::call(fn () => Cache::forget('home.stats'))
->name('flush-home-stats')
->everyFifteenMinutes()
->withoutOverlapping();
// tests/Feature/ScheduleTest.php - розклад перевіряється як звичайний код
test('перерахунок медіан заплановано на 03:10 за Києвом', function () {
$this->travelTo(Carbon::parse('2026-09-20 03:10', 'Europe/Kyiv'));
$due = collect(app(Illuminate\Console\Scheduling\Schedule::class)
->dueEvents($this->app))
->map(fn ($event) => $event->getSummaryForDisplay());
expect($due)->toContain(RecalculateSalaryMedians::class);
});
Відповідь варто будувати на різниці між «хто вирішує, що час» і «хто гарантує, що один раз». Cron дає лише хвилинний тик і один рядок, рішення ухвалює `schedule:run` за cron-виразами з коду. Далі назвіть два різні локи (`withoutOverlapping()` - проти самонакладання задачі, `onOneServer()` - проти дублювання між вузлами), скажіть, що обидва живуть у кеші і після SIGKILL лишаються висіти, і закрийте фразою, яку інтервʼюер зазвичай і чекає: локи прибирають типовий випадок, а від справжнього подвійного нарахування рятує ідемпотентність і унікальний індекс.