# Console Plugin — Complete Reference

The Console plugin (`plugins/webkul/console`) adds a real-time job monitor to the Cydekick admin panel.

- **Header widget** — dark pill in the topbar showing the currently-running job with a pulsing green dot; click to open the panel.
- **Console panel** — slide-in right panel with a terminal aesthetic, grouped by time, with tabs: **Jobs** / Running / Activity / Errors. Default tab is Jobs.

Both poll every **3 seconds** via `wire:poll.3000ms`. No WebSocket needed.

---

## File map

```
plugins/webkul/console/
├── src/
│   ├── ConsoleServiceProvider.php        — registers Livewire components, queue event listeners, render hooks
│   ├── Models/JobActivityLog.php         — Eloquent model for log rows
│   ├── Services/JobLogger.php            — static helper used by every job/service
│   └── Livewire/
│       ├── JobStatusBar.php              — topbar pill (polls every 3s)
│       └── JobConsolePanel.php           — slide-in panel (polls when open)
├── database/migrations/
│   ├── 2026_05_11_100001_create_job_activity_logs_table.php
│   ├── 2026_07_12_000002_add_batch_columns_to_job_activity_logs_table.php
│   ├── 2026_07_13_000001_add_processing_started_at_to_job_activity_logs_table.php
│   └── 2026_07_13_000002_add_queued_to_job_activity_logs_status.php
└── resources/views/
    ├── livewire/
    │   ├── job-status-bar.blade.php
    │   └── job-console-panel.blade.php
    └── components/
        ├── status-bar-hook.blade.php     — injected via GLOBAL_SEARCH_BEFORE
        └── console-panel-hook.blade.php  — injected via BODY_END
```

---

## Database schema — `job_activity_logs`

| Column | Type | Description |
|---|---|---|
| `id` | int PK | |
| `job_class` | varchar | PHP class name (for filtering / dedup) |
| `display_name` | varchar 500 | Main line shown in the console |
| `status` | enum | `running` / `queued` / `ok` / `error` / `warn` / `info` |
| `progress` | JSON | Batch: `{snapshot_count, snapshot_ts}` for rate calculation. Single-job: `{done, total, percent}` |
| `message` | text nullable | Pre-formatted detail block rendered below `display_name` in a dark `white-space:pre` panel |
| `total_items` | int nullable | **Batch only** — expected number of child jobs. Non-null = this is a batch log entry |
| `processed_items` | int | **Batch only** — count of successful child ticks (atomic increment) |
| `failed_items` | int | **Batch only** — count of failed child ticks (atomic increment) |
| `started_at` | timestamp nullable | Set when `startBatch()` / `running()` is called |
| `processing_started_at` | timestamp nullable | **Batch only** — stamped on the first `batchTick()` call (excludes queue-build time from rate/ETA) |
| `finished_at` | timestamp nullable | Set on completion |
| `duration_ms` | int nullable | Auto-calculated on `ok()`/`error()` from `started_at` |
| `created_at` / `updated_at` | timestamps | Standard — `updated_at` is refreshed by every `batchTick()` for the watchdog |

---

## JobLogger API

Import in any job, command, service, or controller:

```php
use Webkul\Console\Services\JobLogger;
```

### `running(string $displayName, ?string $jobClass = null): int`

Creates a `running` entry. **Returns the log ID** — save this to update the entry later.

```php
$logId = JobLogger::running('Importing Shopify products · starting…');
```

### `progress(int $logId, string $displayName, int $done, int $total): void`

Updates the display name and sets the `progress` JSON column. The console blade renders a progress bar automatically when `progress` is present.

Throttle calls — every 50–100 items is enough. The console only polls every 3s so calling every row is wasted DB writes.

```php
if ($i % 100 === 0) {
    JobLogger::progress($logId, "Importing products · {$i} / {$total}", $i, $total);
}
```

### `updateName(int $logId, string $displayName): void`

Updates the display name only — no progress bar. Use when total is unknown (streaming from an API, cursor query, etc).

```php
JobLogger::updateName($logId, "Fetching variants · {$count} so far…");
```

### `ok(string $message, ?int $logId = null, ?string $details = null): void`

Closes a running entry as successful. If `$logId` is null, creates a new one-shot OK entry.

`$details` is stored in the `message` column and rendered as a dark `white-space:pre` block below the main line. Use it for per-item breakdowns, price calculations, etc.

