Skip to content

CLI command to resolve duplicate Stache IDs (please stache:resolve-duplicates) #1445

@stephenmeehanuk

Description

@stephenmeehanuk

Statamic has a CP page for resolving duplicate IDs (/cp/duplicates), but there's no CLI equivalent. This is a problem in two scenarios:

  1. The CP page itself crashes: Duplicates::all() calls $this->stache->store($store)->getItem($id)->path() without a null check. If an entry has been deleted or its collection is misconfigured, getItem() returns null and the entire page throws Call to a member function path() on null. At that point, the only way to fix it is manually editing YAML files.
  2. Headless / CI environments: There's no way to detect and resolve duplicates without the Control Panel.

stache:doctor already detects duplicates, but it can't fix them. The resolution logic lives entirely in DuplicatesController@regenerate, which is only accessible via HTTP.

Proposal

A new command: php please stache:resolve-duplicates

What it does

  • Scans for duplicates using Stache::duplicates()->clear()->find() (same as stache:doctor)
  • Reports a summary: count and affected stores
  • Regenerates IDs for duplicate entries using the same approach as DuplicatesController@regenerate
  • Handles errors gracefully — entries that can't be resolved (e.g. missing blueprints) are reported with a grouped count rather than crashing the whole operation
  • Clears the Stache and dispatches DuplicateIdRegenerated
  • Supports --force for non-interactive use

Key difference from the CP implementation

The command uses getItems() instead of all() to avoid the null crash in Duplicates::all(). This makes it resilient to the same edge case that breaks the CP page.

Example output

Image

If the .md files can't find the correct blueprint it won't work. Once the blueprint reference is fixed (with a quick search and replace) the command works.

Reference implementation

<?php

namespace Statamic\Console\Commands;

use Illuminate\Console\Command;
use Statamic\Console\RunsInPlease;
use Statamic\Events\DuplicateIdRegenerated;
use Statamic\Facades\File;
use Statamic\Facades\Stache;
use Statamic\Support\Str;

use function Laravel\Prompts\confirm;
use function Laravel\Prompts\spin;

class ResolveDuplicates extends Command
{
    use RunsInPlease;

    protected $signature = 'statamic:stache:resolve-duplicates
                            {--force : Resolve all duplicates without confirmation}';

    protected $description = 'Resolve duplicate IDs in the Stache by regenerating them';

    public function handle()
    {
        $duplicates = spin(
            callback: fn () => Stache::duplicates()->clear()->find(),
            message: 'Scanning for duplicate IDs...',
        );

        $items = collect($duplicates->getItems());

        if ($items->isEmpty()) {
            $this->components->info('No duplicate IDs found.');

            return;
        }

        $count = $items->flatMap(fn ($ids) => $ids)->map(fn ($paths) => count($paths))->sum();

        $storeNames = $items->keys()->map(fn ($key) => Str::after($key, '::'))->implode(', ');

        $this->components->error("{$count} duplicate ID(s) found across: {$storeNames}");

        if (! $this->option('force') && ! confirm("{$count} duplicate(s) found. Regenerate IDs?")) {
            $this->components->warn('Aborted.');

            return;
        }

        $resolved = 0;
        $errors = [];

        $items->each(function ($ids, $storeName) use (&$resolved, &$errors) {
            $store = Stache::store($storeName);

            collect($ids)->each(function ($paths, $id) use ($store, &$resolved, &$errors) {
                collect($paths)->each(function ($path) use ($store, &$resolved, &$errors) {
                    try {
                        $item = $store->makeItemFromFile($path, File::get($path));
                        $item->id(Stache::generateId());
                        $item->writeFile();
                        $resolved++;
                    } catch (\Exception $e) {
                        $errors[$e->getMessage()][] = Str::after($path, base_path().'/');
                    }
                });
            });
        });

        spin(
            callback: fn () => Stache::clear(),
            message: 'Clearing the Stache...',
        );

        DuplicateIdRegenerated::dispatch();

        $this->components->info("Regenerated {$resolved} duplicate ID(s). The Stache has been cleared.");

        foreach ($errors as $message => $paths) {
            $this->components->warn(count($paths)." item(s) skipped: {$message}");
        }
    }
}

Side note: Duplicates::all() null safety
While building this, I discovered that Duplicates::all() (line 24) doesn't guard against getItem() returning null. This also crashes the CP duplicates page under the same conditions. A null check there would fix both the CP page and simplify this command:

// Current (crashes)
$this->stache->store($store)->getItem($id)->path()

// Suggested
$this->stache->store($store)->getItem($id)?->path()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions