php-quartz/bundle
PHP Quartz Bundle brings Quartz-style job scheduling to Laravel and PHP applications, giving you a structured way to define, schedule, and manage recurring tasks. It helps organize background jobs and time-based workflows while reducing reliance on hand-written cron entries.
Before anything else: read the state of the package. php-quartz/bundle is archived, read-only, last released 2019-06-10, 2 stars, 0 dependents. That means no PHP 8 support, no Symfony 5/6 support, no security fixes, and effectively no community answers when something breaks. Treat this as "vendor a small library" rather than "install a dependency."
composer.json on the default branch and check the require block: the php constraint and the symfony/* constraints tell you immediately whether you can use it at all. A 2019 bundle will typically sit around PHP 7.x and Symfony 3.4/4.x. If you're on Symfony 6+, stop here and read the migration note in Implementation Patterns instead.src/ as an internal bundle. Pin an exact commit — there will never be a patch release.config/bundles.php for Symfony 4+, AppKernel::registerBundles() for 3.x).DependencyInjection/Configuration.php — it is the authoritative documentation for this package, more so than the README. The bundle alias defined there (something like php_quartz) is the YAML key you'll use in config/packages/.Command/. This is your entry point for actually running schedules; the bundle's whole value proposition is that it wires that command to a container-managed scheduler.execute()-style method, register it as a service, point a cron trigger at it in config, then run the console command in the foreground to confirm it fires. Don't start with a daemon, a persistent store, or multiple schedulers.Where to look first, in order: DependencyInjection/Configuration.php → Resources/config/*.xml|yaml (shows which services and tags exist) → Command/ → the core php-quartz/* packages it wraps (that's where job/trigger/store semantics actually live).
Config-driven registration (the intended path). The bundle's selling point is that schedules live in configuration and job logic lives in services. Keep that split: job classes should be plain services with no scheduling knowledge, and the schedule should be declarative. This makes jobs trivially unit-testable and lets you change cadence without touching code.
Service-tag registration (the escape hatch). For jobs that need constructor dependencies or dynamic scheduling, use the container tag the bundle defines for jobs/triggers (confirm the tag name in Resources/config/) and register via services.yaml. Prefer this over config keys when your schedule is computed rather than literal.
Trigger selection. Quartz semantics give you two families:
| Trigger | Use for |
|---|---|
| Cron | Calendar-shaped schedules ("weekdays at 02:00") |
| Simple/interval | "Every N seconds/minutes", fixed-repeat counts, one-shot delayed jobs |
Cron triggers are where Quartz earns its keep over a plain sleep loop — day-of-week and month fields, misfire handling, and explicit timezones.
Running it. Two operating modes, and you should decide explicitly which one you're in:
Most teams end up with the daemon for fine-grained work and a system cron heartbeat that restarts it if it dies.
Integration with the rest of the app. Jobs are container services, so they compose normally: inject repositories, mailers, HTTP clients. If you're doing heavy work, have the job enqueue a Symfony Messenger message rather than doing the work inline — that gives you retries, failure transport, and idempotency handling you'd otherwise have to build on top of Quartz listeners.
Cross-cutting concerns via listeners. Use the bundle's listener extension point for structured logging (job name, start/end, duration, outcome), metrics emission, and alerting on failure. Do not put retry logic in every job class — centralize it in a listener or a base job class so behaviour is uniform.
Testing. Because scheduling is config and logic is a service, unit tests instantiate the job directly and call its execute method — no scheduler involved. For integration tests, build the scheduler in-process with a RAM store and fire triggers manually rather than sleeping. Never write a test that depends on wall-clock time passing.
Migration path. If you're on a modern Symfony, symfony/scheduler (6.3+) plus Messenger covers the same ground with first-party support: #[AsCronTask], RecurringMessage, and a messenger:consume worker you already run. Port the schedules and the job bodies — those translate cleanly — and drop the Quartz layer entirely. Given this package is archived with zero dependents, planning that exit before you build on it is not pessimism, it's the correct engineering call.
The compatibility cliff is the real gotcha. A 2019 bundle almost certainly uses APIs removed in Symfony 5 (ContainerAwareCommand, getRootNode() without TreeBuilder, DefinitionDecorator, direct ContainerBuilder service manipulation). Even if Composer resolves, container compilation may fail with opaque "service not found" errors. Run bin/console cache:clear and bin/console debug:container early to surface DI problems before they hide inside a command that silently does nothing.
Duplicate execution across app servers is the #1 production bug with any in-process scheduler. If your daemon runs on three web nodes, every job fires three times. Quartz-style schedulers only solve this if you wire a clustered/persistent job store with row locking; the default RAM store definitely does not. If you can't configure a persistent store, run the scheduler on exactly one node (dedicated role, leader election, or a single supervisor group) and let the other nodes do only web traffic.
RAM store loses everything on restart. Misfire and recovery semantics only mean anything with a persistent JobStore. Confirm what store the bundle wires by default in Resources/config/ — with in-memory storage, a crash mid-job leaves no record and no retry.
Long-running workers leak. In daemon mode, PHP accumulates memory and your Doctrine EntityManager will eventually close after an exception. Clear the EM per job (or per N jobs), and restart the process on a schedule. Check whether the command exposes a --clear-entity-managers-style flag; if not, Doctrine\ORM\EntityManagerInterface::clear() from a job listener is the workaround.
Timezone and DST. Cron triggers evaluate in a specific timezone. If your PHP date.timezone, your database, and the trigger's timezone disagree, "daily at midnight" drifts or double-fires around DST transitions. Set the trigger timezone explicitly rather than relying on defaults, and test the spring-forward/fall-back boundaries if the schedule is business-critical.
Overlap and misfire policy. A job that takes longer than its interval will either pile up threads or silently skip. Decide per-job: forbid concurrent execution (blocking), or allow it with an idempotency guard. This is the setting most teams forget until a slow query causes a thundering herd.
Job identity matters. Jobs and triggers are keyed by name/group in Quartz semantics. Renaming a job class or its config key can orphan the old trigger in a persistent store or create a duplicate. Treat job names as stable identifiers once deployed.
Debugging a "nothing happens" schedule. In order: (1) does debug:container show your job service and is it public/tagged? (2) is the trigger actually registered — dump the scheduler's job/trigger list from a listener at startup? (3) is the timezone what you think it is? (4) is the daemon even running? Add a startup listener that logs every registered trigger and its next-fire time; it will save hours.
Extension points worth knowing. Custom triggers, custom job factories, and scheduler listeners are the intended customization surface. If you need behaviour the bundle doesn't expose (a retry decorator, a distributed lock, a dead-letter path), implement it as a listener wrapping job execution rather than patching the bundle — your fork will never get upstream fixes, so keep the diff from upstream as small as possible.
Watch the config key namespace. Symfony bundles silently ignore unknown config keys in some setups, or throw on them in others depending on TreeBuilder usage. If a scheduling change has no effect, verify the key is actually being read by checking the compiled container in var/cache/dev/.
How can I help you explore Laravel packages today?