Extending

Two registries and four events. Everything Nuke can delete goes through one of the registries, so adding your own is the same shape of work as the ones that ship.

A custom scope

One per element type. Extend BaseScope and you get every shared filter — status, sites, dates, search, trashed, limit, the protected-scope check — already implemented.

use craft\elements\db\ElementQuery;
use justinholtweb\nuke\models\Target;
use justinholtweb\nuke\targets\BaseScope;
use mynamespace\elements\Product;

class ProductScope extends BaseScope
{
    public static function handle(): string
    {
        return 'products';
    }

    public function label(): string
    {
        return 'Products';
    }

    public function elementType(): string
    {
        return Product::class;
    }

    public function sourceLabel(): string
    {
        return 'Product Types';
    }

    public function sourceOptions(): array
    {
        // Anything with an id, name and handle.
        return $this->optionsFrom(MyPlugin::getInstance()->productTypes->getAll());
    }

    protected function baseQuery(Target $target): ElementQuery
    {
        $query = Product::find();

        if ($typeIds = $target->ids('sourceIds')) {
            $query->typeId($typeIds);
        }

        return $query;
    }
}

Register it:

use justinholtweb\nuke\events\RegisterScopesEvent;
use justinholtweb\nuke\services\Scopes;
use yii\base\Event;

Event::on(Scopes::class, Scopes::EVENT_REGISTER_SCOPES, function(RegisterScopesEvent $event) {
    $event->scopes[] = ProductScope::class;
});

Four optional methods

  • statusOptions() — override it if your element type has statuses beyond enabled and disabled. This matters more than it looks: asking an element query for a status its type does not have returns nothing at all rather than erroring, which is indistinguishable from “no matches”.
  • warnings(Target) — things the operator should read before firing that are not reasons to refuse. The asset scope uses it to say whether files leave the volume.
  • validate(Target, Settings) — reasons the strike must not run. Call parent::validate() to keep the protected-scope check.
  • hasStructure(Target) — return true if your elements can be hierarchical, and the preview will spend a query counting the descendants that get re-parented.

A custom sweeper

Extend BaseSweeper and implement execute() once. The scan and the sweep both run through it with a flag — separate implementations would be one more place for a preview and an execution to drift apart.

use craft\helpers\Db;
use justinholtweb\nuke\models\SweepResult;
use justinholtweb\nuke\sweepers\BaseSweeper;

class ExpiredCartsSweeper extends BaseSweeper
{
    public static function handle(): string
    {
        return 'expiredCarts';
    }

    public function label(): string
    {
        return 'Expired carts';
    }

    public function description(): string
    {
        // Say what it removes *and* what it deliberately leaves alone. This is what an
        // operator reads before switching it on.
        return 'Deletes abandoned carts older than the window. Completed orders are never touched.';
    }

    public function group(): string
    {
        return self::GROUP_DATABASE;
    }

    public function defaultConfig(): array
    {
        return ['enabled' => true, 'olderThanDays' => 90];
    }

    public function configFields(): array
    {
        return [
            [
                'name' => 'olderThanDays',
                'label' => 'Older than',
                'type' => 'days',
                'min' => 1,
                'max' => 3650,
            ],
        ];
    }

    protected function execute(SweepResult $result, array $config, bool $dryRun): void
    {
        // Counts in dry-run mode and deletes otherwise, from one condition.
        $this->sweepRows($result, '{{%mycarts}}', [
            '<', 'dateUpdated', Db::prepareDateForDb($this->cutoff($this->days($config))),
        ], $dryRun);
    }
}

Register it with Sweepers::EVENT_REGISTER_SWEEPERS. Registration order is run order, except that Craft’s own garbage collector is always moved to the end.

Helpers on BaseSweeper

MethodWhat it does
days($config, $key)Reads a config value as a positive int, falling back to your default.
cutoff($days)The cutoff DateTime, or null when days is 0.
sweepRows($result, $table, $condition, $dryRun)Count, or delete, rows matching a condition.
agedFiles($dir, $cutoff, $keepNewest, $pattern)Files directly inside a directory, newest first.
agedTreeFiles($dir, $cutoff)Files anywhere beneath a directory.
sweepFiles($result, $files, $dryRun)Record — and in a real sweep, delete — a file list.
addSample($result, $line)Add a sample line, up to a sensible ceiling.

If your sweeper’s work is not measured in things removed, set $result->ran = true so it still appears on a report that lists only what changed. A sweeper that throws does not take the sweep down with it.

Events

EventWhenCancellable
Detonator::EVENT_BEFORE_STRIKEBefore a strike deletes anythingyes
Detonator::EVENT_AFTER_STRIKEAfter it finishesno
Sweep::EVENT_BEFORE_SWEEPBefore a sweep runsyes
Sweep::EVENT_AFTER_SWEEPAfter it finishesno

The cancellable ones are how a site enforces its own policy — never during business hours, not this section without a ticket number:

use justinholtweb\nuke\events\StrikeEvent;
use justinholtweb\nuke\services\Detonator;

Event::on(Detonator::class, Detonator::EVENT_BEFORE_STRIKE, function(StrikeEvent $event) {
    if ((int)date('G') >= 9 && (int)date('G') < 18) {
        $event->isValid = false;
    }
});

A cancelled strike is still recorded, with a note saying an event handler stopped it.