Under the hood of the Duplicator addon

21st May 2022

Duplicator is by far my most popular Statamic addon, by downloads at least. And to be honest, it's probably one of my simplest.

Duplicator registers a few actions: one for each of the 'things' you can delete - an entry, an asset, a term and soon (hopefully) a form.

Actions are what you see when you click the 'three dots' on the listing tables:

Today, I'm going to walk you through the action for duplicating entries (which is probably the one used most often). So let's dig in:

php
public static function title()
{
return __('duplicator::messages.duplicate');
}

I thought I'd start off easy. That just returns the title of the action which is the text that's displayed on the 'actions dropdown in the CP.

We're making use of Laravel's __ method here which lets us 'localise' the text so it can be 'Duplicate' but in whatever language the current Control Panel user is using.

The community has contributed translations for a few languages - you can review the docs here on what languages are supported and how to add your own.

php
protected function fieldItems()
{
if (Site::all()->count() > 1) {
return [
'site' => [
'type' => 'select',
'instructions' => __('duplicator::messages.fields.site.instructions'),
'validate' => 'required|in:all,' . Site::all()->keys()->join(','),
'options' => Site::all()
->map(function (SitesSite $site) {
return $site->name();
})
->prepend(__('duplicator::messages.fields.site.all_sites'), 'all')
->toArray(),
'default' => 'all',
],
];
}
 
return [];
}

Actions can optionally use the fieldItems method to return any fields which should be shown to the user before the action is run.

If Duplicator is running on a multi-site, it'll show a 'Site' dropdown field which gives the user the option of duplicating the selected entry to a specific site or even all sites.

We'll get onto how the multisite duplicating works in a little bit.

php
public function visibleTo($item)
{
return $item instanceof AnEntry;
}
 
public function visibleToBulk($items)
{
return $this->visibleTo($items->first());
}

These two methods (visibleTo and visibleToBulk) are where you decide whether an action is shown/visible for a certain item(s). An item could be an Entry, a Term, an Asset, etc.

The visibleTo method expects a single item to be passed in. We then check whether the $item is an instance of the Entry object. If it is, we'll return true to tell Statamic our 'Duplicate' action should show in the Actions List.

The second method, visibleToBulk does pretty much the same thing but multiple items are passed into it.

Because I was lazy when I wrote this, we're just calling the other method for the first item in the $items collection. In some of my other addons, I map through the $items, pass it through the visibleTo, then check the count of them being true is the same as the original count of the $items collection.

The visibleToBulk method is called when you select multiple entries in the Listing Table - which then displays the available Actions at the top of the table.

php
public function run($items, $values)
{
collect($items)
->each(function ($item) use ($values) {
/** @var \Statamic\Entries\Entry $item */
if ($item instanceof AnEntry) {
$itemParent = $this->getEntryParentFromStructure($item);
$itemTitleAndSlug = $this->generateTitleAndSlug($item);
 
$entry = Entry::make()
->collection($item->collection())
->blueprint($item->blueprint()->handle())
->locale(isset($values['site']) && $values['site'] !== 'all' ? $values['site'] : $item->locale())
->published(config('duplicator.defaults.published', $item->published()))
->slug($itemTitleAndSlug['slug'])
->data(
$item->data()
->except(config("duplicator.ignored_fields.entries.{$item->collectionHandle()}"))
->merge([
'title' => $itemTitleAndSlug['title'],
])
->toArray()
);
 
if ($item->hasDate()) {
$entry->date($item->date());
}
 
if (config('duplicator.fingerprint') === true) {
$entry->set('is_duplicate', true);
}
 
$entry->save();
 
if ($itemParent && $itemParent !== $item->id()) {
$entry->structure()
->in(isset($values['site']) && $values['site'] !== 'all' ? $values['site'] : $item->locale())
->appendTo($itemParent->id(), $entry)
->save();
}
 
if (isset($values['site']) && $values['site'] === 'all') {
Site::all()
->reject(function ($site) use ($entry) {
return $site->handle() === $entry->locale();
})
->each(function ($site) use ($entry) {
$entry->makeLocalization($site->handle())->save();
});
}
}
});
}

And here we are... the meat of the article. How Duplicator does the duplicating!

Because an action can be run on multiple items at once, the first thing we're doing is looping through each of the $items with a Laravel Collection.

