gabrielesbaiz/duplicate-toolkit
Laravel package for duplicating Eloquent models and their relationships. Clone records with customizable column overrides, relationship handling, and events via a simple API, making it easy to create reusable copies of templates, content, or configuration records.
Requirements gate first. PHP 8.3+, Laravel 12 or 13. If the app is on Laravel 10/11 or PHP 8.1/8.2, stop here — this is not an "install and see" package, it's coupled to a platform upgrade.
composer require gabrielesbaiz/duplicate-toolkit
2.0 ships a real service provider, so there is nothing to register. Verify auto-discovery worked before anything else:
php artisan list | grep duplicate-toolkit
# relations, duplicate, make-options, upgrade
Wire up one model, not the whole app. Add the trait and generate the options method:
use Gabrielesbaiz\DuplicateToolkit\Concerns\HasDuplicates;
class Invoice extends Model
{
use HasDuplicates;
public function duplicateOptions(): array
{
return [
// start empty; `make-options` will scaffold from your relations
];
}
}
php artisan duplicate-toolkit:make-options "App\Models\Invoice"
Look at your relation tree before you write any config. This is the single highest-value five minutes in the whole evaluation — it turns an abstract "relation graph" into something you can reason about and argue over with a PM:
php artisan duplicate-toolkit:relations "App\Models\Invoice"
First use case — the shallow copy. Defaults are deliberately conservative (max_depth = 1), so this copies the row and one level of relations:
$copy = $invoice->duplicate();
That's the whole day-one integration. Everything below is refinement.
Where to look, in order: the docs site (gabrielesbaiz.github.io/duplicate-toolkit), then UPGRADE.md if and only if you are already on 1.x, then the relations output for your own models. If you're on 1.x, read the upgrade guide before running anything — 2.0 shares no code with 1.x, and there is a known bad case (two different model classes duplicated in one request) that the migration command cannot fix for you.
Model-level defaults, call-site overrides. Put the boring, stable rules on the model and let call sites deviate:
public function duplicateOptions(): array
{
return [
'relations' => [
'lineItems' => 'copy',
'customer' => 'reference', // never clone the customer
'auditNotes'=> 'skip',
],
];
}
Three strategies — copy, reference, skip — settable from the model, from the call site, or via dot notation several levels down ('customer.addresses' => 'skip'). This is where product semantics live now: "duplicate the invoice but reference the customer" used to be custom code and is now configuration.
Attribute overrides apply before insert. This matters more than it looks. suffix, prefix, replace, withAttributes, and mutate are applied on the way in, so renaming a copy is one write — not an insert followed by an update that would re-fire the events quietly() just suppressed:
$copy = $invoice->duplicate(
suffix: ' (copy)',
withAttributes: ['status' => 'draft', 'approved_at' => null],
);
Wrap it behind an internal service. This is not ceremony. 2.0 is a full rewrite that shares no code with 1.x, and it renamed saveAsDuplicate() → duplicate(), Traits\HasDuplicates → Concerns\HasDuplicates, disableDeepDuplication() → shallow(), saveQuietly() → quietly(). A thin DuplicationService is what made that migration survivable and is what will make the next one survivable:
final class DuplicationService
{
public function duplicateInvoice(Invoice $invoice): Invoice
{
return $invoice->duplicate(
prefix: null,
withAttributes: ['status' => 'draft'],
);
}
}
Preview the cost before deep copies. Depth is configurable with cycle detection, and preview() / dry run writes everything then rolls back. Use it as a guard rail in admin UI ("this will create N rows") and as an assertion in tests:
$preview = $invoice->preview(/* options */); // nothing committed
Use strictDepth() when you want the duplication to fail rather than silently truncate at the depth boundary — the right choice for any graph you don't fully own.
Go async for large graphs. Queued and batchable jobs plus four events mean duplication doesn't have to block a request. The pattern that earns its keep: dispatch the job, listen for the completion event, notify the admin. This is what makes "clone this tenant configuration" viable instead of a timeout waiting to happen.
Use the key map for traceability. Every record created is tracked old-key → new-key. Stop matching duplicated rows by name — that breaks the moment someone renames a copy. Feed the map into analytics, support tooling, or follow-on record creation. A provenance column sits alongside it, so you can answer "how many copies came from templates?" without a join against a naming convention.
Testing. Duplication is one of those features that looks correct and quietly isn't. Assert on row counts and the key map inside RefreshDatabase, and lean on preview() for the expensive cases rather than building the graph in every test.
max_depth is 1, and that is intentional. 1.x never recursed past the first level; 2.0 kept that default rather than the previously-planned 3, because on a real schema a 43-row copy could have become thousands. Deeper is opt-in. If a developer says "it's not copying my nested relations," this is the answer nine times out of ten — not a bug.
DuplicateOptions is immutable and fully typed, and __get() is gone. Any 1.x code that reached into options dynamically will now fail loudly (better than the alternative). Treat it as a value object: derive a new one, don't mutate.
quietly() is not a substitute for attribute overrides. It suppresses events, and it does so for good reason on bulk copies. But the pre-insert override path exists precisely so you don't need a quietly() + second save() dance to rename a copy — that pattern re-fires events and costs an extra write.
Never hand-copy media library rows. Media rows are owned by a dedicated media pass, not the generic relation walk. In 1.x they were cloned by the walk, which left the copy pointing at the original's file directory. If you have 1.x-era duplicates, those are wrong and need checking independently of any migration.
Don't mix relation graphs across model classes in one request. The worst 1.x bug was a static cache that never reset, so duplicating a second model in the same request inherited the first model's relations. It's fixed in 2.0 — but the data produced under 1.x is suspect, and php artisan duplicate-toolkit:upgrade explicitly cannot rewrite it. If a 1.x request duplicated two different model classes, verify those rows by hand.
Unique columns are resolved in a single query that excludes the source row. In 1.x this ran against an unsaved replica without excluding the original, producing wrong suffixes and occasionally an endless loop. If you're generating suffixes against a unique index, do a targeted sanity check after upgrade — this is exactly the kind of bug that produces plausible-looking data.
Discovery reflects on return types and invokes nothing. Worth knowing when debugging: 1.x would actually call your public methods to confirm a relation, which meant side effects in accessors and breakage under opcache. 2.0 doesn't. So "my accessor isn't running during discovery" is now correct behaviour, not a regression.
morphToMany no longer fatals, and pivot attributes no longer assume a loaded pivot. But custom pivot models and versioning behaviour are the first thing to spike if your schema leans on them — a single spike on two or three real models is hours now, and it's the cheapest way to find the one thing the package can't do.
Pin the version and read the source. Zero stars, zero dependents, single maintainer, and 2.x has effectively no production track record because it's a rewrite. The engineering hygiene signals are genuinely strong — PHPStan at max with no baseline or ignores, Pint, Rector, 105 tests against a real Testbench app, CI across PHP 8.3/8.4 × Laravel 12/13 × prefer-lowest/prefer-stable. That justifies internal tools, admin workflows, and MVPs. It does not justify core regulated data flows.
Debugging checklist, in order: relations (is the edge even there?), preview() (how many rows should this be?), then events (did the job actually run?). Those three answer most questions without touching the source.
How can I help you explore Laravel packages today?