```php
// Simple close
JobLogger::ok("Import complete · {$total} products synced", $logId);

// With breakdown detail block
$breakdown = "  Cost       £24.07\n  Markup     28.0% (+£6.74)\n  Sell       £37.71";
JobLogger::ok("Pricing · GA2096 · Shopify Somerset4x4", $logId, $breakdown);
```

### `error(string $message, ?int $logId = null): void`

Closes a running entry as failed, or creates a new one-shot error entry.

```php
JobLogger::error("Import failed: {$e->getMessage()}", $logId);
```

### `info(string $message): void` / `warn(string $message): void`

One-shot entries, no log ID. Blue (info) or yellow (warn).

```php
JobLogger::info('Nightly inventory snapshot complete');
JobLogger::warn('Shopify rate-limit hit · backing off 2s');
```

### `startBatch(string $displayName, int $total, ?string $jobClass = null, bool $queued = false): int`

Opens a **batch** log entry — a persistent entry that tracks progress across many child jobs. Returns a `$batchLogId` that must be passed to every child dispatch.

`$total` is the initial estimated count (e.g. all products × channels before fixed-price exclusions). Use `updateBatchTotal()` to correct it after the dispatch loop finishes.

`$queued = true` creates the entry with `status = 'queued'` instead of `running`. Use this when a second batch (e.g. the price sync phase) is dispatched at the same time as the first but will only start running after the first completes. The entry shows as **NEXT** in the console (dashed card, grey text) until the first `batchTick()` arrives, at which point it atomically transitions to `running`.

```php
// Primary batch — starts processing immediately
$batchLogId = JobLogger::startBatch('Repricing · Banwell Website · 20,507 SKUs', $totalJobs, self::class);

// Secondary batch — queued behind the first; shown as "NEXT" until its first tick
$priceSyncLogId = JobLogger::startBatch('Price Sync · Shopify · Banwell Website', $totalJobs, self::class, queued: true);
```

### `updateBatchTotal(int $logId, int $total): void`

Corrects the `total_items` count after the dispatch loop completes. Call this **once** after the loop, with the actual number of jobs dispatched (excluding skipped fixed-price rows etc.). This is critical for accurate ETA.

```php
JobLogger::updateBatchTotal($batchLogId, $dispatched);
```

### `batchTick(int $logId, bool $failed = false): void`

**Called by each child job** when it completes — atomically increments `processed_items` (or `failed_items`). Refreshes `updated_at` (keeps the watchdog alive). Auto-closes the batch log when `processed + failed >= total_items`.

If the batch was created with `queued: true`, the **first** `batchTick()` call atomically transitions it from `queued → running` and stamps `processing_started_at`. This is a DB-level atomic guard (`WHERE status = 'queued'`) so concurrent workers can't double-transition.

```php
// In child job handle():
JobLogger::batchTick($this->batchLogId);

// In child job failed():
JobLogger::batchTick($this->batchLogId, failed: true);
```

**Never call `batchTick` for items you skipped** (fixed-price, no-op rows). The count must match what `updateBatchTotal` reports or the batch will never auto-close.

### `prune(int $days = 7): int`

Deletes log entries older than N days. Call from the scheduler.

```php
$schedule->call(fn () => JobLogger::prune(7))->daily();
```

---

## Elapsed time — how it works

Every job automatically gets a live elapsed time counter with **zero per-job code**. Here's the full picture:

### What the console shows

| Where | What | When |
|---|---|---|
| Status bar pill | amber `2m 15s` badge between `running │` and job name | While running (counts every second, client-side) |
| Running card (panel) | amber `2m 15s` in top-right corner | While running |
| Completed/failed log row | grey `2m 15s` in right column | After completion |

### How it's recorded

- `started_at` is set automatically for every log entry:
  - Auto-logged jobs: `ConsoleServiceProvider` sets `started_at = now()` on `JobProcessing` event
  - Explicit jobs: `JobLogger::running()` sets `started_at = now()`
- `duration_ms` is calculated and stored when the job closes:
  - `JobLogger::ok($msg, $logId)` → `duration_ms = started_at.diffInMilliseconds(now())`
  - `JobLogger::error($msg, $logId)` → same
  - Auto-logged `JobProcessed`/`JobFailed` events → same