Next, we check if the current $item is actually an Entry instance (in case, somehow the visibleTo stuff didn't work).

After that, we then call two custom methods: getEntryParentFromStructure which figures out the entry's parent from the collection's tree, then the second, generateTitleAndSlug generates a title and slug for the duplicated entry. We'll discuss how both of these methods work individually later on.

Next, we start building an Entry object. We set the collection, the blueprint, the locale/site for it to be duplicated into, the publish status, the slug we just made, along with 'entry data'.

  • The collection will be the same as the original entry, so we can use $item->collection() to grab it.

  • The blueprint will also be the same. We can get the handle of the blueprint with $item->blueprint()->handle()

  • Depending on what the user selected when choosing the site(s) to duplicate on, we'll either duplicate to the selected site or to the same site as the original entry.

  • By default, for the publish status, we'll use the same published state as the original entry. However, you can override this in the addon's config file (maybe you want any duplicates to be unpublished by default)

  • The slug will literally just be the slug we generated a minute ago.

  • In terms of the entry data, we'll mostly just copy over the entry data from the original entry but we'll remove any 'ignored fields' (a config option in Duplicator) and we'll set a different title.

After that, we check if the entry collection has dates enabled. If it does, we set the date of the duplicated entry to the date of the original entry.

Duplicator has a concept of 'fingerprints' which essentially means it leaves a is_duplicate key on the entry so you can tell if an entry was created by Duplicator.

We then save the entry! 🎉

After it's saved, we do a couple more things:

  • Add the duplicated entry into the collection's tree (if it has one) - it'll be added just underneath the original entry.

  • If the user wants an entry to be duplicated to all of their sites, we'll do that here by creating a 'localisation' of the entry for each of the other sites.

php
protected function getEntryParentFromStructure(AnEntry $entry)
{
if (! $entry->structure()) {
return null;
}
 
$parentEntry = $entry
->structure()
->in($entry->locale())
->page($entry->id())
->parent();
 
if (! $parentEntry) {
return null;
}
 
if ($entry->structure()->expectsRoot() && $entry->structure()->in($entry->locale())->root()['entry'] === $parentEntry->id()) {
return null;
}
 
return $parentEntry;
}

Here's the first of our two custom methods. As described above, this method figures out the parent of the original entry in the collection's tree.

If the collection doesn't have a tree, we'll just return early.

Otherwise, we'll get the instance of the original entry in the tree, then we'll call the ->parent() method on it.

If the original entry has no parent in the collection tree, we also then just return early.

We then do another check to see if the original entry was the 'root page' in the collection tree (eg. a homepage). If it is, we also just return early as we can't have two root pages 😅.

If we make it this far, we simply return the parent entry.

php
/**
* This method has been copied from the Duplicate Entry code in Statamic v2.
* It's been updated to also deal with entry titles.
*/
protected function generateTitleAndSlug(AnEntry $entry, $attempt = 1)
{
$title = $entry->get('title');
$slug = $entry->slug();
 
if ($attempt == 1) {
$title = $title . __('duplicator::messages.duplicated_title');
}
 
if ($attempt !== 1) {
if (! Str::contains($title, __('duplicator::messages.duplicated_title'))) {
$title .= __('duplicator::messages.duplicated_title');
}
 
$title .= ' (' . $attempt . ')';
}
 
$slug .= '-' . $attempt;
 
// If the slug we've just built already exists, we'll try again, recursively.
if (Entry::findBySlug($slug, $entry->collection()->handle())) {
$generate = $this->generateTitleAndSlug($entry, $attempt + 1);
 
$title = $generate['title'];
$slug = $generate['slug'];
}
 
return [
'title' => $title,
'slug' => $slug,
];
}

And here's the second custom method...

This one generates the title & slug for duplicate entries. You might notice that if you duplicate the same entry multiple times, you get titles/slugs like Entry #1, Entry #2, Entry #3. This is the method that figures how many duplicates you've previously made and adjusts the title.

I copied this code from Statamic v2 (it had Duplicator's functionality built into Core).

The first thing this method does is assigns the title & slug of the original entry to variables.

Then, it'll generate a title based on the $attempt variable. So, $attempt being 5 would generate Entry #5.

It then checks if there's any existing entries in the collection with that title. If there is, it calls itself again, and ups the $attempt by 1. It does this until it finds a unique title/slug it can use, which it then returns.

There you go! I've literally given you all the code to build your own Duplicator addon. Hopefully that's been a helpful learning experience for you - diving into how Actions work & how to interact with some of Statamic's internal APIs.