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: π§ββοΈβ¨
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. β¨π©βπ»