mczolko/heroku-scheduler-bundle
A Symfony bundle that integrates Heroku Scheduler with your app, letting you register and run recurring console commands and scheduled jobs on Heroku dynos without separate cron infrastructure.
This is a Symfony bundle, not a Laravel package. There is no service provider, no auto-discovery, and no Laravel integration of any kind. A Laravel developer should treat it as a reference implementation, not a dependency.
If you're evaluating the source:
composer.json first. A 2016-era bundle will constrain Symfony ~2.3|~3.0 and PHP >=5.5, which will refuse to install on a modern Laravel app (PHP 8.x, Symfony 5/6/7 components).src/ and look at three things: the DependencyInjection/ extension (what config keys exist), the Command/ directory (the single entrypoint command), and any lock/state service. That lock service is the only genuinely reusable idea in the bundle.php bin/console scheduler:run).The problem it solves is narrow: Heroku Scheduler is an addon that fires a one-off dyno on a fixed cadence (every 10 minutes, hourly, or daily) and runs a shell command. It has no concept of a schedule file, no overlap protection, and no coordination between runs. This bundle wraps that into a single "run the scheduler" command with locking.
The Laravel equivalent you should actually build:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
$schedule->command('reports:generate')->everyTenMinutes()->withoutOverlapping();
$schedule->job(new SyncOrders)->hourly()->onOneServer();
}
Then point Heroku Scheduler at exactly one command:
php artisan schedule:run
Set it to run every 10 minutes, and let Laravel's Kernel::schedule() decide what actually fires. That is the whole first use case, and it replaces this bundle entirely.
Single entrypoint, always. The core pattern (and the bundle's main contribution) is one command that Heroku Scheduler invokes, which then dispatches the real work. Never register five different commands in the Heroku Scheduler dashboard — it drifts, it's unauditable, and you lose overlap control. In Laravel this is schedule:run plus a scheduling definition in code.
Frequency mapping. Heroku Scheduler only offers 10-minute, hourly, and daily granularity. Anything you write as ->everyMinute() or ->everyFiveMinutes() in Laravel will silently be throttled to whatever cadence schedule:run fires at. Design around 10 minutes as your floor unless you're willing to pay for more frequent one-off dynos.
Overlap protection is mandatory, not optional. Heroku Scheduler starts a new dyno on schedule regardless of whether the previous run finished. A 15-minute job scheduled every 10 minutes will stack. Use ->withoutOverlapping() for per-task locks and ->onOneServer() when you run multiple web/worker dynos.
Lock backend must be shared. onOneServer() requires a cache store that all dynos can reach. On Heroku that means the Redis or Memcached addon (CACHE_DRIVER=redis). The default file driver writes to the dyno's ephemeral filesystem, so two dynos each get their own "lock" and both run. This is the single most common failure mode in Heroku-scheduled apps, and it's exactly the trap the old bundle's lock service was built to avoid with a shared store.
Keep tasks short, offload long work. A Heroku Scheduler run is a one-off dyno that boots, executes, and dies. Booting a full framework for a 2-second task is wasteful; booting it for a 45-minute task is risky (dyno cycling). Wrap heavy work in dispatch(new SomeJob) and let the worker dyno process it.
If you must run the Symfony bundle (i.e., you're in a Symfony app, not Laravel): register it in AppKernel::registerBundles(), import its config under config.yml, and tag your commands so the entrypoint can discover them. It will not work in Laravel — there is no bridge.
composer.json of a Laravel 8/9/10/11 app; Composer will either fail resolution or you'll pin ancient transitive dependencies.withoutOverlapping() locks expire. Laravel's default is 24 hours. A crashed job can hold the lock and block future runs. Pass an explicit TTL: ->withoutOverlapping(10).->dailyAt('09:00') means 09:00 UTC. Use ->timezone('America/New_York') explicitly or you'll get off-by-hours reports.schedule:run returning non-zero because one task threw will make the dashboard look broken. Check task-level logging rather than the Scheduler dashboard status.heroku run php artisan schedule:run to execute manually, and php artisan schedule:list to confirm the definition is loaded. Tail output with heroku logs --tail --ps scheduler. Locally, php artisan schedule:work mirrors the cadence without Heroku.schedule:run is a no-op if the minute doesn't match. Running it outside a matching minute looks like "nothing happened." That's expected, not a bug — use schedule:test (Laravel 9+) to force a task.ScheduledTaskStarting / ScheduledTaskFinished / ScheduledTaskFailed events for metrics, ->before()/->after()/->onSuccess()/->onFailure() hooks per task, and ->pingBefore()/->thenPing() for healthcheck integration.How can I help you explore Laravel packages today?