### How the live counter works (client-side)

The status bar and panel running cards use Alpine.js to count up from `started_at` every second:

```js
// Pattern used in both status-bar and console-panel blades:
{
    elapsed: Math.max(0, Math.floor((Date.now() - new Date(isoString).getTime()) / 1000)),
    init() {
        const startMs = new Date(isoString).getTime();
        setInterval(() => {
            this.elapsed = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
        }, 1000);
    },
    get elapsedLabel() {
        const s = this.elapsed;
        if (s < 60) return s + 's';
        const m = Math.floor(s / 60), r = s % 60;
        if (m < 60) return m + 'm ' + String(r).padStart(2, '0') + 's';
        return Math.floor(m / 60) + 'h ' + String(m % 60).padStart(2, '0') + 'm';
    }
}
```

Key design points:
- `elapsed` is initialised from the ISO timestamp, not from zero — so if Alpine re-initialises (e.g. page load mid-job), the counter starts at the correct value
- Uses `Date.now() - startMs` (not `elapsed++`) — drift-free even if the tab is in the background
- `wire:key="{{ $log->id }}"` on each running card tells Livewire morphdom to reuse the DOM element across polls, so the Alpine component and `setInterval` are NOT recreated every 3 seconds
- The status bar uses `x-effect="resetTimer($wire.startedAt || null)"` — resets when the active job changes, continues uninterrupted when the same job is still running

### Nothing to do in new jobs

**You do not need to write any timer code in new jobs.** The elapsed counter appears automatically for:
- Any job using `JobLogger::running()` (explicit logging)
- Any job auto-logged via `ConsoleServiceProvider` (not in `$skipClasses`)
- Batch jobs using `JobLogger::startBatch()`

The final duration also records automatically — `JobLogger::ok()` / `::error()` calculate it; auto-logged jobs get it from the `JobProcessed`/`JobFailed` events.

---

## Status badge reference

| Status | Colour | When to use |
|---|---|---|
| `running` | pulsing green | Actively processing |
| `queued` | grey dashed "NEXT" card | Batch waiting to start — jobs dispatched but no tick received yet |
| `ok` | light green | Completed successfully |
| `error` | red | Failed / threw an exception |
| `warn` | yellow | Non-fatal issue (partial failure — batch completed with some errors) |
| `info` | blue | Informational milestone |

---

## Adding console logging to a job — step by step

### Option A — Auto-logging (no code changes)

Any queued job **not** in `$skipClasses` in `ConsoleServiceProvider` is logged automatically:
- `JobProcessing` → `running` entry
- `JobProcessed` → `ok`
- `JobFailed` → `error` with exception message

Good for short jobs where you just want a record it ran. No progress bar.

### Option B — Explicit single-job logging (recommended for long-running jobs)

Gives you: custom display name, live progress bar, clean error messages, optional detail block.

```php
use Webkul\Console\Services\JobLogger;

class MyBigJob implements ShouldQueue
{
    public function handle(): void
    {
        $items = $this->loadItems();
        $total = count($items);

        $logId = JobLogger::running("My Job · 0 / {$total}");

        $done = 0;
        foreach ($items as $item) {
            $this->processItem($item);
            $done++;

            if ($done % 100 === 0) {
                JobLogger::progress($logId, "My Job · {$done} / {$total}", $done, $total);
            }
        }

        JobLogger::ok("My Job complete · {$total} processed", $logId);
    }

    public function failed(\Throwable $e): void
    {
        JobLogger::error('My Job failed · ' . $e->getMessage());
    }
}
```

**Add to skipClasses** in `ConsoleServiceProvider::registerQueueListeners()` so auto-logging doesn't double-log:

```php
$skipClasses = [
    \Webkul\MyPlugin\Jobs\MyBigJob::class,
];
```

### Option C — Distributed batch logging (fan-out parent → many children)

Use this when a parent job dispatches thousands of child jobs and you want **one persistent progress bar** that tracks all of them together.

#### Pattern overview

```
Parent job (FlagAndQueueBulkRecalculation)
  → startBatch() → $batchLogId          (status = running, swish animation until first tick)
  → startBatch(queued: true) → $nextId  (status = queued, shown as "NEXT" card)
  → dispatch N × ChildJob($batchLogId, $nextId)
  → updateBatchTotal($batchLogId, N)
  → updateBatchTotal($nextId, N)

Each ChildJob:
  → handle():  batchTick($batchLogId)        — ok
               batchTick($nextId)            — transitions queued→running on first call
  → failed():  batchTick($batchLogId, true)
               batchTick($nextId, true)
```

