DuplicateToolkit2.0

Fig. 1 — App\Models\Product · depth 2

Read the drawing before you cut.

One Artisan command prints exactly what your model would do — every relation, its type, and the strategy that applies. No surprises at the far end of a transaction.

Get started  → Read the guide
Terminallive
Platescale 1:1
Product products · root versions HasMany · 12 descriptions HasMany · 148 setting HasOne · 1 notes MorphMany · 3 categories BelongsToMany · 4 tags BelongsToMany · 22 auditLogs HasMany · not copied 161 rows 412 ms 0 rows re-attached
copy
reference
skip
DrawingFig. 1
Depth—
Rows—
Stateidle

The relations command, in full

The strategy column is your code

Nothing on that plate is a framework default. The command resolves your model's own options and prints the answer — which is the whole point of reading it before you cut.

class Product extends Model implements Duplicatable
{
    use HasDuplicates;

    public function duplicateOptions(): DuplicateOptions
    {
        return DuplicateOptions::make()
            ->referenceRelations('categories', 'tags')
            ->excludeRelations('auditLogs')
            ->excludeColumnsMatching('/_count$/')
            ->uniqueColumns('name')
            ->quietly();
    }
}

Every option, and what it does

Every relation gets one of three answers

The defaults follow the relation type, so the ordinary case needs no configuration. Override from the model, from the call site, or with dot notation four levels down.

copy

It belongs to the record

The related rows are replicated and attached to the duplicate, recursively, to the depth you allow.

hasOne · hasMany
morphOne · morphMany

reference

It is shared with the record

The duplicate is attached to rows that already exist. Pivot payload declared with withPivot() comes along; the keys and pivot timestamps do not.

belongsToMany
morphToMany

skip

It should not follow at all

Never written, never even queried. Parents are always skipped — duplicating one would create a row the copy does not own.

belongsTo · morphTo
hasOneThrough · hasManyThrough

DuplicateOptions::make()
    ->copyRelations('versions', 'bundles')
    ->referenceRelations('categories', 'tags')
    ->excludeRelations('auditLogs')
    ->relation('versions.descriptions', fn ($o) => $o->excludeColumns('cached_html'));

// or invert it — everything not listed is skipped
DuplicateOptions::make()->onlyRelations('versions');

Relation strategies, in full

One write, not two

Renaming a copy after the fact costs a second write — and fires the events you just suppressed. Attribute overrides are applied to the model before it is inserted.

Before · two writes

$copy = $product->saveAsDuplicate();

$copy->update([
    'name' => $copy->name.' - COPY',
]);

The update() fires saving, saved and updated — the observers and search indexing that saveQuietly() was meant to avoid.

After · one write

$copy = $product->duplicate(fn ($o) => $o
    ->suffix('name', ' - COPY')
    ->quietly());

suffix, prefix, replace, withAttributes, mutate — all applied to the model before save(), in the order you registered them.

DuplicateOptions::make()
    ->suffix('name', ' - COPY')
    ->replace('version', search: '2025', replace: '2026')
    ->withAttributes(['is_active' => false, 'product_id' => $target->id])
    ->mutate('code', fn ($value, $duplicate, $source) => $value.'-'.$source->getKey())
    ->uniqueColumns('name')              // "Widget" → "Widget (1)", in one query
    ->excludeColumnsMatching('/_count$/');  // counters are wrong the moment they are copied

Attribute overrides, in full

It hands back every key it wrote

The reason this package exists. If you have ever duplicated a tree and then rebuilt an old-id to new-id lookup by matching names, this replaces it — and unlike name matching, it does not silently mis-map the moment two records share a name.

$result = $product->duplicateWithResult();

$result->model;                   // the new root model
$result->idMap(Version::class);   // [12 => 480, 13 => 481, 27 => 482]
$result->newKeyFor($version);     // 480
$result->count();                 // 161
$result->count(Version::class);   // 12
$result->counts();                // ['Version' => 12, 'Description' => 148, ...]
$result->durationMs;              // 412.4

// rewire the grandchildren, without guessing
foreach ($descriptions as $description) {
    $description->update([
        'version_id' => $result->idMap(Version::class)[$description->version_id],
    ]);
}

Rehearse it first

preview() performs every write inside a transaction and rolls it back. The counts are measured, not estimated — then decide whether to run it inline or hand it to the queue.

Or send it away

dispatch() queues a batchable job, tagged for Horizon and guarded so the same record is never duplicated twice at once.

