Hello to all Drupal developers! πŸ‘‹ Today I want to share my experience and thoughts on migrating projects from Drupal 7 to newer versions (Drupal 9/10). Many face this challenge, especially now as official support for Drupal 7 is coming to an end. ⏰

The Drupal 7 Situation πŸ•°οΈ

Drupal 7 has been running on thousands of sites for many years, but its support is gradually ending. The official end-of-support date has been postponed several times due to various factors, including the pandemic. So if you're still using Drupal 7, it's time to take action. πŸš€

But an important question arises: how exactly should you approach migration? Is it worth simply transferring the existing structure to a new version? My answer is mostly no. ❌ And here's why.

Problems with Direct Migration from D7 to D9/D10 🚧

1. Architectural Gap Between Versions πŸŒ‰

There is a fundamental architectural gap between Drupal 7 and newer versions. Drupal 8 and later versions are based on Symfony components, use a plugin system, services, object-oriented approach, and many other modern practices. A simple "migration" often means you're transferring outdated design patterns to a new system without utilizing its advantages. πŸ˜•

2. Technical Debt πŸ’Έ

From experience, I can say that most long-term projects on Drupal 7 have accumulated significant technical debt:

  • πŸ“¦ Outdated or unsupported modules
  • πŸ—„οΈ Suboptimal data structures
  • πŸ”„ Convoluted business logic
  • ⚠️ Unsafe hacks and code overrides
  • πŸ§ͺ Lack of tests

Direct migration means you'll transfer all this debt to your new system. It's like moving all your clutter to a new house without sorting through it first! 🏠➑️🏒

3. Missed Opportunities ✨

Drupal 9/10 provides many new capabilities that may be difficult to integrate if you're constrained by the old architecture:

  • πŸ”Œ APIs for decoupling (JSON:API, GraphQL)
  • 🧩 Component system and Twig
  • βš™οΈ Configuration management
  • 🎨 Layout Builder
  • πŸ“ Paragraphs and other modern approaches to content structuring

Why miss out on all the cool new toys? 🎁

4. Outdated Modules and Their Modern Alternatives πŸ”„

Many popular modules from Drupal 7 have been either completely redesigned or replaced with better alternatives in Drupal 9/10. It's like trading in your old flip phone for a smartphone! πŸ“±βœ¨ Here are some examples:

Drupal 7 Module Modern Alternative in D9/D10 Notes
Views Views (in core) Now part of core, but with a different API
Context Layout Builder + Block Visibility Groups More flexible and visual approach
Panels Layout Builder Native layout system
Wysiwyg CKEditor 5 (in core) Modern editor with more capabilities
Features Configuration Management Now built into core
Webform Webform or Contact Forms Enhanced Completely rebuilt from scratch
IMCE Media Library (in core) Modern media library
Ctools Various APIs in core Functionality moved to core
Pathauto Pathauto Exists, but uses new API
Backup & Migrate No direct equivalent Recommended to use external tools

The change in API also means that even if the module name remains the same, how it's used may have changed significantly. Same name, different beast! 🦁

The "Clean Slate" Approach 🧹✨

Instead of direct migration, I recommend a "clean slate" approach. This doesn't mean you should throw everything away and start from scratch. Rather, it means critically rethinking your project. Think of it as Marie Kondo-ing your Drupal site! πŸ‘—

Rethinking Project Needs πŸ€”

Ask yourself and the client:

  • 🎯 What business goals should the site achieve?
  • πŸ“ˆ Have these goals changed since the initial development?
  • πŸ“Š Which success metrics are most important now?
  • πŸ‘₯ What are the main use cases?

Time for some soul-searching with your website! πŸ§˜β€β™€οΈ

Audit of Existing Functionality and Data πŸ”

Conduct a detailed audit:

  • 🧰 Which modules and functions are actually being used?
  • πŸ“‹ Which content types and fields are really needed?
  • πŸ’Ύ What data is important to preserve, and what can be restructured?
  • πŸ“ˆ Which parts of the site generate the most traffic or conversions?

