mtamazlicaru/cron-bundle
A Symfony bundle for defining and running scheduled cron jobs in PHP applications. Configure recurring console commands, let the bundle handle due-job execution, and keep background tasks organized alongside your application code.
Step 0 — verify you can actually install it. mtamazlicaru/cron-bundle (SbkCronBundle) is a Symfony bundle. It registers through a Symfony Bundle class, a DI extension, and symfony/console commands. It ships no ServiceProvider, no facade, no extra.laravel auto-discovery block, and nothing that targets Illuminate\Console\Scheduling\Schedule. In a Laravel app, composer require mtamazlicaru/cron-bundle will either fail on symfony/* constraint conflicts or force you to downgrade shared framework components. Run it in a throwaway directory first:
composer require mtamazlicaru/cron-bundle --dry-run
If it only resolves with --ignore-platform-reqs or version pins on symfony/console/symfony/framework-bundle, stop. That is a disqualifier, not a workaround. Note also that stars, dependents, and score are all zero — there is no upstream to inherit bug fixes from. Anything you adopt here, you own.
Where to look first (30 minutes, as a reference read). Read the bundle's composer.json for PHP/Symfony constraints and last-commit date, then the DependencyInjection/ extension to see the actual config schema, then the console command that evaluates what is due. The interesting part is small: parse a config file → map entries to commands → evaluate cron expressions → dispatch and log. That's the pattern worth extracting, not the code.
The Laravel-native first use case. Everything the bundle's description promises — "configure cron jobs in configuration files," version-controlled alongside your code — Laravel already does, with one crontab entry. Start with the smallest real job you have (nightly cache/table cleanup):
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1routes/console.php (Laravel 11+) or a ScheduleServiceProvider:
Schedule::command('cleanup:stale-rows')->dailyAt('02:00')->withoutOverlapping()->onOneServer();
php artisan schedule:list, then run php artisan schedule:run -v once and read the output.If you want the declarative-config flavor, that's the real first deliverable (see below).
Config-as-code schedule registry (the pattern, ported). This is the thing the bundle is actually selling, and it ports cleanly in ~1–2 days:
// config/schedules.php
return [
'daily-digest' => [
'command' => 'mail:daily-digest',
'cron' => '0 7 * * *',
'timezone' => 'UTC',
'overlap' => false,
'one_server'=> true,
'timeout' => 900,
],
];
// app/Providers/ScheduleServiceProvider.php
public function boot(): void
{
foreach (config('schedules', []) as $name => $job) {
$event = Schedule::command($job['command'])
->cron($job['cron'])
->timezone($job['timezone'] ?? config('app.timezone'))
->name($name);
if ($job['overlap'] ?? false === false) $event->withoutOverlapping();
if ($job['one_server'] ?? false) $event->onOneServer();
if (isset($job['timeout'])) $event->runInBackground();
}
}
Benefits you get for free that the bundle doesn't: onOneServer() (cache-backed lock, so autoscaled workers don't double-fire), withoutOverlapping(), runInBackground(), and before()/after()/onSuccess()/onFailure() hooks.
Jobs as classes, not closures. Keep schedule entries pointing at artisan commands or invokable job classes. Closures inside config files can't survive php artisan config:cache, and they're untestable in isolation. A command per job also gives you a manual trigger (php artisan mail:daily-digest) for debugging — the same convenience the bundle's "run/list/inspect" console commands provide.
Per-job logging and failure tracking. The bundle advertises per-job logging; in Laravel do it explicitly rather than relying on the global log:
Schedule::command('sync:invoices')
->hourly()
->withoutOverlapping()
->appendOutputTo(storage_path('logs/invoices.log'))
->onFailure(fn () => Log::channel('alerts')->error('sync:invoices failed'));
Send onFailure to a channel that actually pages someone. A scheduled job that fails silently is worse than no scheduled job, because you believe it ran.
Environment and deployment integration. Gate non-production schedules with ->environments(['production']) or by keying your config file per environment (config/schedules/production.php). For containerized deploy, choose one ownership model and document it: either a single long-lived schedule:work worker container, or an external trigger (Kubernetes CronJob, cloud scheduler) that runs php artisan schedule:run on one replica. Don't do both — two dispatchers means duplicate executions.
Testing. Use Carbon::setTestNow() plus $this->artisan('schedule:run') and assert the downstream effect. Add php artisan schedule:list to CI as a smoke test so a broken config file fails the build instead of dying quietly in production.
schedule:run fires only once per minute. It evaluates due tasks and exits. Long or blocking jobs hold the dispatcher and delay everything after them — use runInBackground() (requires schedule:finish handling of exit codes) for anything slow, and treat jobs shorter than the interval as the default assumption.onOneServer() needs a shared cache store. It's backed by an atomic cache lock. With CACHE_STORE=file or array on multiple hosts, every node has its own lock and you get N executions — the exact failure the feature claims to prevent. Verify your production cache driver is Redis/database/memcached.withoutOverlapping() locks for 24 hours by default. If a job crashes hard, the lock can outlive the job. Set the expiry explicitly: ->withoutOverlapping(10). Also note withoutOverlapping and onOneServer solve different problems (overlap vs. multi-node) — you often want both.->timezone() on the event, APP_TIMEZONE in config, and the server's cron daemon timezone are three separate things. A mismatch shifts jobs by an hour twice a year. Pin everything to UTC and convert only for display.everyMinute job, monitored externally) or a staleness alert on your job output table. This is the single most common production incident with any application-layer scheduler.config:cache is load-bearing. Config-driven schedules read cleanly at boot, but anything non-serializable (closures, service instances) in config/schedules.php will break config:cache — or worse, break only in production where the cache is warm.php artisan schedule:list (is it registered?) → php artisan schedule:test (does it run on demand?) → php artisan schedule:run -v (does the dispatcher see it as due?) → check the lock in your cache store (is a stale lock suppressing it?). Work in that order; it localizes the fault fast.Schedule::macro() for repeated configurations (e.g. ->businessHours()), and the ScheduledTaskStarting/ScheduledTaskFinished/ScheduledTaskFailed events for cross-cutting concerns like metrics or audit rows — that's where you'd hook a lightweight "run history" if you need one, rather than adding a dependency for it.How can I help you explore Laravel packages today?