When content URLs change during migrations, it is always a good idea to do something to handle the old URLs to prevent them from suddenly starting to throw 404s which are bad for SEO. In this article, we'll discuss how to migrate URL aliases provided by the path module (part of D8 core) and URL redirects provided by the redirect module.

The Problem

Say we have two CSV files (given to us by the client):

The project requirement is to:

  • Migrate the contents of article.csv as article nodes.
  • Migrate the contents of category.csv as terms of a category terms.
  • Make the articles accessible at the path blog/{{ category-slug }}/{{ article-slug }}.
  • Make blog/{{ slug }}.php redirect to article/{{ article-slug }}.

Here, the term slug refers to a unique URL-friendly and SEO-friendly string.

Before We Start

Migrate Node and Category Data

This part consists of two simple migrations:

The article data migration depends on the category data migration to associate each node to a specific category like:

# Migration processes
process:
  ...
  field_category:
    plugin: 'migration_lookup'
    source: 'category'
    migration: 'example_category_data'
    no_stub: true
  ...

So, if we execute this migration, we will have all categories created as category terms and 50 squeaky new nodes belonging to those categories. Here's how it should look if we run the migrations using drush:

$ drush migrate-import example_article_data,example_category_data
Processed 5 items (5 created, 0 updated, 0 failed, 0 ignored) - done with 'example_category_data'
Processed 50 items (50 created, 0 updated, 0 failed, 0 ignored) - done with 'example_article_data'

Additionally, we will be able to access a list of articles in each category at the URL blog/{{ category-slug }}. This is because of the path parameter we set in the category data migration. The path parameter is processed by the path module to create URL aliases during certain migrations. We can also use the path parameter while creating nodes to generate URL aliases for those nodes. However, in this example, we will generate the URL aliases in a stand-alone migration.

Generate URL Aliases with Migrations

The next task will be to make the articles available at URLs like /blog/{{ category-slug }}/{{ article-slug }}. We use the example_article_alias migration to generate these additional URL aliases. Important sections of the migration are discussed below.

Source

source:
  plugin: 'csv'
  path: 'article.csv'
  ...
  constants:
    slash: '/'
    source_prefix: '/node/'
    alias_prefix: '/blog/'
    und: 'und'

We use the article.csv file as our source data to iterate over articles. Also, we use source/constants to define certain data which we want to use in the migration, but we do not have in the CSV document.

Destination

destination:
  plugin: 'url_alias'

Since we want to create URL aliases, we need to use the destination plugin url_alias provided by the path module. Reading documentation or taking a quick look at the plugin source at Drupal\path\Plugin\migrate\destination\UrlAlias::fields(), we can figure out the fields and configuration supported by this plugin.

Process

...
temp_nid:
  plugin: 'migration_lookup'
  source: 'slug'
  migration: 'example_article_data'
...
temp_category_slug:
    # First, retrieve the ID of the taxonomy term created during the "category_data" migration.
    -
      plugin: 'migration_lookup'
      source: 'category'
      migration: 'example_category_data'
    # Use a custom callback to get the category name.
    -
      plugin: 'callback'
      callable: '_migrate_example_paths_load_taxonomy_term_name'
    # Prepare a url-friendly version for the category.
    -
      plugin: 'machine_name'

Since we need to point the URL aliases to the nodes we created during the article data migration, we use use the migration_lookup plugin (formerly migration) to read the ID of the relevant node created during the article data migration. We store the node id in temp_nid. I added the prefix temp_ to the property name because we just need it temporarily for calculating another property and not for using it directly.

Similarly, we need to prepare a slug for the category to which the node belongs. We will use this slug to generate the alias property.

source:
  plugin: 'concat'
  source:
    - 'constants/source_prefix'
    - '@temp_nid'

Next, we generate the source, which is the path to which the alias will point. We do that by simply concatenating '/nid/' and '@temp_nid' using the concat plugin.

alias:
  plugin: 'concat'
  source:
    - 'constants/alias_prefix'
    - '@temp_category_slug'
    - 'constants/slash'
    - 'slug'

And finally, we generate the entire alias by concatenating '/article/', '@temp_category_slug', a '/' and the article's '@slug'. After running this migration like drush migrate-import example_article_alias, all the nodes should be accessible at /article/{{ category-slug }}/{{ article-slug }}.

Generate URL Redirects with Migrations

For the last requirement, we need to generate redirects, which takes us to the redirect module. So, we create another migration named example_article_redirect to generate redirects from /blog/{{ slug }}.php to the relevant nodes. Now, let's discuss some important lines of this migration.

Source

constants:
  # The source path is not supposed to start with a "/".
  source_prefix: 'blog/'
  source_suffix: '.php'
  redirect_prefix: 'internal:/node/'
  uid_admin: 1
  status_code: 301

We use source/constants to define certain data which we want to use in the migration, but we do not have in the CSV document.

Destination

destination:
  plugin: 'entity:redirect'

In Drupal, every redirect rule is an entity. Hence, we use the entity plugin for the destination.

Process

redirect_source:
  plugin: 'concat'
  source:
    - 'constants/source_prefix'
    - 'slug'
    - 'constants/source_suffix'

First, we determine the path to be redirected. This will be the path as in the old website, example, blog/{{ slug }}.php without a / in the front.

redirect_redirect:
  plugin: 'concat'
  source:
    - 'constants/redirect_prefix'
    - '@temp_nid'

Just like we did for generating aliases, we read node IDs from the article data migration and use them to generate URIs to which the user should be redirected when they visit one of the /blog/{{ slug }}.php paths. These destination URIs should be in the form internal:/node/{{ nid }}. The redirect module will intelligently use these URIs to determine the URL alias for those paths and redirect the user to the path /article/{{ slug }} instead of sending them to /node/{{ nid }}. This way, the redirects will not break even if we change the URL alias for a particular node after running the migrations.

# We want to generate 301 permanent redirects as opposed to 302 temporary redirects.
status_code: 'constants/status_code'

We also specify a status_code and set it to 301. This will create 301 permanent redirects as opposed to 302 temporary redirects. Having done so and having run this third migration as well, we are all set!

Migration dependencies

migration_dependencies:
  required:
    - 'example_article_data'

Since the migration of aliases and the migration of redirects both require access to the ID of the node which was generated during the article data migration, we need to add the above lines to define a migration_dependency. It will ensure that the example_article_data migration is executed before the alias and the redirect migrations. So if we run all the migrations of this example, we should see them executing in the correct order like:

$ drush mi --tag=example_article
Processed 5 items (5 created, 0 updated, 0 failed, 0 ignored) - done with 'example_category_data'
Processed 50 items (50 created, 0 updated, 0 failed, 0 ignored) - done with 'example_article_data'
Processed 50 items (50 created, 0 updated, 0 failed, 0 ignored) - done with 'example_article_alias'
Processed 50 items (50 created, 0 updated, 0 failed, 0 ignored) - done with 'example_article_redirect'

Next steps