Put on your detective hat and start investigating! πŸ•΅οΈβ€β™€οΈ

Dividing into "Preserve" and "Rebuild" βœ‚οΈ

Based on the audit, create two lists:

  1. Preserve βœ… β€” data and functionality that definitely need to be transferred
  2. Rebuild πŸ”„ β€” parts that can or should be rethought

Time to make some tough decisions! πŸ’ͺ

Practical Action Plan πŸ“‹

1. Analysis of the Existing System πŸ”¬

Start by documenting the current architecture:

  • πŸ“¦ All modules used and their purpose
  • πŸ—‚οΈ Content types, taxonomies, field structures
  • πŸ’» Custom modules and functionality
  • πŸ”Œ APIs and integrations with other systems
  • ⏱️ Load and performance bottlenecks

Know thy enemyβ€”err, I mean website! πŸ˜‰

2. Designing a New Architecture πŸ—οΈ

Based on the analysis, design a new system:

  • 🧩 Choose core modules and create a strategy for configuring them
  • πŸ“ Design the structure of entity types
  • πŸ› οΈ Determine which parts need to be developed as custom modules
  • πŸ”Œ Plan APIs and integrations
  • πŸ§ͺ Define a testing strategy

Time to put on your architect hat! πŸ‘·β€β™€οΈ

Here's an example of how you can transform a convoluted field structure in Drupal 7 into a clean component-based approach in Drupal 9/10 using Paragraphs: βœ¨πŸ§™β€β™‚οΈ

Drupal 7 (old approach):

// Overly complex field with endless hook_form_alter
function mymodule_form_article_node_form_alter(&$form, &$form_state, $form_id) {
  // Add special validators for field combinations
  if (isset($form['field_related_content']) && isset($form['field_product_reference'])) {
    $form['#validate'][] = 'mymodule_validate_related_content';
  }

  // Hide/show fields based on other values
  $form['field_product_reference']['#states'] = array(
    'visible' => array(
      ':input[name="field_content_type[und]"]' => array('value' => 'product'),
    ),
  );

  // 50+ more lines of specific logic...
}

Drupal 9/10 (component approach with Paragraphs):

// Creating a paragraph type through configuration
$paragraphType = ParagraphsType::create([
  'id' => 'product_reference',
  'label' => 'Product Reference',
]);
$paragraphType->save();

// Behavior in the Parameter Entity Reference field
class ProductReferenceBehavior extends ParagraphsBehaviorBase {
  public function buildBehaviorForm(ParagraphInterface $paragraph, array &$form, FormStateInterface $form_state) {
    $form['display_price'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Display product price'),
      '#default_value' => $paragraph->getBehaviorSetting('product_reference', 'display_price', FALSE),
    ];
    return $form;
  }
}

This approach allows you to:

  1. 🧩 Separate different content types into logical components
  2. πŸ”„ Easily transfer these components between different content types
  3. πŸ–ŒοΈ Provide editors with a visual interface for working with components

Much cleaner, right? 😍

3. Data Migration Strategy πŸ“Š

Not all data can simply be transferred. Develop a strategy:

  • πŸ€– What to migrate automatically versus manually
  • πŸ”„ How to transform data for the new structure
  • βœ… How to verify data integrity after migration
  • πŸ—ƒοΈ What data to archive rather than migrate

Remember, data is the heart of your website! ❀️

Here's an example of a simple migration class for transferring content from Drupal 7 to Drupal 9/10 with data transformation: πŸ§™β€β™‚οΈβœ¨

namespace Drupal\my_migration\Plugin\migrate;

use Drupal\migrate\Plugin\Migration;
use Drupal\migrate\Row;
use Drupal\migrate\Plugin\MigrationInterface;

