DEV Community

Cover image for Laravel replicate(): Duplicate Eloquent Models Safely
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel replicate(): Duplicate Eloquent Models Safely

"Duplicate this" buttons look easy: copy the row, save it, done. Then the first copy fails with a duplicate-key error, or the new post comes out already deleted, or it quietly carries over an API token and a view count from the original. Eloquent has a built-in method for this, replicate(), and it handles more than people expect. It also leaves a few traps. I ran every example on Laravel 13.35.0 against a real database, so the results below are what actually happened.

The basic example

This one comes from the Laravel documentation. A shipping address becomes a billing address with one changed field:

$shipping = Address::create([
    'type'   => 'shipping',
    'line_1' => '123 Example Street',
    'city'   => 'Victorville',
]);

$billing = $shipping->replicate()->fill([
    'type' => 'billing',
]);

$billing->save();
Enter fullscreen mode Exit fullscreen mode

After this, the table had two rows: id 1 (shipping) and id 2 (billing), both with the same street. The key point from the docs: replicate() returns an unsaved copy. Nothing is written until you call save().

What replicate() leaves out automatically

In the framework source, the method skips these on its own: the primary key, the created_at and updated_at columns, any columns listed in uniqueIds() (the UUID/ULID helpers), and any columns listed in the #[Refreshes] attribute. So the copy has no ID and no timestamps, and Eloquent fills them in on save. For a model using HasUuids, the copy's ID was null before saving and a brand new UUID after.

Skip more attributes

Pass an array of names that should not be copied:

$copy = $flight->replicate(['last_flown', 'last_pilot_id']);
Enter fullscreen mode Exit fullscreen mode

I confirmed the excluded attribute came back as null on the copy. The array is added to the automatic list above, it does not replace it.

Trap 1: unique columns

If a column has a unique index, such as a slug, the copy is identical to the original, and the insert fails. Here is the real error from my test:

Illuminate\Database\UniqueConstraintViolationException
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'hello' ...
Enter fullscreen mode Exit fullscreen mode

The fix is to change the value before saving:

$copy = $post->replicate();
$copy->title = $post->title . ' (copy)';
$copy->slug  = $post->slug . '-copy';
$copy->save();
Enter fullscreen mode Exit fullscreen mode

If a user can duplicate the same post many times, generate a unique value, for example by checking for an existing slug and adding a number.

Trap 2: it copies everything else, including secrets and counters

The copy gets the raw attribute values of the original, and mass assignment rules do not apply. In my test the post had $fillable = ['title', 'slug', 'status'], a hidden api_token column and a reads counter. After replicate() and save(), the new row had the same api_token (SECRET) and reads = 40. A "duplicate" button would give the copy the original's token and its view count, and a published post would be published again straight away.

$copy = $post->replicate(['api_token', 'reads']);
$copy->status = 'draft';
$copy->save();
Enter fullscreen mode Exit fullscreen mode

A good habit: list every column you do not want carried over (tokens, counters, published flags), instead of hoping the copy is harmless.

Trap 3: copying a soft-deleted record

I soft-deleted a post, loaded it with withTrashed(), replicated and saved it. The new row had the same deleted_at value, so the copy was already deleted. Add 'deleted_at' to the exclude list when you can duplicate trashed records.

Trap 4: related records are not copied

A post with two comments, copied with replicate(), gives you a new post with no comments in the database. There is a subtle detail: if the relation was already loaded, the copy does carry it in memory (relationLoaded('items') was true and the count was 2). That only affects the object, though. After save(), the database had 0 child rows for the copy.

To duplicate the children as well, replicate each one and attach it, ideally inside a transaction so you never end up with half a copy:

DB::transaction(function () use ($post) {
    $new = $post->replicate();
    $new->slug = $post->slug . '-copy';
    $new->save();

    $new->items()->saveMany(
        $post->items->map(fn ($item) => $item->replicate())
    );
});
Enter fullscreen mode Exit fullscreen mode

That produced 2 new child rows for the copy and left the original's 2 rows alone. Remember to load $post->items first (or use $post->load('items')), otherwise it will lazy load the children, which is one more query. For very large trees, copy in a job instead of a web request.

Events: replicating and replicateQuietly()

replicate() fires a replicating event on the model, which is a good place to adjust a copy's fields (clear a token, reset a status) in one spot. If you do not want events, use replicateQuietly(). In my test, a call to each produced exactly one replicating event, from replicate(). The Laravel docs section for this topic does not show replicateQuietly(); I found it in the framework source and ran it.

Quick checklist before you ship a duplicate button

  • Change every unique column (slug, SKU, email).
  • Exclude secrets, counters and publish state.
  • Exclude deleted_at if trashed records can be copied.
  • Copy children explicitly, inside a transaction.
  • Remember the copy is unsaved until you call save().

References


Originally published on DEV Talk.

Top comments (0)