Laravel Scheduled Jobs Not Completing? Monitor the Worker, Too
php artisan schedule:run can finish successfully while your billing reconciliation never executes. The scheduler dispatched a job, Redis accepted it, and the worker responsible for processing it stopped.
Your scheduler heartbeat remains green because it measures dispatch infrastructure. Your business data remains stale because nothing measures completion.
When investigating a Laravel scheduled job not running, follow the execution chain through the queue worker and the database commit. A successful scheduler invocation is only the first part of that chain.
For the basic command setup, schedule, and alert verification, use Laravel scheduler monitoring. Continue here when your task dispatches queued work and you need to trace it through worker completion.
1. The Illusion of schedule:run: The False Positive Heartbeat
Consider this cron entry:
* * * * * cd /var/www/app && php artisan schedule:run && /usr/local/bin/report-scheduler-heartbeat
The heartbeat tells you that schedule:run returned zero. It does not prove that every asynchronous task dispatched during that invocation finished.
Suppose your schedule dispatches a reconciliation job:
use App\Jobs\ReconcileBilling;
use Illuminate\Support\Facades\Schedule;
Schedule::job(
new ReconcileBilling(now('UTC')->subDay()->toDateString()),
'billing',
'redis',
)->dailyAt('00:00')->timezone('UTC');
With an asynchronous Redis connection, the sequence is:
00:00:00 Scheduler finds the reconciliation task due
00:00:01 Laravel pushes the job onto Redis
00:00:01 Scheduler invocation finishes
00:00:01 Scheduler heartbeat arrives
00:00:02 No billing worker is available
00:30:00 Reconciliation is still waiting
01:00:00 Scheduler heartbeats continue arriving
Nothing about successful dispatch establishes that a worker called handle().
A worker may have crashed, stopped after deployment, become stuck on another job, or been configured to consume the wrong queue. A large backlog can produce the same business symptom without any process crashing.
The queue can retain the job while the scheduler continues reporting healthy invocations. Since the reconciliation has not executed, there may be no exception and no entry in failed_jobs.
2. Tracing the Laravel Execution Chain: Where the Cable Snaps
Use this diagnostic flow:
System cron or platform scheduler
|
| Correct user, PHP binary, release path, environment?
v
php artisan schedule:run
|
| Task due? Correct timezone? Maintenance or overlap suppression?
v
Schedule definition
|
| routes/console.php, bootstrap/app.php,
| or app/Console/Kernel.php in an older application
v
Queue dispatch
|
| Correct connection and queue name?
v
Queue storage
|
| Redis or database connection reachable?
| Job waiting, reserved, delayed, or failed?
v
Queue worker
|
| Horizon for Redis, or queue:work under a process manager
| Worker consuming this exact connection and queue?
v
Job handle()
|
| Dependencies available? Transactions committed?
v
Validated business result
|
v
Completion heartbeat
Horizon supervises Redis queue workers. The operating-system Supervisor service is a separate process manager that may keep Horizon or ordinary queue:work processes running.
Start with the deployed application
Run diagnostic commands from the deployed release, with the relevant application environment and operating-system permissions:
php artisan schedule:list
php artisan horizon:status
php artisan queue:failed
Use horizon:status only where Horizon is installed. For ordinary workers, inspect the process manager and the actual queue:work arguments.
Check the connection and queue together. A worker consuming redis/default does not automatically process jobs on redis/billing.
Stale configuration after deployment
There are two distinct stale-state problems:
- The cached Laravel configuration contains old values.
- Long-running workers retain the configuration and code loaded when they started.
Changing .env does not update either state automatically.
Once configuration is cached, Laravel does not load .env during normal Artisan execution. Read environment variables in configuration files and use config() in application code. Laravel configuration caching
During deployment, rebuild configuration and gracefully replace the relevant workers:
php artisan config:cache
# For ordinary queue:work processes:
php artisan queue:restart
# For Horizon-managed workers, use this instead:
php artisan horizon:terminate
The process manager must restart the stopped processes. Verify that the replacement workers use the intended release and configuration. Laravel documents that queue workers are long-lived and require deployment handling. Queue workers and deployment
Overlap locks can suppress dispatch
Scheduler-level withoutOverlapping() uses a cache lock. An active or stale lock can prevent a due task from executing. Laravel provides schedule:clear-cache for clearing scheduler locks when recovery requires it. Preventing task overlaps
Inspect the actual running work before clearing locks. Removing a legitimate lock can create concurrent execution.
Also distinguish two different mechanisms:
- Scheduler
withoutOverlapping(): protects the scheduled event. - Queue
WithoutOverlappingmiddleware: protects worker-side job processing.
A scheduler event that only dispatches a job may release its lock long before the queued business work finishes. It is not automatically a lock covering the entire reconciliation.
Queue middleware can also release or skip an attempt before handle() executes, depending on its configuration.
Maintenance mode affects both layers
Scheduled tasks normally do not run during maintenance mode unless explicitly permitted with evenInMaintenanceMode(). Laravel scheduler maintenance behavior
Horizon also stops processing queued jobs during maintenance unless its supervisor configuration allows forced processing. Horizon maintenance behavior
These are intentional controls, not necessarily application failures. But an unexpected maintenance state can leave a critical outcome missing while the web application’s deployment process appears otherwise normal.
3. Choosing the Correct Heartbeat Topology: Job-Level vs. Scheduler-Level
Monitor the boundary you actually depend on.
| Heartbeat location | What it establishes | What it does not establish |
|---|---|---|
After schedule:run | Scheduler invocation reached the reporting point | Every due task executed |
| After queue dispatch | Job submission reached the reporting point | A worker processed it |
After successful handle() business work | That job reached its completion boundary | Other jobs or downstream batches completed |
| After committed work and explicit validation | Required result passed application checks | Correctness beyond those checks |
Keep scheduler heartbeats if they help diagnose infrastructure. Give critical asynchronous outcomes their own monitors.
Do not send scheduler and reconciliation heartbeats to the same monitor URL. Frequent scheduler check-ins would conceal missing reconciliation completions.
Put the primary completion signal in the job
For a self-contained queued job, the clearest location is near the end of handle():
Execute required work
→ Validate the result
→ Commit the transaction
→ Send completion heartbeat
If the job dispatches additional jobs and returns, its handle() method is not the final completion boundary. Report from a successful batch or chain completion path after all required work and final validation succeed.
Use framework events with explicit scope
Laravel exposes both scheduling and queue events:
Illuminate\Console\Events\ScheduledTaskFinishedconcerns the scheduled task. For an asynchronous dispatch task, that boundary can be job submission.Illuminate\Queue\Events\JobProcessedis closer to worker execution and can support centralized instrumentation. Laravel exposes it throughQueue::after(...). Laravel queue events
Neither event is inherently a business-success assertion.
In the current worker implementation, JobProcessed can occur after a handler path returns even when an attempt was released. A listener must inspect the job’s state and a durable business-completion record rather than treating every event as success. Laravel worker implementation
A centralized listener should:
- Identify the specific job and logical run.
- Exclude released and failed attempts.
- Confirm that the required completion record was committed.
- Report only the outcome associated with that monitor.
For one critical reconciliation job, an explicit post-commit heartbeat inside handle() is easier to inspect.
4. Production Implementation: Securing Your Heartbeat in a Queued Job
The implementation must preserve two boundaries:
- A failed reconciliation must remain a failed job.
- A failed reporting request must not cause already-committed billing work to run again merely to deliver a heartbeat.
Load the URL through configuration
Provision the private monitor URL as an environment variable:
PINGCRON_RECONCILIATION_URL="REPLACE_WITH_YOUR_PRIVATE_MONITOR_SUCCESS_URL"
Add this entry inside the returned array in config/services.php:
'pingcron' => [
'reconciliation_url' => env('PINGCRON_RECONCILIATION_URL'),
],
The job reads:
config('services.pingcron.reconciliation_url');
This uses the requested environment variable while remaining compatible with config:cache. Keep the URL out of source control, exception output, and queue payloads.
Define the application-specific completion contract
The queued job below delegates business logic to an existing application service:
App\Services\BillingReconciler
This is an application dependency, not a Laravel API. Adapt it to your reconciliation implementation. The example expects:
public function reconcileAndValidate(
\Illuminate\Database\Connection $connection,
string $period,
): void;
That method must:
- Perform the required database writes using the supplied connection.
- Validate reconciliation invariants and throw if they fail.
- Persist completion for the logical period.
- Safely handle repeat execution through database constraints, locking, or an idempotent run ledger.
- Return only after synchronous work finishes.
It must not swallow exceptions, commit the caller’s transaction manually, or merely dispatch more work.
For a large reconciliation, avoid wrapping hours of processing in one transaction. Process bounded units and use a final completion transaction after every required unit has succeeded.
Queued job implementation
Save this as app/Jobs/ReconcileBilling.php:
<?php
namespace App\Jobs;
use App\Services\BillingReconciler;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use LogicException;
use RuntimeException;
use Throwable;
final class ReconcileBilling implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
// Includes business work, validation, and heartbeat delivery.
public int $timeout = 300;
public bool $failOnTimeout = true;
public function __construct(public string $period)
{
$this->onConnection('redis');
$this->onQueue('billing');
// If dispatched inside a transaction, wait for that transaction
// to commit before making the job available to workers.
$this->afterCommit();
}
public function backoff(): array
{
return [60, 300];
}
public function handle(BillingReconciler $reconciler): void
{
$connection = DB::connection();
// This implementation owns the outermost business transaction.
if ($connection->transactionLevel() !== 0) {
throw new LogicException(
'Reconciliation requires a clean transaction boundary.'
);
}
// Business failures propagate to Laravel's queue retry handling.
// Do not place the heartbeat inside this closure.
$connection->transaction(
function () use ($connection, $reconciler): void {
$reconciler->reconcileAndValidate(
$connection,
$this->period,
);
},
3,
);
if ($connection->transactionLevel() !== 0) {
throw new LogicException(
'Reconciliation left an open transaction.'
);
}
// This example reconciles yesterday's UTC business period.
// An old backlog item must not stand in for today's expected result.
$expectedPeriod = CarbonImmutable::now('UTC')
->subDay()
->toDateString();
if ($this->period !== $expectedPeriod) {
Log::notice(
'Historical reconciliation completed; current heartbeat omitted.',
['period' => $this->period],
);
return;
}
// Reporting failures are isolated from committed business work.
try {
$url = config('services.pingcron.reconciliation_url');
if (
! is_string($url)
|| filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https'
) {
throw new RuntimeException(
'Invalid reconciliation heartbeat configuration.'
);
}
$response = Http::timeout(10)
->retry(3, 5000)
->connectTimeout(3)
->withoutRedirecting()
->post($url);
if (! $response->successful()) {
throw new RuntimeException(
'Heartbeat did not receive a successful response.'
);
}
} catch (Throwable) {
// Never log the URL, response body, or HTTP exception:
// they may contain the private monitor credential.
Log::warning(
'Reconciliation committed; heartbeat delivery failed.',
['period' => $this->period],
);
}
}
}
Why the transaction placement matters
Laravel commits a successful transaction closure and rolls it back when the closure throws. Its transaction retry argument permits retries for deadlocks. Laravel database transactions
The HTTP request occurs only after that transaction returns and the code confirms there is no remaining open transaction.
This avoids a dangerous sequence:
Write data
→ Send success heartbeat
→ Commit fails
→ Database rolls back
→ Monitor remains green
The job also rejects an ambient outer transaction. Without that guard, returning from a nested transaction would not necessarily mean the final database commit had happened.
The constructor’s afterCommit() serves a different purpose: it delays dispatch when the producer is inside a transaction. It does not replace the post-commit boundary inside the worker.
HTTP retries must not become billing retries
retry(3, 5000) means up to three total HTTP attempts, separated by five-second delays. With ten-second request timeouts, allow roughly forty seconds for that reporting path, plus overhead. Laravel HTTP client retries
Only the reporting request is retried there.
If all reporting attempts fail, the job logs the problem and returns without throwing a business exception. PingCron can still detect the missing receipt. The database work remains committed.
This means a queue-level success can coexist with failed heartbeat delivery. Investigate both the business completion record and reporting logs before rerunning anything.
There remains a crash window between database commit and HTTP reporting. A simple heartbeat cannot make those two systems atomic. If durable delivery is required, write an outbox event in the business transaction and deliver it separately, with controls preventing stale events from masquerading as current completion.
Keep worker timeouts consistent
The job timeout includes reporting time. For this example, an illustrative hierarchy is:
Job timeout: 300 seconds
Horizon timeout: 330 seconds
Redis retry_after: 360 seconds
Horizon’s timeout should exceed job-level timeouts and remain below the queue connection’s retry_after. Otherwise, execution can be terminated prematurely or become eligible for duplicate processing. Horizon timeout guidance
Configure database and network timeouts as well. Do not rely exclusively on killing the worker to interrupt blocking I/O.
5. Calculating Queue Grace Periods and Alert Testing
Create a monitor specifically for completed reconciliation:
| Setting | Example |
|---|---|
| Name | billing-reconciliation-completed |
| Schedule | 0 0 * * * |
| Timezone | UTC |
| Grace | Derived from queue and execution budgets |
| Signal | Successful reconciliation of yesterday’s UTC period |
| Destination | Responsible team’s Slack, Discord, or Email |
PingCron’s Laravel guidance distinguishes scheduler invocation, queue dispatch, and worker completion. The receipt should follow the work your application actually depends on. PingCron Laravel monitoring
Queue wait belongs in the grace period
A job dispatched at midnight can wait behind thousands of other jobs before a worker reserves it.
The completion budget is:
Grace period >=
scheduler and dispatch delay
+ queue waiting time
+ execution and validation time
+ permitted retry delays and additional attempts
+ heartbeat delivery time
+ operating margin
For example:
Scheduling and dispatch allowance: 1 minute
Combined queue waiting allowance: 15 minutes
Up to three five-minute attempts: 15 minutes
Retry backoff allowance: 6 minutes
Operating margin: 8 minutes
Total grace: 45 minutes
The five-minute attempt budget already includes HTTP reporting. Do not count that time twice.
These numbers are illustrative, not a recommendation to tolerate arbitrary backlog. Measure queue wait and job duration, including waits after retries.
If reconciliation has a strict deadline, isolate it on a dedicated queue with suitable worker capacity. Increasing grace indefinitely hides a capacity problem.
Verify one complete scheduled execution
Before relying on monitoring:
- Confirm the job was dispatched onto the intended connection and queue.
- Confirm a worker executed it.
- Inspect the committed reconciliation result.
- Confirm the corresponding PingCron receipt.
- Verify the configured notification destination.
- Observe a real scheduled run separately from manual dispatch.
If you replay historical periods, keep them distinguishable from the current expected outcome. The period guard in the example prevents an old backlog item from sending the current monitor’s success signal.
Pause an isolated Horizon supervisor
Use a staging application, dedicated test queue, and separate monitor. Do not stop production billing workers to test an alert.
Identify the actual Horizon supervisor consuming that queue, then pause it:
php artisan horizon:pause-supervisor supervisor-billing-test
php artisan horizon:supervisor-status supervisor-billing-test
Use the deployed supervisor’s actual name. If several supervisors or Horizon instances consume the test queue, ensure no remaining consumer can process the test job.
Horizon provides supervisor-specific pause, continue, and status commands. Horizon process control
Before beginning the absence test, let any already-running test job finish. Pausing consumption is not evidence that an active job has stopped.
Leave the scheduler and PingCron monitor enabled. Then verify:
Scheduler executes
→ Test job is dispatched
→ Job remains waiting
→ No completion receipt arrives
→ Grace expires
→ External notification arrives
This proves the difference between scheduler health and worker completion.
Restore processing:
php artisan horizon:continue-supervisor supervisor-billing-test
Confirm that the worker resumes and the test outcome completes. If the queued run is now historical, the freshness guard may intentionally omit its heartbeat; verify the next current scheduled period instead.
Test rollback and reporting failure separately
Two additional tests protect the implementation’s most important boundaries:
| Test | Expected result |
|---|---|
| Reconciliation throws before commit | Database changes roll back; no heartbeat request |
| Reconciliation commits but HTTP delivery fails | Completion remains committed; reporting warning appears; no business retry caused by the HTTP failure |
Laravel’s HTTP fakes can assert request behavior in automated tests. Transaction-sensitive tests must also exercise a real commit boundary rather than relying entirely on an outer test transaction.
Finally, verify that notification delivery actually reaches the intended channel. A recorded incident and a message received by an operator are separate observations.
6. Conclusion: Monitor the Business Outcome
A running scheduler establishes that Laravel can evaluate scheduled work. Successful dispatch establishes that a queue accepted a job. Neither establishes that reconciliation finished.
Track the entire path: dispatch, queue wait, worker execution, validation, commit, and completion reporting. Keep scheduler monitoring for diagnosis, but give critical asynchronous outcomes their own independent expectation.
Create a free PingCron monitor for one high-value Laravel process. Send its heartbeat after committed, validated work, then pause an isolated test worker to prove that missing completion produces an external alert.
Related monitoring guides
For configuration and setup, see the Laravel scheduler monitoring guide.