/**
 * @Migration(
 *   id = "custom_article_migration",
 *   source = {
 *     "plugin" = "d7_node",
 *     "node_type" = "article"
 *   },
 *   destination = {
 *     "plugin" = "entity:node",
 *     "default_bundle" = "article"
 *   },
 *   process = {
 *     "title" = "title",
 *     "body/value" = "body/0/value",
 *     "body/format" = "body/0/format",
 *     "field_tags" = {
 *       "plugin" = "migration_lookup",
 *       "migration" = "d7_taxonomy_term",
 *       "source" = "field_tags"
 *     },
 *     "field_image" = {
 *       "plugin" = "migration_lookup",
 *       "migration" = "d7_file",
 *       "source" = "field_image"
 *     }
 *   },
 *   migration_dependencies = {
 *     "required" = {
 *       "d7_taxonomy_term",
 *       "d7_file"
 *     }
 *   }
 * )
 */
class CustomArticleMigration extends Migration {

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    // Get old data
    $old_body = $row->getSourceProperty('body/0/value');

    // Transform content (example: update image format)
    if ($old_body) {
      $new_body = preg_replace(
        '/,
        ',
        $old_body
      );

      // Add Bootstrap 5 classes
      $new_body = str_replace('', '', $new_body);

      // Update the row
      $row->setSourceProperty('body/0/value', $new_body);
    }

    // Combine multiple old fields into one new paragraph field
    $featured = $row->getSourceProperty('field_featured');
    $highlight_color = $row->getSourceProperty('field_highlight_color');

    if ($featured == 1) {
      $paragraph_data = [
        'type' => 'featured_content',
        'field_color' => $highlight_color ?: 'blue',
        'field_display_mode' => 'highlighted',
      ];

      $row->setSourceProperty('field_content_components', [$paragraph_data]);
    }

    return parent::prepareRow($row);
  }
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    





  
  
  4. Parallel Implementation 🏎️🏎️
It's often impossible to simply "turn off" the old site and "turn on" the new one. I recommend:
πŸ‘―β€β™‚οΈ Developing the new site in parallel with the operation of the old one
πŸ“ˆ Creating a gradual transition plan
πŸ”„ Preparing a data synchronization strategy during the transition period
πŸ”™ Developing a rollback plan in case of problems
Always have a safety net! πŸ₯½
  
  
  Practical Examples πŸ”

  
  
  Case: Educational Portal πŸŽ“
I worked on migrating a large educational portal from D7 to D9. Instead of direct migration, we:

Conducted a full UX audit πŸ” and found that 40% of functionality was hardly used

Rethought the course structure πŸ—οΈ β€” instead of dozens of separate fields, we created components with Paragraphs

Developed a new API architecture πŸ“± that allowed us to create a mobile app using the same backend

Implemented incremental migration πŸ“¦ of content, starting with the most popular sections
Example code for an API endpoint that allows retrieving course data for a mobile app: πŸ“±βœ¨



namespace Drupal\custom_course_api\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\node\Entity\Node;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;

/**
 * Controller for the courses API.
 */
class CoursesApiController extends ControllerBase {

  /**
   * Entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity_type.manager')
    );
  }

  /**
   * Constructor.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   Entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * Returns a list of courses in JSON format.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return \Drupal\Core\Cache\CacheableJsonResponse
   *   JSON response with course data.
   */
  public function getCourses(Request $request) {
    $category = $request->query->get('category');
    $limit = $request->query->get('limit', 10);

    // Base query for courses
    $query = $this->entityTypeManager->getStorage('node')->getQuery()
      ->condition('type', 'course')
      ->condition('status', 1)
      ->sort('created', 'DESC')
      ->range(0, $limit);

    // Add filter by category if specified
    if ($category) {
      $query->condition('field_course_category', $category);
    }

    $nids = $query->execute();
    $courses = [];
    $cache_metadata = new CacheableMetadata();

    foreach ($nids as $nid) {
      $node = Node::load($nid);
      $cache_metadata->addCacheableDependency($node);

      // Structure data for the API
      $courses[] = [
        'id' => $node->id(),
        'title' => $node->label(),
        'description' => $node->field_description->value,
        'duration' => $node->field_duration->value,
        'image' => $node->field_image->entity 
          ? $node->field_image->entity->createFileUrl() 
          : NULL,
        'lessons' => $this->getLessonsData($node),
      ];
    }

    $response = new CacheableJsonResponse($courses);
    $response->addCacheableDependency($cache_metadata);

    return $response;
  }

