norvutec/cron-manager
norvutec/cron-manager is a Laravel package for managing scheduled tasks in one place: define and run cron jobs, track execution status and history, and keep an eye on failing tasks without digging through server crontabs.
Version 7.0.1 is a patch release on the 7.0 line — no migration steps beyond what 7.0.0 required. Treat this assessment as covering the 7.0.x series; if you are coming from 6.x, read the 7.0.0 upgrade notes first (major versions of this package have historically bumped PHP/Laravel minimums and renamed tables/namespaces).
Minimal setup:
composer require norvutec/cron-manager
php artisan vendor:publish --provider="NorvuTec\CronManager\CronManagerServiceProvider"
php artisan migrate
Publish the config and migrations specifically (--tag=cron-manager-config, --tag=cron-manager-migrations) if the provider tag publishes more than you want. Then:
config/cron-manager.php — read this file top to bottom before anything else. It is where the table names, route prefix, middleware, timezone, and lock/overlap behaviour live. Nearly every "it doesn't work" issue traces back to a key here.* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
and, in routes/console.php (Laravel 11+) or app/Console/Kernel.php:
Schedule::command('cron-manager:run')->everyMinute()->withoutOverlapping();
Confirm the exact command name in the config/service provider — it is the single point of failure for the whole package.
First use case: pick two or three $schedule->command(...) lines that ops regularly asks you to change (frequency, on/off during incidents). Move them into cron-manager rows, verify the runs table records executions, then delete the hardcoded entries. Do not migrate the whole schedule at once.
Let the package own when, not what. Keep the actual work in artisan commands or queued jobs; cron-manager stores the expression, timezone, enabled flag, and run history. This keeps the schedule changeable without a deploy while keeping logic testable and version-controlled.
Seed your schedule, don't click it. Define jobs in a seeder so local, staging, and prod converge:
CronJob::updateOrCreate(
['command' => 'reports:nightly'],
['expression' => '0 2 * * *', 'timezone' => 'Europe/London', 'enabled' => true],
);
Clicking in the UI is fine for hotfixes, but a prod-only manual row is invisible drift that bites on the next environment rebuild.
Use toggles for incident response. During an outage, disabling a job through the UI is often safer and faster than reverting a deploy. Make sure whoever is on call knows the URL and that they have authorization to reach it.
Overlap protection at two levels. withoutOverlapping() on the scheduler entry protects the runner; a per-job lock protects a single long-running job when others are short. A */5 * * * * job that occasionally takes 8 minutes will otherwise stack up silently.
Route heavy work through the queue. If a command can exceed the minute, wrap it in a job implementing ShouldQueue and keep the cron entry as a thin dispatcher. The manager records the dispatch; the queue handles retries and timeouts where you already have visibility.
Testing. Freeze time and assert registration rather than wall-clock behaviour:
$this->travelTo(now()->setTime(2, 0));
$this->artisan('cron-manager:run')->assertSuccessful();
Fake the bus/queue for anything dispatched. For the management routes, cover the authorization path (allowed vs. forbidden) — that is the highest-risk surface in the package.
Deploy checklist: php artisan migrate --force, php artisan config:clear (or rebuild config:cache), and php artisan queue:restart if you changed job definitions.
schedule:run is not firing, the UI's "run now" and the run history are the only things that will appear to work. Check php artisan schedule:list and the server crontab first, not the package config.0 2 * * * plus a server in UTC plus an app in America/New_York gives you 2am in the wrong place. Set app.timezone deliberately and store per-job timezones where the package supports it.withoutOverlapping() relies on cache locks; with CACHE_STORE=file on a multi-node deployment, each node gets its own lock and the protection is illusory. Use redis or the database store.config/cron-manager.php in production, a cached config silently keeps the old values. Always config:clear and re-cache as part of the deploy.web + auth, ideally a gate/policy. Do not assume the package ships it locked down; check before exposing the prefix publicly, and consider restricting it to an internal network or IP allowlist."norvutec/cron-manager": "7.0.1") until you have verified the runner in staging.storage/. Mismatched users produce locks that are never released.--tag=views) for branding the UI, custom job types if the package supports registering command resolvers, and the run-history model for wiring your own alerting (a failed run is only useful if something watches it — hook the runs table into your existing notification channel rather than relying on the UI).storage/logs/laravel.log for the runner command → the raw query against the jobs table to confirm the row is enabled and the expression parses → the server crontab. Nine times out of ten the problem is step four.How can I help you explore Laravel packages today?