#### Parent job

```php
// Pre-count so the progress bar has a total from the start
$productCount = $query->count();
$totalJobs    = $productCount * count($channelIds);

$batchLogId = $totalJobs > 0
    ? JobLogger::startBatch('Repricing · Banwell Website · ' . number_format($productCount) . ' SKUs', $totalJobs, self::class)
    : 0;

// Secondary phase — queued: true so it shows as NEXT until first tick
$priceSyncLogId = $batchLogId > 0
    ? JobLogger::startBatch('Price Sync · Shopify · Banwell Website', $totalJobs, self::class, queued: true)
    : 0;

$dispatched = 0;
$query->cursor()->each(function ($product) use ($channelIds, $batchLogId, $priceSyncLogId, &$dispatched) {
    foreach ($channelIds as $channelId) {
        if ($this->isFixed($product->id, $channelId)) {
            continue; // DO NOT tick — excluded from total
        }
        ChildJob::dispatch($product->id, $channelId, $batchLogId ?: null, $priceSyncLogId ?: null);
        $dispatched++;
    }
});

// Correct the total after the loop (excludes fixed-price skips etc.)
if ($batchLogId) {
    JobLogger::updateBatchTotal($batchLogId, $dispatched);
}
if ($priceSyncLogId) {
    JobLogger::updateBatchTotal($priceSyncLogId, $dispatched);
}
```

#### Child job

The key gotcha: **do not use `readonly` constructor property promotion for `$batchLogId`**. PHP's `unserialize()` skips the constructor — a promoted property's default is only set when the constructor runs. Old payloads (queued before the property was added) will have an uninitialized property instead of `null`, causing instant fatal errors.

**Always declare it as a separate class property with an explicit default:**

```php
class ChildJob implements ShouldBeUnique, ShouldQueue
{
    // Separate class property — gets null default even when unserialize skips the constructor.
    // DO NOT use: public readonly ?int $batchLogId = null  (promoted default is ignored by unserialize)
    public ?int $batchLogId = null;
    public ?int $priceSyncLogId = null;

    public function __construct(
        public readonly int $productId,
        public readonly int $channelId,
        ?int $batchLogId = null,
        ?int $priceSyncLogId = null,
    ) {
        $this->batchLogId     = $batchLogId;
        $this->priceSyncLogId = $priceSyncLogId;
    }

    public function handle(): void
    {
        $inBatch = $this->batchLogId !== null;

        // In batch mode: no individual running entry (would create thousands of running rows)
        $logId = $inBatch ? null : JobLogger::running("Processing · {$this->productId}");

        // ... do work ...

        if ($inBatch) {
            JobLogger::batchTick($this->batchLogId);
        }
        // Individual OK entry — always written (batch or not); in batch mode logId is null → creates standalone entry
        JobLogger::ok("Processed · {$this->productId}", $logId, $detailBreakdown);
    }

    public function failed(\Throwable $e): void
    {
        if ($this->batchLogId !== null) {
            JobLogger::batchTick($this->batchLogId, failed: true);
        }
    }
}
```

**Add both parent and child to skipClasses** in `ConsoleServiceProvider`.

---

## Batch progress bar — what the console shows

When a log entry has `total_items !== null` (i.e. it was created by `startBatch()`), the console panel renders a pinned card above the log body.

### queued batch — "NEXT" card

```
Price Sync · Shopify · Banwell Website                      [NEXT]
[░░░░░░░░░░░░░░░░░░░░░░░░]
20,507 jobs queued · starts when current job completes
```

Dashed border, dark background, grey text. No fill bar. Shown in the **Jobs** tab (queued batch entries have no `message`, so they classify as Jobs).

### Phase 1 — Building queue (`batch_done === 0`)

```
Repricing · Banwell Website · Somerset4x4 Website
[~~~swish animation~~~]
Building queue… 20,507 jobs to dispatch
```

A swish animation shows during the dispatch phase before any child jobs complete.

### Phase 2 — Processing (`batch_done > 0`)