  /**
   * Gets lesson data for a course.
   *
   * @param \Drupal\node\Entity\Node $course
   *   Course node.
   *
   * @return array
   *   Array with lesson data.
   */
  protected function getLessonsData(Node $course) {
    $lessons = [];

    if ($course->hasField('field_lessons') && !$course->field_lessons->isEmpty()) {
      foreach ($course->field_lessons->referencedEntities() as $lesson) {
        $lessons[] = [
          'id' => $lesson->id(),
          'title' => $lesson->label(),
          'preview' => $lesson->field_preview->value,
        ];
      }
    }

    return $lessons;
  }
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




Result: πŸŽ‰ Instead of a simple "clone" on a new platform, we got a modern, modular system where loading speed significantly increased, and conversion rates substantially improved as well. Win-win! πŸ†
  
  
  Comparison of Old and New Architecture πŸ“Š
Old Architecture (D7) πŸ‘΅:
🏒 Monolithic system with heavy Views
πŸ•ΈοΈ Convoluted structure of fields and taxonomies
πŸ“š Large volume of PHP code in the theme
🐒 Slow database queries
Example of old theme code in D7 πŸ™ˆ:


/**
 * Implements hook_preprocess_node().
 */
function mytheme_preprocess_node(&$variables) {
  if ($variables['type'] == 'course') {
    // Check various conditions and add many template variables
    $node = $variables['node'];

    // Custom logic for determining course status
    if (!empty($node->field_start_date) && !empty($node->field_end_date)) {
      $start = strtotime($node->field_start_date[LANGUAGE_NONE][0]['value']);
      $end = strtotime($node->field_end_date[LANGUAGE_NONE][0]['value']);
      $now = time();

      if ($now < $start) {
        $variables['course_status'] = 'upcoming';
        $variables['course_classes'] = 'course--upcoming';
      }
      elseif ($now > $end) {
        $variables['course_status'] = 'completed';
        $variables['course_classes'] = 'course--completed';
      }
      else {
        $variables['course_status'] = 'active';
        $variables['course_classes'] = 'course--active';
      }
    }

    // Heavy queries and business logic in the template
    if (!empty($node->field_course_category)) {
      $term_id = $node->field_course_category[LANGUAGE_NONE][0]['tid'];
      $term = taxonomy_term_load($term_id);
      $variables['category_name'] = $term->name;

      // Complex query for related courses
      $related_query = new EntityFieldQuery();
      $related_query->entityCondition('entity_type', 'node')
        ->entityCondition('bundle', 'course')
        ->propertyCondition('status', 1)
        ->fieldCondition('field_course_category', 'tid', $term_id)
        ->propertyCondition('nid', $node->nid, '!=')
        ->range(0, 3);

      $result = $related_query->execute();
      if (isset($result['node'])) {
        $related_nids = array_keys($result['node']);
        $related_nodes = node_load_multiple($related_nids);

        $variables['related_courses'] = array();
        foreach ($related_nodes as $related_node) {
          $variables['related_courses'][] = array(
            'title' => $related_node->title,
            'link' => url('node/' . $related_node->nid),
          );
        }
      }
    }
  }
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




New Architecture (D9) πŸ‘Ά:
🧩 Modular system with clear separation of responsibilities
🧱 Component approach with Paragraphs and Twig
πŸ“² Decoupling via JSON:API for mobile clients
⚑ Caching at different levels
Example of new code using services and Twig in D9 😍:



namespace Drupal\custom_course\Service;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\node\NodeInterface;

/**
 * Service for working with courses.
 */
class CourseService {

  /**
   * Entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Constructor.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   Entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * Determines course status based on dates.
   *
   * @param \Drupal\node\NodeInterface $node
   *   Course node.
   *
   * @return array
   *   Array with course status data.
   */
  public function getCourseStatus(NodeInterface $node) {
    $status = [
      'state' => 'unknown',
      'class' => 'course--unknown',
    ];

    if ($node->hasField('field_start_date') && $node->hasField('field_end_date')) {
      $now = new DrupalDateTime('now');

      if (!$node->field_start_date->isEmpty()) {
        $start = $node->field_start_date->date;

        if (!$node->field_end_date->isEmpty()) {
          $end = $node->field_end_date->date;

          if ($now < $start) {
            $status = [
              'state' => 'upcoming',
              'class' => 'course--upcoming',
            ];
          }
          elseif ($now > $end) {
            $status = [
              'state' => 'completed',
              'class' => 'course--completed',
            ];
          }
          else {
            $status = [
              'state' => 'active',
              'class' => 'course--active',
            ];
          }
        }
      }
    }

    return $status;
  }

  /**
   * Gets related courses.
   *
   * @param \Drupal\node\NodeInterface $node
   *   Course node.
   * @param int $limit
   *   Maximum number of courses.
   *
   * @return array
   *   Array of related courses.
   */
  public function getRelatedCourses(NodeInterface $node, $limit = 3) {
    $related_courses = [];

    if ($node->hasField('field_course_category') && !$node->field_course_category->isEmpty()) {
      $term_id = $node->field_course_category->target_id;

      $query = $this->entityTypeManager->getStorage('node')->getQuery()
        ->condition('type', 'course')
        ->condition('status', 1)
        ->condition('field_course_category', $term_id)
        ->condition('nid', $node->id(), '!=')
        ->range(0, $limit)
        ->sort('created', 'DESC');

      $nids = $query->execute();
      if (!empty($nids)) {
        $related_nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids);

        foreach ($related_nodes as $related_node) {
          $related_courses[] = [
            'title' => $related_node->label(),
            'link' => $related_node->toUrl()->toString(),
            'status' => $this->getCourseStatus($related_node),
          ];
        }
      }
    }

    return $related_courses;
  }
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




Example of a Twig template for a course 🎨:

{# node--course--full.html.twig #}
{% set status = course_service.getCourseStatus(node) %}

{{ attributes.addClass('course', status.class) }}>
  
    {{ title_attributes }}>{{ label }}

    {% if content.field_image %}
     class="course__image">
      {{ content.field_image }}
    
    {% endif %}

     class="course__status-badge">
      {{ status.state|capitalize }}
    
  

   class="course__content">
    {% if content.field_description %}
       class="course__description">
        {{ content.field_description }}
      
    {% endif %}

    {% if content.field_lessons %}
       class="course__lessons">
        {{ 'Lessons'|t }}
        {{ content.field_lessons }}
      
    {% endif %}
  

  {% if related_courses %}
     class="course__related">
      {{ 'Related courses'|t }}
       class="course__related-list">
        {% for course in related_courses %}
           href="{{ course.link }}" class="course-card {{ course.status.class }}">
            {{ course.title }}
             class="course-status">{{ course.status.state|capitalize }}
          
        {% endfor %}
      
    
  {% endif %}




    Enter fullscreen mode
    


    Exit fullscreen mode
    





  
  
  Conclusions πŸŽ“
Migration from Drupal 7 is not just a technical task but an opportunity to rethink your project. Instead of simply transferring the old architecture, I recommend:
πŸ” Start with an audit and rethinking of project goals
πŸ—οΈ Design a new architecture using modern Drupal capabilities
πŸ“‹ Develop a clear data migration strategy
πŸ“ˆ Implement changes gradually
This approach may seem more complex initially, but it will significantly reduce technical debt and create a better foundation for the development of your project in the future. Short-term pain for long-term gain! πŸ’ͺ
  
  
  Useful Resources πŸ“š

πŸ“– Official Drupal migration guide

πŸš€ Drupal Migration System

What was your experience migrating from Drupal 7? Share in the comments! πŸ’¬Alina Khmilevska, Full-stack Drupal developer with experience in creating complex web applications and participating in open-source projects. βœ¨πŸ‘©β€πŸ’»