$plan = Duplicate::of($product)->preview();

if ($plan->count() > 5_000) {
    Duplicate::of($product)->onQueue('duplicates')->dispatch();
} else {
    Duplicate::of($product)->execute();
}

The result object, in full

Depth, and the tree that eats itself

A model already visited during a run is never visited again, so a self-referencing tree terminates instead of recursing forever.

01

Set how far it follows

The default comes from duplicate-toolkit.max_depth, which ships as 1. Zero duplicates the root and nothing else.

->depth(2)          ->shallow()
02

Find out when it was not enough

By default a tree deeper than the limit is quietly truncated. Strict mode throws MaxDepthExceededException instead, so a silently shallow copy cannot reach production.

->depth(2)->strictDepth()
03

Steer a grandchild from the root

Dot notation reaches down without touching the child's model. Its own options still apply — yours are merged on top.

->relation('versions.descriptions', fn ($o) => $o->excludeColumns('cached_html'))

Depth and cycles, in full

Four commands, no guessing

The plate above is the first of them. The other three pick, generate and migrate.

duplicate-toolkit:relations {model?}

The drawing. --depth=N to walk further, --with-counts for row counts, --all to include parents, --json for scripts.

duplicate-toolkit:duplicate {model?} {id?}

Search for a record, tick the relations — pre-ticked from your model's own options — review the plan, confirm. Or drive the whole thing from flags.

duplicate-toolkit:make-options {model?}

Runs the same picker and writes a typed duplicateOptions() into the model, adding the trait, the interface and the imports if they are missing.

duplicate-toolkit:upgrade {path}

Rewrites 1.x usages to the 2.0 API, and reports the two cases that need a human decision rather than guessing at them.

php artisan duplicate-toolkit:duplicate Product 41 \
    --only=versions,bundles \
    --reference=categories \
    --suffix='name= - COPY' \
    --depth=2 \
    --dry-run

Every command, with every flag

Specification

discovery
return-type reflection · nothing is invoked, no source file is read
cache
memoised per class-string · relations cannot leak between model classes
depth
configurable · cycles detected and stopped · strictDepth() throws instead of truncating
writes
one transaction · chunked with lazyById() · events fire after commit
uniqueness
resolved in one query, never colliding with the source row
result
old key → new key for every row created, per model class
extras
soft deletes · UUID and ULID keys · provenance column · media library
analysis
PHPStan level max · no baseline, no ignores · Pint · Rector
tested
PHP 8.3 and 8.4 × Laravel 12 and 13 · prefer-lowest and prefer-stable

How relation discovery works

Install it

The service provider is auto-discovered. No migrations, no tables, no assets — publishing the config is optional, and the defaults duplicate correctly untouched.

composer require gabrielesbaiz/duplicate-toolkit

php artisan vendor:publish --tag=duplicate-toolkit-config
php artisan duplicate-toolkit:relations "App\Models\Product"
PHP8.3+
Laravel12 · 13
LicenseMIT
Providerauto-discovered
Upgrading

Coming from 1.x? It leaked relations between model classes, discovered relations by reading your source files and calling every public method, and crashed on morphToMany. Treat a 1.x duplication of two different models in one request as suspect.

duplicate-toolkit:upgrade rewrites the mechanical changes. Read the upgrade guide →

The full installation guide

— Support this package

Free forever.
Not free to maintain.

Duplicating a relation tree correctly is the kind of thing nobody notices until the day it writes four thousand rows under the wrong parent. That day never shows up in a budget — so the packages that prevent it tend to be written on goodwill and maintained on whatever is left over.

Sponsorship is what keeps this one current with the frameworks it sits on, and keeps the test suite proving that every fix still holds. It stays MIT either way.

⭐Free

Star the repo

Thirty seconds, and it is the first signal other developers look at when deciding whether to trust a package.

🐛Free

Open a good issue

A clear reproduction is worth more than you think. Several of the fixes in 2.0 started as somebody else's bug report.

❤️

Sponsor from $5

Goes towards the maintenance nobody sees: framework upgrades, holding PHPStan at level max, and keeping the test matrix green.

🏢

Company tier

Your logo here and in the README. If this package duplicates your production data, it is cheaper than the incident it prevents.

♡

Why ask at all? This package is MIT and always will be. Nothing is paywalled, nothing phones home, and no feature is held back for sponsors. Sponsorship buys maintenance time, not features — and if you cannot sponsor, the star and the bug report genuinely help.