Introduction
DuplicateToolkit copies an Eloquent record and everything under it, letting you decide per relation whether to duplicate the related rows, point at the originals, or leave them alone — and it hands back a map of every key it wrote.
What problem it solves
Eloquent ships replicate(). It copies one row's attributes and returns an unsaved model. If that is all you need, use it — one method call, no dependency.
The difficulty starts one level down. A product has twelve versions; those versions have a hundred and forty-eight descriptions; the product is also attached to four categories through a pivot table. Duplicating the product means duplicating the versions and the descriptions, but not the categories — the copy should share them. Get that distinction wrong and you either orphan rows or silently clone a reference table.
Then, having created a hundred and sixty-one rows, you need to know which new row corresponds to which old one, because something downstream has to be rewired.
What you get
- Deep duplication of
hasOne,hasMany,morphOne,morphMany,belongsToManyandmorphToMany, to any depth, with cycle detection. - Three strategies per relation — copy, reference, skip — set on the model, at the call site, or with dot notation several levels down.
- Attribute overrides applied before the insert, so renaming a copy costs one write rather than two.
- An old key to new key map for every record created.
- Four Artisan commands, including one that draws your model's relation tree and one that migrates 1.x code.
- A dry run that performs every write and rolls back, so the plan is measured rather than estimated.
What it is not
It is not a soft-delete or versioning system, it does not track changes over time, and it does not decide whether a duplicate is authorised — that is your application's job. It duplicates whatever model you hand it.
morphToMany. Read Upgrading from 1.x before you start.Requirements
| Requirement | Version |
|---|---|
| PHP | 8.3 or 8.4 |
| Laravel | 12.x or 13.x |
| Runtime dependencies | None beyond Illuminate and spatie/laravel-package-tools |
Installation
One composer command. The service provider is auto-discovered, there are no migrations and no assets, and the defaults duplicate correctly untouched.
Install the package
composer require gabrielesbaiz/duplicate-toolkit
Publish the config
Optional. Do it when you want to change the default depth, the globally excluded columns, or the queue connection.
php artisan vendor:publish --tag=duplicate-toolkit-config
That writes config/duplicate-toolkit.php. Every key is documented on the Configuration page.
Check it sees your models
Before writing any code, ask the package what it found. This command reads nothing but return types and prints the relation tree it would follow.
php artisan duplicate-toolkit:relations "App\Models\Product" --depth=2
Add the trait
use Gabrielesbaiz\DuplicateToolkit\Concerns\HasDuplicates;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasDuplicates;
}That is the whole setup. $product->duplicate() now works, following the type defaults described in Relation strategies.
Quick start
From a bare model to a configured one, in the order you will actually do it.
1 · Duplicate something
$copy = $product->duplicate();
Child relations are copied, pivoted relations are referenced, parent relations are left alone. Timestamps and soft-delete columns are never carried over.
2 · Adjust it for one call
Pass a callback to refine the options for this call only. Nothing on the model changes.
use Gabrielesbaiz\DuplicateToolkit\DuplicateOptions;
$copy = $product->duplicate(fn (DuplicateOptions $o) => $o
->suffix('name', ' - COPY')
->excludeRelations('auditLogs'));3 · Move the settled rules onto the model
Once the same options keep appearing at call sites, they belong on the model. Implement Duplicatable and return them.
use Gabrielesbaiz\DuplicateToolkit\Concerns\HasDuplicates;
use Gabrielesbaiz\DuplicateToolkit\Contracts\Duplicatable;
use Gabrielesbaiz\DuplicateToolkit\DuplicateOptions;
class Product extends Model implements Duplicatable
{
use HasDuplicates;
public function duplicateOptions(): DuplicateOptions
{
return DuplicateOptions::make()
->excludeColumnsMatching('/_count$/')
->uniqueColumns('name')
->referenceRelations('categories', 'tags')
->excludeRelations('auditLogs')
->quietly();
}
}php artisan duplicate-toolkit:make-options "App\Models\Product" runs a relation picker and writes that method for you, imports included.4 · Take the result, not just the model
$result = $product->duplicateWithResult(); $result->model;// the new Product$result->idMap(Version::class);// [12 => 480, 13 => 481]$result->count();// 161
5 · Check the cost before committing
$plan = Duplicate::of($product)->preview();// writes, then rolls back$plan->count();// 161$plan->counts();// ['Version' => 12, 'Description' => 148, ...]
Relation strategies
Every relation gets exactly one of three answers. The defaults follow the relation type, so the ordinary case needs no configuration at all.
It belongs to the record
The related rows are replicated and attached to the duplicate, recursively.
It is shared with the record
The duplicate is attached to rows that already exist. Nothing new is written.
It should not follow
Never written, never even queried.
Defaults by relation type
| Relation | Default | Why |
|---|---|---|
hasOne hasMany | copy | The rows carry a foreign key pointing at this record. They belong to it. |
morphOne morphMany | copy | Same, through a morph key. |
belongsToMany | reference | The rows exist independently. A duplicated product should share the category table, not clone it. |
morphToMany | reference | Same, through a morph pivot. |
belongsTo morphTo | skip | Always. Duplicating a parent would create a row the copy does not own. |
hasOneThrough hasManyThrough | skip | Always. The intermediate rows are not the copy's to write. |
the medialibrary media relation | media pass | Always. Only the media library can move a row together with its file, so the relation is never walked — see below. |
Media is not an ordinary relation
A media row's file lives in a directory named after the row's key. Cloning that row the way any other morphMany is cloned would give the copy a record pointing at the original's directory, and a duplicated uuid with it. So the relation is never walked: the duplicator asks the media library to copy each item instead, which writes both the row and the file.
Naming the relation still says whether the copy carries its media over — copyRelations('media') turns the media pass on, excludeRelations('media') turns it off — and withMedia() wins over both. Without medialibrary installed none of this applies.
Overriding them
DuplicateOptions::make()
->copyRelations('versions', 'bundles')
->referenceRelations('categories', 'tags')
->excludeRelations('auditLogs');Only these, and nothing else
onlyRelations() inverts the default: anything not listed is skipped.
DuplicateOptions::make()->onlyRelations('versions');With an attribute on the class
use Gabrielesbaiz\DuplicateToolkit\Attributes\DuplicateRelations;
#[DuplicateRelations(
copy: ['versions'],
reference: ['tags'],
skip: ['auditLogs'],
)]
class Product extends Model { use HasDuplicates; }Precedence, highest first: options passed at the call site, then duplicateOptions() on the model, then the attribute, then the type default.
Pivot payload
Columns declared with withPivot() are carried over in both copy and reference mode. The pivot's own primary key, the two foreign keys, the morph type and the pivot timestamps are not — the relation manages those itself.
Custom handlers
When a relation needs logic the options cannot express, take it over entirely.
DuplicateOptions::make()->relationUsing('bundles', function (
Model $source,
Model $duplicate,
RelationMeta $relation,
DuplicateContext $context,
) {
// $context->idMap(Version::class) is already populated here
});RelationDuplicating or RelationDuplicated event fires for it. Classes implementing DuplicatesRelation are accepted as well as closures.Nested relations
Dot notation reaches a grandchild from the root, without editing the child's model.
Reaching down
DuplicateOptions::make()
->relation('versions.descriptions', fn ($o) => $o->excludeColumns('cached_html'))
->relation('versions.attachments', RelationStrategy::Skip);How options merge
A related model's own duplicateOptions() is not discarded. The nested options you declare are merged on top of it, so a child keeps its own rules unless you explicitly override them.
- The child model's
duplicateOptions()is resolved first. - Anything you declared for that relation path is merged over it.
- Where both set the same thing, yours wins. Where only one sets it, that one applies.
quietly(), depth() and the unique strategy are single values, and the incoming one wins.Per-relation column rules
Two shorthands exist for the common case of excluding or uniquing columns on a child.
DuplicateOptions::make()
->excludeRelationColumns([
'versions' => ['cached_html', 'rendered_at'],
'notes' => ['pinned'],
])
->uniqueRelationColumns([
'versions' => ['name'],
]);Both are equivalent to a relation() call with a callback, and are kept because they read better when you are only adjusting columns.
Depth still applies
Declaring 'a.b.c' does not raise the depth limit. If max_depth is 2, the third level is never reached regardless of what you configured for it. See Depth and cycles.
Attribute overrides
Every override is applied to the model before it is inserted. That is one write rather than two, and it does not fire the events you just suppressed.
Why it matters
The obvious way to rename a copy is to duplicate it and then update it:
// two writes — and the update fires saving, saved and updated,// which is exactly what quietly() was meant to avoid$copy = $product->duplicate(fn ($o) => $o->quietly()); $copy->update(['name' => $copy->name.' - COPY']);
The override form does the same thing in one insert, with the events still suppressed:
$copy = $product->duplicate(fn ($o) => $o
->suffix('name', ' - COPY')
->quietly());The five forms
| Method | Effect |
|---|---|
suffix($col, $s) | Appends a string. |
prefix($col, $s) | Prepends a string. |
replace($col, $search, $replace) | Search and replace within the value. |
withAttributes([...]) | Forces values. A value may be a closure. |
mutate($col, $fn) | Transforms the current value. |
DuplicateOptions::make()
->suffix('name', ' - COPY')
->prefix('slug', 'copy-')
->replace('version', search: '2025', replace: '2026')
->withAttributes(['is_active' => false, 'published_at' => null])
->mutate('code', fn ($value, $duplicate, $source) => $value.'-'.$source->getKey());Overrides run in the order you registered them, so a suffix() after a withAttributes() appends to the forced value.
Excluding columns
An excluded column is left at its database default rather than copied.
DuplicateOptions::make()
->excludeColumns('sku', 'external_ref')
->excludeColumnsMatching('/_count$/', '/^cached_/');created_at, updated_at and deleted_at are always excluded. /_count$/ is a shipped default, because a copied counter is wrong the instant it is written.Callbacks around the save
DuplicateOptions::make()
->beforeSave(fn (Model $duplicate, Model $source) => ...)
->afterSave(fn (Model $duplicate, Model $source) => ...);$duplicate::class if you only mean to touch one.Unique columns
Declare a column unique and the duplicate gets a value that does not collide — resolved in a single query.
DuplicateOptions::make()->uniqueColumns('name');
// "Widget" → "Widget (1)" → "Widget (2)"Strategies
| Strategy | Result |
|---|---|
NumericSuffix default | Widget (1) |
Uuid | Widget (3f2a91c7) |
Ulid | Widget (7ZK4M2QX) |
Timestamp | Widget (1769812345) |
use Gabrielesbaiz\DuplicateToolkit\Enums\UniqueStrategy;
DuplicateOptions::make()
->uniqueColumns('name')
->uniqueUsing(UniqueStrategy::Uuid);The format
The numeric strategy uses duplicate-toolkit.unique_format, which defaults to ' (%d)'. Change it globally, or per model through the config.
"Widget (1)", then "Widget (2)", and so on — and it queried through an unsaved replica without excluding the source row, which could loop forever. 2.0 fetches the matching values once and picks the lowest free suffix in memory.Depth and cycles
How far the duplication follows, and what stops a self-referencing tree from running forever.
Setting the depth
DuplicateOptions::make()->depth(2);// two levels of relationsDuplicateOptions::make()->shallow();// the root model only, same as depth(0)
The default comes from duplicate-toolkit.max_depth, which ships as 1 — one level of relations, which is what 1.x did and what most applications mean by "duplicate this record". Depth counts levels of relations, not models: the root is level 0, its children level 1, their children level 2. Going deeper is opt-in on purpose: on a real schema the second level often reaches transactional tables that reference the record without belonging to it.
Cycles
A model already visited during a run is never visited again. A category whose children are categories terminates on its own; so does a pair of models that reference each other.
class Category extends Model
{
use HasDuplicates;
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
}
$root->duplicate(); // terminates; each node is visited onceWhen the tree is deeper than the limit
By default the extra levels are quietly dropped. That is usually what you want, and occasionally a silent bug — a copy that looks complete and is not.
DuplicateOptions::make()->depth(2)->strictDepth();// throws MaxDepthExceededException if anything was left uncopied
Seeing the shape first
php artisan duplicate-toolkit:relations "App\Models\Product" --depth=3 --with-counts
Or measure it exactly, without writing anything:
$plan = Duplicate::of($product)->preview(); $plan->counts();// ['Version' => 12, 'Description' => 148, ...]
All options
DuplicateOptions is immutable and fully typed. Every method returns a new instance, so a model-level object can be derived from on each call without leaking state between duplications.
$options->excludeColumns('name'); on its own does nothing. Assign it or chain it.Columns
| Method | Effect |
|---|---|
excludeColumns(...$cols) | Left at the database default rather than copied. |
excludeColumnsMatching(...$patterns) | Same, by regular expression. |
uniqueColumns(...$cols) | Resolved to a non-colliding value. |
uniqueUsing(UniqueStrategy) | Which scheme makes them unique. |
Attributes
| Method | Effect |
|---|---|
withAttributes(array) | Forces values. Closures allowed. |
suffix($col, $s) prefix($col, $s) | Append or prepend. |
replace($col, $search, $replace) | Search and replace. |
mutate($col, Closure) | Transform the current value. |
Relations
| Method | Effect |
|---|---|
relation($name, $strategyOrCallback) | Set a strategy, or refine nested options. Accepts dot notation. |
copyRelations(...) | Force copy. |
referenceRelations(...) | Attach the originals. |
excludeRelations(...) | Skip. |
onlyRelations(...) | Copy these; skip everything else. |
excludeRelationColumns(array) | Per-relation excluded columns. |
uniqueRelationColumns(array) | Per-relation unique columns. |
relationUsing($name, $handler) | Take the relation over entirely. |
Behaviour
| Method | Effect |
|---|---|
depth(int) shallow() | How many levels to follow. |
strictDepth() | Throw rather than truncate a deeper tree. |
quietly() | Save without firing Eloquent model events. |
withTrashed() | Copy soft-deleted children too. |
withMedia(bool) | Copy medialibrary collections. Wins over a strategy set on the media relation. |
trackProvenance(?string) | Write the source key to a column, if it exists. |
dryRun() | Write everything, then roll back. |
beforeSave(Closure) afterSave(Closure) | Hooks around each save in the tree. |
merge(DuplicateOptions) | Combine two sets; the incoming one wins. |
Reading them back
There is no __get(). Every value has a typed getter — getExcludedColumns(), getUniqueColumns(), getMutators(), strategyFor($relation), forRelation($relation), getDepth(), shouldSaveQuietly() and so on — which is what lets the package hold PHPStan at level max with no baseline.
Entry points
// on the model$product->duplicate($options);// returns the new model$product->duplicateWithResult($options);// returns a DuplicateResult$product->duplicator();// returns a PendingDuplicate// through the facade — useful when your class already has duplicate()Duplicate::of($product)->suffix('name', ' - COPY')->execute(); Duplicate::of($product)->preview(); Duplicate::of($product)->onQueue('duplicates')->dispatch(); Duplicate::run($product, $options);
The result object
Every duplication can hand back a record of what it did — most importantly, which new key corresponds to which old one.
$result = $product->duplicateWithResult();
What it carries
| Property or method | Returns |
|---|---|
model | The new root model. |
source | What it was copied from. |
idMap(?string $class) | Old key to new key, for one class or all of them. |
newKeyFor(Model $source) | The new key for a single source record. |
count(?string $class) | Records created, in total or per class. |
counts() | The whole tally, keyed by class name. |
durationMs | How long the run took. |
dryRun | Whether anything was actually kept. |
toArray() | All of the above, for logging. |
Why the id map exists
Without it, rewiring a duplicated tree means matching records by a human-readable column:
// fragile: two versions with the same name silently mis-map,// and a renamed one drops out entirely$map = $original->versions->mapWithKeys(fn ($v) => [ $v->id => $copy->versions->firstWhere('name', $v->name)?->id, ]);
With it, the mapping is the one the engine actually created:
$result = $product->duplicateWithResult();
foreach ($descriptions as $description) {
$description->update([
'version_id' => $result->idMap(Version::class)[$description->version_id],
]);
}In a listener
The Duplicated event carries the whole result, after the transaction commits.
use Gabrielesbaiz\DuplicateToolkit\Events\Duplicated;
Event::listen(Duplicated::class, function (Duplicated $event) {
Log::info('Duplicated', $event->result->toArray());
});Artisan commands
Four commands: one to look, one to do, one to generate, one to migrate.
duplicate-toolkit:relations
Prints the relation tree of a model and the strategy that applies to each relation — resolved from your duplicateOptions(), not the framework default.
php artisan duplicate-toolkit:relations "App\Models\Product" --depth=2 --with-counts
INFO Product (products) — 7 relation(s).
Relation Type Related Strategy Rows
────────────────────────────────────────────────────────────────
versions HasMany Version copy 12
└─ descriptions HasMany Description copy 148
setting HasOne Setting copy 1
notes MorphMany Note copy 3
categories BelongsToMany Category reference 4
tags BelongsToMany Tag reference 22
auditLogs HasMany AuditLog skip 940| Flag | Effect |
|---|---|
--depth=N | How many levels to walk. Default 1. |
--with-counts | Count rows per related table. One query each, off by default. |
--all | Include parent and through relations, which are never duplicated. |
--json | Machine readable output. |
Omit the model argument for a searchable picker.
duplicate-toolkit:duplicate
Duplicates one record. Interactively it walks you through model, record, relations and a dry-run plan before writing anything; the relation checkboxes come pre-ticked from the model's own options, so pressing Enter through the prompts reproduces production behaviour.
php artisan duplicate-toolkit:duplicate Product 41 \
--only=versions,bundles \
--reference=categories \
--suffix='name= - COPY' \
--depth=2 \
--dry-run| Flag | Effect |
|---|---|
--only=a,b | Copy only these; skip the rest. |
--reference=a | Attach the originals instead of copying. |
--exclude=a | Skip the relation. |
--exclude-column=c --unique=c | Column rules. |
--set=c=v --suffix=c=s --replace=c=a:b | Attribute overrides. |
--depth=N | How deep to follow. |
--quiet-events | Save without firing model events. |
--dry-run | Show the plan, write nothing. |
--queue | Dispatch instead of running inline. |
duplicate-toolkit:make-options
Runs the same relation picker and writes a typed duplicateOptions() into the model, adding the trait, the Duplicatable interface and the imports if they are missing, and pre-excluding *_count columns. It shows the code and asks before writing.
php artisan duplicate-toolkit:make-options "App\Models\Product" --print
duplicate-toolkit:upgrade
Rewrites 1.x usages to the 2.0 API and reports the two cases that need a human decision. See Upgrading from 1.x.
php artisan duplicate-toolkit:upgrade app --dry-run php artisan duplicate-toolkit:upgrade app
Configuration
Every key has a per-duplication equivalent on DuplicateOptions, and the options always win.
php artisan vendor:publish --tag=duplicate-toolkit-config
Keys
| Key | Default | Controls |
|---|---|---|
model_namespaces | ['App\Models', 'App'] | Where the commands look when you pass a short model name. |
max_depth | 1 | How many levels of relations are followed. Deeper is opt-in — a second level often reaches transactional tables. |
chunk_size | 500 | lazyById() chunk size when copying relations. |
save_quietly | false | Save duplicates without firing Eloquent events. |
unique_strategy | NumericSuffix | How unique columns are resolved. |
unique_format | ' (%d)' | Format used by the numeric strategy. |
excluded_columns | [] | Columns never copied, globally. |
excluded_column_patterns | ['/_count$/'] | Column patterns never copied, globally. |
default_relation_strategy | Copy | Fallback for child relations. |
default_pivoted_relation_strategy | Reference | Fallback for pivoted relations. |
provenance_column | 'duplicated_from_id' | Column written by trackProvenance(). |
discovery.invoke_untyped | false | Allow discovery to call relation methods with no return type. |
discovery.cache | true | Memoise discovered relations per class. |
copy_trashed | false | Copy soft-deleted children. |
media.enabled | true | Copy medialibrary collections when installed. The media relation itself is never walked. |
queue.connection queue.queue | null | Where DuplicateModel is dispatched. |
created_at, updated_at and deleted_at are always excluded. There is no sensible case for copying them.Events and queue
Two Eloquent model events, four dispatched events, and a batchable job.
Model events
Registered exactly like the framework's own. Returning false from a duplicating listener aborts the run and nothing is written.
protected static function booted(): void
{
static::duplicating(function (Product $product) {
return $product->is_locked === false; // false aborts
});
static::duplicated(fn (Product $product) => ...);
}Dispatched events
| Event | When | Carries |
|---|---|---|
Duplicating | Before anything is written | source, resolved options |
Duplicated | After the transaction commits | source, duplicate, full result |
RelationDuplicating | Before each relation | relation meta, chosen strategy |
RelationDuplicated | After each relation | relation meta, strategy, record count |
Duplicated fires after commit, so anything it triggers sees rows that are actually there. On a dry run it does not fire at all.The queue
Duplicate::of($product)->dispatch();
Duplicate::of($product)->onQueue('duplicates')->onConnection('redis')->dispatch();Jobs\DuplicateModel is ShouldQueue and Batchable, tagged for Horizon, and guarded by WithoutOverlapping keyed on the source model — so the same record is never duplicated twice concurrently. The connection and queue default to duplicate-toolkit.queue.
Bus::batch(
$products->map(fn ($p) => new DuplicateModel($p))->all()
)->dispatch();mutate(), beforeSave(), afterSave() and relationUsing() must live on the model's duplicateOptions() rather than be passed at dispatch time.Relation discovery
Relations are found by reflecting on method return types. Nothing is invoked, no file is read, and results cannot leak between model classes.
The contract
public function versions(): HasMany// ✓ discovered{ return $this->hasMany(Version::class); } public function versions()// ✗ invisible by default{ return $this->hasMany(Version::class); }
Add the return type. It is better code regardless, and it is what lets discovery stay free of side effects.
If you cannot
// config/duplicate-toolkit.php'discovery' => ['invoke_untyped' => true],
Why it was rewritten
The 1.x implementation opened the model's source file with SplFileObject, matched on stripos($code, '$this->hasMany('), and then invoked every public method to confirm. That broke under opcache, on eval'd classes, and on relations defined in traits — and it ran accessors that were never meant to be called during discovery.
It also kept a single static array of discovered relations that was never cleared, so duplicating a second model class in the same request inherited the first model's relations. The 2.0 cache is keyed by class name, which makes that failure structurally impossible.
Using the inspector directly
use Gabrielesbaiz\DuplicateToolkit\Support\RelationInspector; $inspector = app(RelationInspector::class); $inspector->for($product);// every relation$inspector->duplicatableFor($product);// children and pivoted only$inspector->get($product, 'versions');// one RelationMeta$inspector->tree($product, depth: 2);// nested, cycle-safe
Each entry is a RelationMeta: name, parentClass, type, relatedClass, kind, plus shortType(), relatedBasename() and defaultStrategy().
Recipes
Patterns that come up repeatedly, written out.
Rename the copy without a second write
$copy = $model->duplicate(fn ($o) => $o->suffix('name', ' - COPY'));Re-parent while duplicating
Duplication is a reasonable way to move a row under a different parent: copy it, and force the foreign keys in the same insert.
$copy = $description->duplicate(fn ($o) => $o->withAttributes([
'product_id' => $target->product_id,
'version_id' => $target->id,
]));Copy a product but keep the original categories
$result = $product->duplicateWithResult(
fn ($o) => $o->referenceRelations('categories')
);The copied bundles are attached to the same category rows. No category is duplicated, and the pivot payload comes across.
Rewire grandchildren with the id map
$result = $product->duplicateWithResult();
$versions = $result->idMap(Version::class);
foreach ($oldDescriptions as $description) {
$description->update(['version_id' => $versions[$description->version_id]]);
}Bulk duplicate with search and replace
foreach ($products as $product) {
$product->duplicate(fn ($o) => $o->replace('name', '2025', '2026'));
}Decide inline or queued, by size
$plan = Duplicate::of($product)->preview();
if ($plan->count() > 5_000) {
Duplicate::of($product)->onQueue('long')->dispatch();
} else {
Duplicate::of($product)->execute();
}Inside a Nova action
public function handle(ActionFields $fields, Collection $models)
{
$model = $models->first();
$result = $model->duplicateWithResult(
fn ($o) => $o->suffix('name', ' - COPY')
);
return Action::visit('/resources/products/'.$result->model->getKey());
}foreach ($models as $model) processes only the first one — a mistake easily made, and easily missed.Record where a copy came from
Schema::table('products', fn ($t) => $t->foreignId('duplicated_from_id')->nullable());
$copy = $product->duplicate(fn ($o) => $o->trackProvenance());
$copy->duplicated_from_id; // 41The column is written only if it exists on the table, so turning provenance on globally is safe for models that do not have it.
Troubleshooting
The failures people actually hit, and what each one means.
A relation is not being duplicated
php artisan duplicate-toolkit:relations "App\Models\Product"
- Missing from the list entirely — the method has no return type. See Relation discovery.
- Listed as
skiporreference— that is yourduplicateOptions(), or the type default. The command reads your configuration, so what it prints is what would happen. - Listed but nothing was written — check
depth(). A relation beyond the limit is quietly dropped unlessstrictDepth()is on.
My duplicateOptions() is ignored
The method must be public function duplicateOptions(): DuplicateOptions. The 1.x name getDuplicateOptions() is no longer called — run the upgrade command.
duplicate() does nothing, or the wrong thing
Your class probably defines its own duplicate() method, which silently shadows the trait — PHP gives the class method priority and reports no error. Rename yours, or go through the facade:
Duplicate::of($model)->execute();
Call to undefined method ::duplicate()
The trait is missing, or you are calling it on a query builder rather than a model instance. Product::query()->duplicate() is not a thing.
Counters are copied
Add excludeColumnsMatching('/_count$/'), or rely on the shipped config default. A copied counter is wrong the instant it is written.
Observers fire when I did not expect them to
Use quietly(), and make sure you are not following the duplication with an update() — that second write fires everything the first one suppressed. Fold the change into an attribute override instead.
It is slow, or runs out of memory
- Run
preview()first to find out how many rows are genuinely involved. - Lower
depth(), orexcludeRelations()the ones you do not need. - Raise
chunk_sizeif rows are small, lower it if they are large. - Move it to the queue with
->dispatch().
MaxDepthExceededException
You turned on strictDepth() and the tree really is deeper than the limit. Either raise depth() or accept the truncation by dropping strict mode.
NotDuplicatableException
The model does not exist in the database yet. Duplication reads relations through queries, so there has to be a row to read.
Upgrading from 1.x
2.0 is a rewrite. The concepts are the same; the names and the entry points changed, and there is a codemod for most of it.
morphToMany. If you duplicated two different model classes in one request under 1.x, treat the result as suspect.Requirements changed
| 1.x | 2.0 | |
|---|---|---|
| PHP | 8.0+ | 8.3+ |
| Laravel | 10, 11, 12 | 12, 13 |
Run the codemod
composer require gabrielesbaiz/duplicate-toolkit:^2.0 php artisan duplicate-toolkit:upgrade app --dry-run php artisan duplicate-toolkit:upgrade app
What it rewrites
| 1.x | 2.0 |
|---|---|
Traits\HasDuplicates | Concerns\HasDuplicates |
Options\DuplicateOptions | DuplicateOptions |
getDuplicateOptions() | duplicateOptions() |
DuplicateOptions::instance() | ::make() instance() still works |
$model->saveAsDuplicate() | $model->duplicate() |
->disableDeepDuplication() | ->shallow() |
->saveQuietly() | ->quietly() |
Helpers\RelationHelper | Support\RelationInspector |
excludeColumns(), uniqueColumns(), excludeRelations(), excludeRelationColumns() and uniqueRelationColumns() keep their names and their behaviour.
What it will not rewrite
1 · A model that already defines duplicate()
The 2.0 entry point is duplicate(). A method of that name on your class silently shadows the trait — no error, just the wrong behaviour. Rename yours, or route through Duplicate::of($model). The command reports every file where this applies.
2 · saveAsDuplicate() followed by update()
This pattern is the main reason 2.0 exists, and rewriting it automatically would change behaviour rather than just names.
// 1.x — two writes; the update fires what saveQuietly() suppressed$copy = $model->saveAsDuplicate(); $copy->update(['name' => $copy->name.' - COPY']);// 2.0 — one write, applied before the insert$copy = $model->duplicate(fn ($o) => $o->suffix('name', ' - COPY'));
Behaviour worth knowing about
- Options are immutable. Every method returns a new instance.
$o->excludeColumns('x');without using the result now does nothing. - No magic property access.
$options->excludedColumnsis gone; usegetExcludedColumns(). - Relation methods need return types. See Relation discovery.
- Pivoted relations are still referenced by default — the 1.x behaviour, now named explicitly as
referenceRelations().
Worth adopting afterwards
- Replace name-matching lookups with
$result->idMap(). - Replace hand-listed
*_countexclusions withexcludeColumnsMatching('/_count$/'). - Run
preview()before large duplications.
Project & licence
Who maintains this, what it promises, and what it does not.
Licence
MIT, and it will stay MIT. Nothing is paywalled, nothing phones home, and no feature is held back for sponsors.
Quality gates
| Gate | Standard |
|---|---|
| Static analysis | PHPStan level max, no baseline, no ignores |
| Style | Pint, Laravel preset with strict types enforced |
| Refactoring | Rector, PHP 8.3 and Laravel sets |
| Tests | Pest, against a real Testbench workbench |
| Matrix | PHP 8.3 and 8.4 × Laravel 12 and 13, prefer-lowest and prefer-stable |
Credits
Written and maintained by Gabriele Sbaiz. The 1.x line was a fork of neurony/laravel-duplicate, whose design informed this one; 2.0 shares no code with it.
Security
Report vulnerabilities privately to gabriele@sbaiz.com rather than opening a public issue. Authorisation is your application's responsibility — this package duplicates whatever model it is handed.
Disclaimer
This package writes to your database. It creates rows, copies relations and, when asked, attaches existing records to new ones. Whoever deploys it is responsible for deciding whether the result is correct for their schema — which includes reviewing what each relation strategy will do, running preview() against production-shaped data, keeping backups, and understanding that duplicated rows may trigger observers, listeners, queued jobs and search indexing unless quietly() is used.
Changelog
What changed, and when. Dates are the release date; versions follow semantic versioning.
2.0.0
BreakingA full rewrite. See Upgrading from 1.x for the migration path and the duplicate-toolkit:upgrade codemod.
Requirements: PHP 8.3+ (was 8.0+), Laravel 12 and 13 (was 10, 11, 12).
Fixed
- The package had no service provider.
DuplicateToolkitServiceProviderwas referenced by the test suite but never existed, andcomposer.jsonhad noextra.laravelblock, so nothing auto-discovered. - Relations leaked between model classes.
RelationHelper::$relationswas a static accumulator that was never reset, so duplicating a second model in the same request inherited the first model's relations. - Relation discovery read model source files with
SplFileObjectand matched onstripos, then invoked every public method to test it. It broke under opcache, on eval'd classes and on relations declared in traits, and triggered side effects in accessors. hasManyThroughwas matched during discovery and then silently dropped.- Pivot attributes were read through
getOtherKey(), a deprecated alias, and assumed a loaded pivot — a fatal onmorphToMany. - Unique column resolution queried an unsaved replica and never excluded the source row, producing wrong suffixes and a possible infinite loop. It now resolves in a single query.
saveAsDuplicate()wrapped everything intry { … } catch (Exception $e) { throw $e; }, which caught nothing and missedError.
Added
Duplicatefacade and a fluent builder:Duplicate::of($model)->…->execute().DuplicateResultwith the old key to new key map, per-model counts and timing.- Three relation strategies — copy, reference, skip — with dot notation for nested relations.
- Attribute overrides applied before the insert:
suffix(),prefix(),replace(),withAttributes(),mutate(). excludeColumnsMatching()for pattern-based column exclusion.- Configurable depth with cycle detection, plus
strictDepth(). preview()and--dry-run: every write performed inside a transaction, then rolled back.- Event classes and
beforeSave()/afterSave()callbacks. - Queued, batchable
Jobs\DuplicateModelwith overlap protection. - Custom per-relation handlers via
relationUsing(), and the#[DuplicateRelations]attribute. - Four Artisan commands:
relations,duplicate,make-options,upgrade. - Soft delete awareness, UUID and ULID keys, a provenance column, and optional media library copying.
- A publishable config file.
Changed
Traits\HasDuplicates→Concerns\HasDuplicates;Options\DuplicateOptions→DuplicateOptions.saveAsDuplicate()→duplicate();getDuplicateOptions()→duplicateOptions(), now optional.disableDeepDuplication()→shallow();saveQuietly()→quietly().DuplicateOptionsis immutable and fully typed — the__get()magic accessor is gone.- Relations are copied with
lazyById()chunking in one transaction;Duplicatedfires after commit. declare(strict_types=1)across the codebase.
Removed
Helpers\RelationHelper, replaced bySupport\RelationInspector. Its unused predicates are gone..php-cs-fixer.phpandtlint.json, superseded by Pint.
Tooling
- PHPStan at level max, Rector, Pint, and CI across PHP 8.3/8.4 × Laravel 12/13 on both
prefer-lowestandprefer-stable. - A real Testbench workbench with models covering every relation type, soft deletes, UUID keys and cycles.
1.1.0
- Added the
saveQuietly()option.
1.0.0
- Initial release, based on neurony/laravel-duplicate.