← Monitoring guides

By PingCron · Updated

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:

  1. The cached Laravel configuration contains old values.
  2. 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 WithoutOverlapping middleware: 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 locationWhat it establishesWhat it does not establish
After schedule:runScheduler invocation reached the reporting pointEvery due task executed
After queue dispatchJob submission reached the reporting pointA worker processed it
After successful handle() business workThat job reached its completion boundaryOther jobs or downstream batches completed
After committed work and explicit validationRequired result passed application checksCorrectness 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\ScheduledTaskFinished concerns the scheduled task. For an asynchronous dispatch task, that boundary can be job submission.
  • Illuminate\Queue\Events\JobProcessed is closer to worker execution and can support centralized instrumentation. Laravel exposes it through Queue::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:

  1. Identify the specific job and logical run.
  2. Exclude released and failed attempts.
  3. Confirm that the required completion record was committed.
  4. 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:

SettingExample
Namebilling-reconciliation-completed
Schedule0 0 * * *
TimezoneUTC
GraceDerived from queue and execution budgets
SignalSuccessful reconciliation of yesterday’s UTC period
DestinationResponsible 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:

  1. Confirm the job was dispatched onto the intended connection and queue.
  2. Confirm a worker executed it.
  3. Inspect the committed reconciliation result.
  4. Confirm the corresponding PingCron receipt.
  5. Verify the configured notification destination.
  6. 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:

TestExpected result
Reconciliation throws before commitDatabase changes roll back; no heartbeat request
Reconciliation commits but HTTP delivery failsCompletion 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.