```
Repricing · Banwell Website · Somerset4x4 Website          62%
[████████████░░░░░░░░░░░]
5,634 of 9,088 items · 23 failed           155 items/sec  ~3m 2s remaining
```

The fill bar, count, rate, and ETA all update on every 3s poll.

### Rate / ETA accuracy

Rate and ETA are computed in `JobActivityLog::currentRate()` using a two-tier approach:

**Primary — 30-second rolling snapshot (most accurate):**

`batchTick()` writes `{snapshot_count, snapshot_ts}` to the `progress` JSON column every 30 seconds using raw Unix milliseconds from `getPreciseTimestamp(3)`. The model computes:

```
rate = (done_now - snapshot_count) / ((now_ms - snapshot_ts) / 1000)
```

Pure integer subtraction — no timezone or string-precision issues. Rate is only used when at least 5 seconds have elapsed from the snapshot.

**Fallback — overall rate from `processing_started_at`:**

Used before the first 30-second snapshot is available. The floor is 30 seconds to prevent absurdly high rates in the first few Livewire polls.

**Important:** `processing_started_at` is stamped on the first `batchTick()` call (not `started_at`, which includes queue-build time). This means ETA correctly reflects queue throughput, not dispatch speed.

`updateBatchTotal()` corrects the total after fixed-price/skipped items are excluded, preventing "done > total" or wrong percentages.

### Auto-close

`batchTick()` checks `processed + failed >= total_items` after every increment. When true, it sets status to `ok` (no failures) or `warn` (some failures) and the card disappears from the running section.

### Watchdog

`JobStatusBar` times out any `running` entry whose `updated_at` is more than 10 minutes old. Since `batchTick()` refreshes `updated_at` on every tick, a healthy batch never trips the watchdog. A batch that gets stuck (worker died, no more ticks) will be closed as `error — timed out` after 10 minutes.

### Always visible

The console fetches 200 recent logs by `created_at` plus **all running, queued, and error entries** from the past 48h. Batch cards stay pinned even when thousands of per-SKU OK entries flood the log.

---

## Kill & Clear All Jobs

`Settings → General → Kill & Clear All Jobs` (`app/Filament/Pages/Settings/GeneralPage.php::clearJobs()`):

1. Deletes all rows from `jobs` (pending) and `failed_jobs`
2. Closes all `status = 'running'` log entries as `error — force cleared`
3. **Deletes** all `status = 'queued'` log entries — their jobs are now gone from the queue so there's nothing to run

---

## Console tab reference

| Tab | Shows | Classifier |
|---|---|---|
| **Jobs** | Landmark operations — batch progress cards, top-level job summaries (Price Sync, Repricing, imports) | `message IS NULL` — no detail block |
| **Running** | Currently-executing jobs (pinned progress cards only) | `status = running` |
| **Activity** | Per-item detail entries — pricing breakdowns, Shopify sync diffs | `message IS NOT NULL` — has a detail block |
| **Errors** | All failed entries in the past 48h | `status = error` |

**Default tab**: Jobs.

**Rule of thumb for new jobs**: if the job writes a detail breakdown via `JobLogger::ok($name, $logId, $details)`, it appears in Activity. If it calls `JobLogger::ok($name, $logId)` (no details), it appears in Jobs. No code beyond the existing `JobLogger` call is needed — the tab classifier reads the `message` column automatically.

---

## Detail block (`message` column)

The `message` column is rendered in a dark monospaced panel below the main `display_name` line whenever it contains text. Use it for structured breakdowns. Format with spaces + `─` dividers for readability:

```
  Cost        £24.07
  Markup      28.0% (+£6.74)
  Fees        2% (+£0.62)  Fixed fee +£0.25
  ──────────────────────────────
  Ex VAT      £31.43
  VAT         20.0% (+£6.28)
  ──────────────────────────────
  Sell        £37.71
```

`RecalculateProductChannelPrice::buildBreakdown(PricingResult $result, string $channelName, bool $includeHeader)` generates this format and is reusable for any pricing output.

---

## Jobs with explicit logging (July 2026)

