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:
- Preserve β β data and functionality that definitely need to be transferred
- 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:
- π§© Separate different content types into logical components
- π Easily transfer these components between different content types
- ποΈ 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: π§ββοΈβ¨
<?php 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( '/<img src="\/sites\/default\/files\/([^"]+)"/', '<img src="/sites/default/files/new_structure/$1"', $old_body ); // Add Bootstrap 5 classes $new_body = str_replace('<table>', '<table class="table table-striped">', $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); } }
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: π±β¨
<?php 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; } }
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 π:
<?php /** * 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), ); } } } } }
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 π:
<?php 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; } }
Example of a Twig template for a course π¨:
{# node--course--full.html.twig #} {% set status = course_service.getCourseStatus(node) %} <article{{ attributes.addClass('course', status.class) }}> <header> <h1{{ title_attributes }}>{{ label }}</h1> {% if content.field_image %} <div class="course__image"> {{ content.field_image }} </div> {% endif %} <div class="course__status-badge"> {{ status.state|capitalize }} </div> </header> <div class="course__content"> {% if content.field_description %} <div class="course__description"> {{ content.field_description }} </div> {% endif %} {% if content.field_lessons %} <div class="course__lessons"> <h2>{{ 'Lessons'|t }}</h2> {{ content.field_lessons }} </div> {% endif %} </div> {% if related_courses %} <div class="course__related"> <h2>{{ 'Related courses'|t }}</h2> <div class="course__related-list"> {% for course in related_courses %} <a href="{{ course.link }}" class="course-card {{ course.status.class }}"> <h3>{{ course.title }}</h3> <span class="course-status">{{ course.status.state|capitalize }}</span> </a> {% endfor %} </div> </div> {% endif %} </article>
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 π
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. β¨π©βπ»
Top comments (0)