Oh snap”, said the project manager. “The client has this whole range of rich articles they probably are expecting to still work after the migration!

The project was a relaunch of a Drupal / Commerce 1 site, redone for Drupal 8 and Commerce 2. A couple of weeks before the relaunch, and literally days before the client was allowed in to see the staging site, we found out we had forgotten a whole range of rich articles where the client had carefully crafted landing pages, campaign pages and “inspiration” pages (this is a interior type of store). The pages were panel nodes, and it had a handful of different panel panes (all custom).

In the new site we had made Layout builder available to make such pages.

We had 2 options:

  • Redo all of them manually with copy paste.
  • Migrate panel nodes into layout builder enabled nodes.

Is that even possible?”, said the project manager.

Well, we just have to try, won’t we?

Creating the destination node type

First off, I went ahead and created a new node type called “inspiration page”. And then I enabled layout builder for individual entities for this node type.

Now I was able to create “inspiration page” landing pages. Great!

Creating the migration

Next, I went ahead and wrote a migration plugin for the panel nodes. It ended up looking like this:

id: mymodule_inspiration
label: mymodule inspiration
migration_group: mymodule_migrate
migration_tags:
  - mymodule
source:
  # This is the source plugin, that we will create.
  plugin: mymodule_inspiration
  track_changes: TRUE
  # This is the key in the database array.
  key: d7
  # This means something to the d7_node plugin, that we inherit from.
  node_type: panel
  # This is used to create a path (not covered in this article).
  constants:
    slash: '/'
process:
  type:
    plugin: default_value
    # This is the destination node type
    default_value: inspiration_page
  # Copy over some values
  title: title
  changed: changed
  created: created
  # This is the important part!
  layout_builder__layout: layout
  path:
    plugin: concat
    source:
      - constants/slash
      - path
destination:
  plugin: entity:node
  # This is the destination node type
  default_bundle: inspiration_page
dependencies:
  enforced:
    module:
      - mymodule_migrate

As mentioned in the annotated configuration, we need a custom source plugin for this. So, let’s take a look at how we make that:

Creating the migration plugin

If you have a module called “mymodule”, you create a folder structure like so, inside it (just like other plugins):

src/Plugin/migrate/source

And let’s go ahead and create the “Inspiration” plugin, a file called Inspiration.php:

<?php

namespace Drupal\mymodule_migrate\Plugin\migrate\source;

use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\State\StateInterface;
use Drupal\layout_builder\Section;
use Drupal\layout_builder\SectionComponent;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Drupal\node\Plugin\migrate\source\d7\Node;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Panel node source, based on panes inside a panel page.
 *
 * @MigrateSource(
 *   id = "mymodule_inspiration"
 * )
 */
class Inspiration extends Node {

  /**
   * Uuid generator.
   *
   * @var \Drupal\Component\Uuid\UuidInterface
   */
  protected $uuid;

  /**
   * Inspiration constructor.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    MigrationInterface $migration,
    StateInterface $state,
    EntityManagerInterface $entity_manager,
    ModuleHandlerInterface $module_handler,
    UuidInterface $uuid
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition,
      $migration, $state, $entity_manager, $module_handler);
    $this->uuid = $uuid;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $migration,
      $container->get('state'),
      $container->get('entity.manager'),
      $container->get('module_handler'),
      $container->get('uuid')
    );
  }

}

Ok, so this is the setup for the plugin. For this specific migration, there were some weird conditions for which of the panel nodes were actually inspiration pages. If I copy-pasted it here, you would think I was insane, but for now I can just mention that we were overriding the public function query. You may or may not need to do the same.

So, after getting the query right, we are going to do some work inside of the prepareRow function:

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    $result = parent::prepareRow($row);
    if (!$result) {
      return $result;
    }
    // Get all the panes for this nid.
    $did = $this->select('panels_node', 'pn')
      ->fields('pn', ['did'])
      ->condition('pn.nid', $row->getSourceProperty('nid'))
      ->execute()
      ->fetchField();
    // Find all the panel panes.
    $panes = $this->getPanelPanes($did);
    $sections = [];
    $section = new Section('layout_onecol');
    $sections[] = $section;
    foreach ($panes as $delta => $pane) {
      if (!$components = $this->getComponents($pane)) {
        // You must decide what you want to do when a panel pane can not be
        // converted.
        continue;
      }
      // Here we used to have some code dealing with changing section if this
      // and that. You may or may not need this.
      foreach ($components as $component) {
        $section->appendComponent($component);
      }
    }
    $row->setSourceProperty('layout', $sections);
    // Don't forget to migrate the "path" part. This is left out for this
    // article.
    return $result;
  }

Now you may notice there are some helper methods there. They look something like this:

  /**
   * Helper.
   */
  protected function getPanelPanes($did) {
    $q = $this->select('panels_pane', 'pp');
    $q->fields('pp');
    $q->condition('pp.did', $did);
    $q->orderBy('pp.position');
    return $q->execute();
  }

  /**
   * Helper to get components back, based on pane configuration.
   */
  protected function getComponents($pane) {
    $configuration = @unserialize($pane["configuration"]);
    if (empty($configuration)) {
      return FALSE;
    }
    $region = 'content';
    // Here would be the different conversions between panel panes and blocks.
    // This would be very varying based on the panes, but here is one simple
    // example:
    switch ($pane['type']) {
      case 'custom':
        // This is the block plugin id.
        $plugin_id = 'my_custom_content_block';
        $component = new SectionComponent($this->uuid->generate(), $region, [
          'id' => $plugin_id,
          // This is the title of the block.
          'title' => $configuration['title'],
          // The following are configuration options for this block.
          'image' => '',
          'text' => [
            // These values come from the configuration of the panel pane.
            'value' => $configuration["body"],
            'format' => 'full_html',
          ],
          'url' => $configuration["url"],
        ]);
        return [$component];

      default:
        return FALSE;
    }
  }

So there you have it! Since we now have amazing tools in Drupal 8 (namely Layout builder and Migrate) there is not task that deserves the question “Is that even possible?”.

To finish off, let's have an animated gif called "inspiration". And I hope this will give some inspiration to other people migrating landing pages into layout builder.