| Job | Style | Progress bar | Detail block | Notes |
|---|---|---|---|---|
| `ScrapeAllmakesPsp` | Explicit | Yes | No | |
| `BulkImportPspFitmentJob` | Explicit | Yes | No | |
| `FetchShopifyProductsJob` | Explicit | No (updateName) | No | |
| `ImportShopifyProductsJob` | Explicit | No (updateName) | No | |
| `ProcessProductExportJob` | Explicit | Yes | No | |
| `ProcessProductImageExportJob` | Explicit | Yes | No | |
| `FlagAndQueueBulkRecalculation` | **Batch parent** | Yes (batch card) | No | Opens repricing batch (running) + price sync batch (queued: true); calls `updateBatchTotal` on both |
| `RecalculateProductChannelPrice` | **Batch child** | No | Yes — price breakdown | Calls `batchTick` on repricing batch; downstream `SyncProductPriceToChannel` ticks the price sync batch |
| `SyncProductPriceToChannel` | **Batch child** (price sync) | No | No | Ticks the `priceSyncBatchLogId`; first tick transitions price sync from queued→running |
| `SyncProductToChannels` | **Batch child** (Shopify push) | No | Yes — per-field diff (inventory, price, title, images, etc.) | Dispatched from `ManageListings::bulkPush()` / `pushAllFiltered()` with a `batchLogId`; ticks on success **and** failure; `failed()` hook ticks if job fails at Laravel level. `ShouldBeUnique` lock force-released before each dispatch — see pitfalls |

---

## Rendering hooks

The status bar and panel are injected via `FilamentView::registerRenderHook()` in `ConsoleServiceProvider::packageRegistered()`:

- `PanelsRenderHook::GLOBAL_SEARCH_BEFORE` → status bar pill
- `PanelsRenderHook::BODY_END` → slide-in console panel

Both are Livewire components registered in `packageBooted()`:

```php
Livewire::component('console-status-bar', JobStatusBar::class);
Livewire::component('console-panel', JobConsolePanel::class);
```

---

## Common pitfalls

| Problem | Cause | Fix |
|---|---|---|
| Child job instantly fails (RUNNING→FAIL in <100ms) | `$batchLogId` is `readonly` with promoted default — `unserialize` skips constructor, property is uninitialized | Declare as separate class property: `public ?int $batchLogId = null;` |
| Batch card times out after 10 min despite jobs still running | `DB::table()->increment()` does not update `updated_at` automatically | Always pass `['updated_at' => now()]` as third arg to `increment()` |
| ETA shows ~0s when batch is 50% done | Fixed-price skips called `batchTick` synchronously at thousands/sec, inflating rate | Never tick skipped items; use `updateBatchTotal` with actual dispatched count |
| ETA wildly off early / astronomically high rate | ISO timestamp strings have second-level precision — `diffInMilliseconds` of a string written 50ms ago returns near-zero, causing `items / 0.001 = 5,500,000/sec` | Rate uses raw Unix milliseconds via `getPreciseTimestamp(3)` — pure integer subtraction, no precision issues |
| ETA wildly off early | Rate measured from `started_at` which includes queue-build time | `processing_started_at` (set on first tick) is used for the fallback rate denominator |
| Batch card disappears mid-reprice | Batch log's `created_at` older than 200th recent entry | Running and queued entries are always fetched regardless of 200-entry limit |
| Both batch cards show as Running simultaneously | Second batch created with `status='running'` at dispatch time | Pass `queued: true` to `startBatch()` for the second batch |
| "NEXT" card stays in console after Kill & Clear All Jobs | `clearJobs()` only updated `status='running'` rows | `clearJobs()` now also deletes `status='queued'` rows |
| Price sync label shows "channel" instead of real channel name | `$channel` variable defined in `handle()` not passed to `handleFeeProfile()` | Load `with('channel')` inside `handleFeeProfile()` and use `$profile->channel?->name` |
| Double console entry | Parent observer fires on save AND button click both dispatch | Expected if >10s apart; de-duplicate at dispatch level if needed |
| Batch stays at "Building queue… N jobs" indefinitely (0 ticks) | `ShouldBeUnique` lock held from previous dispatch (job deleted by Kill & Clear but cache lock not released) — new dispatch silently dropped | Call `Cache::lock('laravel_unique_job:' . ChildJob::class . ':' . $job->uniqueId())->forceRelease()` before each dispatch in the batch loop |
| TypeError: Return value of method() must be of type array, none returned | A PHP method declared with `array` return type has a bare `return;` on an early-exit path | Replace every early `return;` in the typed method with `return $summary;` (or whatever the zero-value return is) |
