zpm-packages/cron-manager
Cron Manager for Laravel lets you define, organize, and monitor scheduled tasks in one place. Manage cron jobs from code or a dashboard, enable or disable entries, and track upcoming and past runs without digging through server crontabs.
Install and publish
composer require zpm-packages/cron-manager
php artisan vendor:publish --tag=cron-manager-config
php artisan vendor:publish --tag=cron-manager-migrations
php artisan migrate
php artisan schedule:list # smoke test: does the package load at all?
Pin the version explicitly ("zpm-packages/cron-manager": "1.0.*"). With 0 stars, 0 dependents, and a release dated 2026-05-06, you are the QA team — a caret range will eventually hand you a breaking change on a Tuesday.
Where to look first, in this order
src/*ServiceProvider.php — grep for publishes(, mergeConfigFrom(, commands(, and any Schedule/event listener registration. This tells you how the package hooks in and what the real publish tags are.config/cron-manager.php — connection, table name, model class, cache/lock store, whether the DB loader is enabled.src/Models/CronJob.php (or CronEntry/ScheduledJob — confirm the name) — casts tell you which flags are supported (enabled, without_overlapping, run_in_background, timezone).database/migrations/* — the actual column set. The README will drift; the migration will not.First use case: move one hotfix-prone schedule out of code.
// routes/console.php — before
Schedule::command('reports:daily')->dailyAt('02:00');
Replace it with a row so ops can retime or disable it without a deploy:
CronJob::create([
'name' => 'Daily revenue report',
'command' => 'reports:daily',
'expression' => '0 2 * * *',
'timezone' => 'America/New_York',
'enabled' => true,
]);
If that row appears in schedule:list and executes once, the package works for you. If it doesn't, stop here and reconsider — the whole value proposition is that one behaviour.
Load DB entries alongside code-defined ones. The package almost certainly either registers a listener or exposes a loader. If it exposes one, call it at the end of your schedule so filesystem-defined jobs still win on conflicts:
// routes/console.php
Schedule::command('backup:run')->daily();
if (config('cron-manager.load_from_database')) {
app(\Zpm\CronManager\CronManager::class)->registerInto(Schedule::getFacadeRoot());
}
If it auto-registers, check when — a booted() callback that queries the DB will break config:cache and any migrate on a fresh DB. Confirm the provider guards against a missing table.
Keep command pointing at a class, not a shell string. A DB-backed scheduler is a remote-code-execution surface by design. Constrain it:
protected static function booted(): void
{
static::saving(function (CronJob $job) {
if (! array_key_exists($job->command, Artisan::all())) {
throw new InvalidArgumentException("Unknown command [{$job->command}]");
}
});
}
Whitelist the allowlist in config, and never let the admin UI accept sh -c.
Add run history yourself. Persisting outcomes is the most common gap in small scheduler packages, and it's what you'll actually want at 3am:
Event::listen(ScheduledTaskFinished::class, function ($event) {
CronJob::where('command', $event->task->command)
->first()?->runs()->create(['exit_code' => $event->task->exitCode, 'ran_at' => now()]);
});
Same for ScheduledTaskFailed and ScheduledTaskSkipped (the last one is how you catch silent withoutOverlapping lock contention).
Multi-tenant / environment scoping. Add a team_id or environment column and override the loader query via a bound repository rather than editing vendor files. Keep production and staging job tables separate — a shared table means staging disables production's invoices.
Testing. Don't assert on the scheduler's internals; assert that a row produces a scheduled event:
$this->travelTo(now()->setTime(2, 0));
Artisan::call('schedule:list');
$this->assertStringContainsString('reports:daily', Artisan::output());
Then invoke the command directly to prove the payload is valid, independent of scheduling.
Deployment. Your system crontab stays exactly one line — * * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1. The package does not and cannot install that for you. Include php artisan schedule:list in your deploy script as a post-migrate check.
last_run_at is older than twice their expected interval.withoutOverlapping locks live in the cache store, not the DB. With CACHE_DRIVER=array or a per-container file cache, the lock is decorative. Require Redis or the database cache driver in any environment with more than one node.app.timezone unless you call ->timezone() per task. If the package stores a timezone per row, verify it's actually applied to the Event — a stored column that's never read is a classic. Store and display UTC in the admin UI, convert at the edge.boot() and queries on every request, config:cache and Octane will both misbehave. Prefer registering in withSchedule() / a console-only path.--tag=cron-manager-config may be cron-manager-configs, config, or absent. Run php artisan vendor:publish interactively and read the numbered list before scripting it.git diff between tags, not just the release notes, and fork (vcs repository in composer.json) if you need to patch a bug — you will not get an upstream fix on your timeline.auth + can: middleware by default. Assume not.CronJob model (subclass and rebind in the container if the config exposes a model key), and event listeners. Avoid subclassing the service provider.How can I help you explore Laravel packages today?