Introduction
This is the story of how I migrated this website from Drupal 7 to Drupal 10. It was not a straightforward upgrade, but rather a gradual reconstruction of the website, with a fair amount of investigation, troubleshooting and occasional surprises along the way.
The migration started after a security incident affecting the original hosting environment. Before moving forward, I had to investigate what happened and recover the original installation. That part of the story is documented separately:
Security incident and recovery of the original website
Once the immediate problems were addressed, I decided it was time to move the website to a maintained version of Drupal. I wanted to preserve as much of its content and history as possible, while rebuilding the application using compatible modules and a modern Drupal installation.
There was no intention of starting from scratch. The articles, their original publication dates, historical statistics and existing structure were all part of what needed to survive the migration.
1. Setting up the new environment
I started with a clean Drupal 10 installation, using the following environment:
- Drupal 10.6.20
- PHP 8.5.12
- MariaDB
- Composer-managed Drupal core and contributed modules
- Olivero as the base theme, with a custom subtheme
For the migration, I imported the old Drupal 7 database temporarily into the new MariaDB server. Having both databases available at the same time made it considerably easier to investigate differences, compare records and recover historical information.
I used the following database names:
Old Drupal 7: old_tar_gz_ro
New Drupal 10: new_tar_gz_ro
One particularly useful decision was to preserve the original content NIDs (node IDs). This allowed me to compare old and new records directly, without having to establish a separate mapping between the two databases.
It also made subsequent investigations much easier.
2. Bringing the old BBCode content back to life
2.1. The first visible problem
After migrating the articles, I noticed that some of the content was not displaying as it should.
The problem was not necessarily missing content. The original website had used BBCode extensively, and the migrated article bodies still contained all those familiar tags. Drupal 10, however, had no reason to understand them.
Years of articles depended on that formatting, including headings, lists, links, images, embedded videos and code examples. Rewriting all that content would have been an unreasonable amount of work.
I therefore decided to preserve the original BBCode and find a way to make the new installation understand it.
2.2. Introducing XBBCode
The solution was the XBBCode module, which I installed through Composer:
composer require drupal/xbbcode
I configured the migrated content to use the following text format:
xbbcode
XBBCode supports custom tag definitions through its administration interface, which meant I could recreate the formatting rules used by the old website without modifying the original article bodies.
2.3. Recreating the old tags
The original website used quite a few BBCode tags, so I had to recreate them in the new installation.
In total, I created 33 tag definitions covering formatting, headings, alignment, lists, links, images, YouTube videos, quotes, code blocks, tables and other functionality.
The definitions include:
- Formatting: b, i, u, s
- Headings: h1, h2, h3, h4, h5, h6
- Alignment: center, left, justify
- Lists: list, ol, ul, li
- Links and media: url, img, youtube, wikipedia
- Other: quote, code, color, size, font, abbr, acronym, table, hr, sub, sup, node, php, define
I created three tags manually first, then imported the remaining definitions in bulk using Drupal's entity storage API.
The tag templates use Twig variables such as:
{{ tag.content }}
{{ tag.option }}
{{ tag.source }}
One detail worth mentioning is the PHP tag. It displays the supplied source as code, just as it did on the old website. It does not execute PHP.
2.4. An unexpected problem with lists
Most of the tags were relatively straightforward to recreate, but the original list syntax presented a small complication.
The old website used asterisks to identify individual list items:
[list]
[li]First item
[/li][li]Second item
[/li][/list]
Unfortunately, XBBCode's parser does not accept an asterisk as a tag name. This meant I couldn't simply register the old list item marker as another custom tag.
Rather than modifying XBBCode itself, I created a small custom module named legacy_bbcode. Its job is to convert the old list syntax into standard li tags before XBBCode processes the content.
The module consists of:
modules/custom/legacy_bbcode/
├── legacy_bbcode.info.yml
└── src/Plugin/Filter/LegacyBbcodeListFilter.php
The filter runs before XBBCode in the text format processing order.
Importantly, this conversion does not change the content stored in the database. It happens during text processing, when Drupal prepares the article for display.
Status: Working and tested.
2.5. Adjusting quote formatting
Another small detail was the appearance of quoted text.
The initial template was simply:
<blockquote>{{ tag.content }}</blockquote>
However, the default appearance was too large for the migrated articles. I wanted something closer to the original presentation, without having to introduce another theme-specific CSS modification.
I therefore adjusted the template directly in the XBBCode tag definition:
<blockquote style="margin: 0.6em 0; padding: 0.4em 1em; border-left: 3px solid #888; font-size: 0.9em; line-height: 1.4;">
{{ tag.content }}
</blockquote>
This keeps the formatting self-contained within the tag definition.
2.6. Code blocks and multiline content
Code blocks required a little more investigation.
The current template is:
<pre><code>{{ tag.source }}</code></pre>
I noticed that a newline immediately after the opening code tag can produce an empty first line in the rendered block.
Looking into the XBBCode source, particularly TagElement.php and TemplateTagPlugin.php, showed that the original source is passed to the template without automatically trimming it.
I haven't made a final decision about changing this behaviour yet. Preserving intentional trailing blank lines is also something worth considering before introducing any trimming.
For now, the existing implementation works, and this remains a minor formatting detail to revisit.
3. Recovering historical statistics and view counts
3.1. Bringing back the old Statistics functionality
Preserving the articles was only part of the job. I also wanted to retain their historical view counts.
The old Drupal 7 website used the core Statistics module, together with its Popular content block. For Drupal 10, I installed the contributed replacement:
composer require drupal/statistics
The installed version is 1.0.1.
It provides a Popular content block with lists such as All time, Today's and Last viewed. Once installed, the block can be placed through the usual Block layout administration interface.
3.2. Recovering the historical counters
The old database contained a node_counter table with the historical view counts. Since the original NIDs were preserved, transferring those records was relatively straightforward.
The relevant columns are:
nid
totalcount
daycount
timestamp
I used the following query to transfer the counters:
INSERT INTO new_tar_gz_ro.node_counter
(nid, totalcount, daycount, timestamp)
SELECT
nid, totalcount, daycount, timestamp
FROM old_tar_gz_ro.node_counter
ON DUPLICATE KEY UPDATE
totalcount = new_tar_gz_ro.node_counter.totalcount + VALUES(totalcount);
There is one important detail here: this operation adds the old total counts to existing new counts. It is not an operation that can be safely repeated without considering what is already in the destination table.
I kept that in mind during the subsequent testing.
3.3. Adding view counts to article teasers
With the historical counters available, I wanted to display the number of views alongside the author and publication date in article teasers.
This also needed to work for promoted content displayed on the front page.
Instead of modifying Drupal or Olivero directly, I created another small custom module, node_views.
Its definition is:
modules/custom/node_views/node_views.info.yml
Contents:
name: Node Views
type: module
description: Displays content view counts in node teasers.
core_version_requirement: ^10 || ^11
package: Custom
The node_views.module file contains:
<?php
/**
* Implements hook_preprocess_node().
*/
function node_views_preprocess_node(&$variables) {
if ($variables['view_mode'] !== 'teaser') {
return;
}
$node = $variables['node'];
$count = \Drupal::database()
->select('node_counter', 'nc')
->fields('nc', ['totalcount'])
->condition('nid', $node->id())
->execute()
->fetchField();
$variables['view_count'] = (int) $count;
}
The module reads the total count from the Statistics table and makes it available to the node template through the view_count variable.
3.4. Making the count visible in Olivero
Getting the count into the template was only half the solution.
Olivero uses a separate template for teaser rendering:
node--teaser.html.twig
The template embeds the Olivero teaser component using an only context. This means additional variables are not automatically available inside the component.
I therefore had to explicitly pass the new variable:
view_count: view_count,
I then modified the metadata line to include the count:
{% if view_count is defined %}, views: {{ view_count }}{% endif %}
The result looks like this:
By root, 20 May, 2026, views: 1231
Status: Working.
A cache clear was necessary before the changes appeared.
3.5. Discovering orphaned statistics records
While testing the Popular content block, I encountered an Undefined array key warning in StatisticsPopularBlock.php, inside nodeTitleList().
After investigating the problem, I found that the Statistics module was trying to load nodes referenced by node_counter that did not exist in the new node_field_data table.
In other words, some historical statistics records had no corresponding content.
I used the following query to identify them:
SELECT
nc.nid,
nc.totalcount,
nc.daycount,
FROM_UNIXTIME(nc.timestamp) AS last_viewed
FROM new_tar_gz_ro.node_counter nc
LEFT JOIN new_tar_gz_ro.node_field_data n
ON n.nid = nc.nid
WHERE n.nid IS NULL
ORDER BY nc.totalcount DESC;
The result was 62 orphaned records.
I wanted to establish whether these were articles that had been lost during migration or records that were already orphaned in the original database. To find out, I compared them against the old Drupal 7 node table:
SELECT
nc.nid,
nc.totalcount,
o.title AS old_title,
o.type AS old_type
FROM new_tar_gz_ro.node_counter nc
LEFT JOIN old_tar_gz_ro.node o
ON o.nid = nc.nid
LEFT JOIN new_tar_gz_ro.node_field_data n
ON n.nid = nc.nid
WHERE n.nid IS NULL
ORDER BY nc.totalcount DESC;
All 62 records had NULL values for both old_title and old_type.
That answered the question: none of these NIDs existed in either the old or new content tables. They were already orphaned records in the original statistics data, and I had simply copied them along with the rest of the table.
Before considering their removal, I prepared a backup:
CREATE TABLE new_tar_gz_ro.node_counter_orphans_backup
LIKE new_tar_gz_ro.node_counter;
INSERT INTO new_tar_gz_ro.node_counter_orphans_backup
SELECT nc.*
FROM new_tar_gz_ro.node_counter nc
LEFT JOIN new_tar_gz_ro.node_field_data n
ON n.nid = nc.nid
WHERE n.nid IS NULL;
The deletion command is:
USE new_tar_gz_ro;
DELETE nc
FROM node_counter nc
LEFT JOIN node_field_data n ON n.nid = nc.nid
WHERE n.nid IS NULL;
My initial attempt failed because I had not selected a database. Selecting new_tar_gz_ro first resolved the error.
After deletion, the following query can be used to verify the result:
SELECT COUNT(*) AS orphaned_records
FROM node_counter nc
LEFT JOIN node_field_data n ON n.nid = nc.nid
WHERE n.nid IS NULL;
The expected result is 0.
Status: Orphan records identified and backed up. Deletion and final verification still require confirmation.
4. Giving the new website its own Olivero theme
Initially, I used the standard Olivero theme that comes with Drupal 10.
However, I knew that I would eventually need to make changes to templates and styling. Modifying the original theme directly would not be a good idea, as those changes could be lost during future updates.
I therefore created a custom subtheme:
themes/custom/olivero_custom/
Its theme definition is:
name: Olivero Custom
type: theme
description: Custom Olivero theme overrides.
core_version_requirement: ^10 || ^11
base theme: olivero
I also explicitly declared the Olivero regions:
regions:
header: Header
primary_menu: Primary menu
secondary_menu: Secondary menu
hero: Hero (full width)
highlighted: Highlighted
breadcrumb: Breadcrumb
social: Social Bar
content_above: Content Above
content: Content
sidebar: Sidebar
content_below: Content Below
footer_top: Footer Top
footer_bottom: Footer Bottom
The idea is simple: Olivero continues to provide the inherited assets and functionality, while my own changes live in a separate theme.
4.1. Keeping template modifications separate
The templates that need modification are copied into the custom theme rather than edited in Drupal core.
For example:
themes/custom/olivero_custom/templates/content/node.html.twig
themes/custom/olivero_custom/templates/content/node--teaser.html.twig
This way, future Drupal core updates should not overwrite my custom modifications.
As usual with Twig changes, Drupal caches need to be cleared before the updated templates are discovered and compiled.
4.2. Theme settings and block placement
One thing I had to keep in mind when switching from Olivero to Olivero Custom was that theme settings and block placements are associated with the active theme.
I therefore cannot simply assume that everything configured under the original Olivero theme will automatically be available in the new one.
The block layout and theme configuration need to be checked separately.
Status: Custom subtheme created. Final migration of all theme-specific settings and placements still needs to be checked.
5. Rebuilding the parent and child content navigation
Another feature I wanted to preserve was the way the old website handled content hierarchy.
In Drupal 7, parent menu entries could have child article links. For example, visiting the Hardware page would provide a list of articles belonging to that section.
I wanted to reproduce this behaviour dynamically in Drupal 10, without manually maintaining a separate article list on every parent page.
5.1. Checking the migrated menu structure
Before looking for a solution, I first wanted to make sure the original menu hierarchy had actually survived the migration.
The relevant tables in the new database are:
- menu_link_content
- menu_link_content_data
One detail worth noting is that the menu link UUID is stored in menu_link_content, not menu_link_content_data.
I used the Hardware page as my test case. Its node ID is 403, and its menu link UUID is:
2f73edfa-1b5f-4523-bf06-c75280c57917
The parent had 27 direct child menu links in the main menu.
5.2. Trying Menu Children Views filter
I first installed the contributed Views module:
composer require 'drupal/views_menu_children_filter:^4.0'
The installed version is 4.0.2.
It provides a contextual filter called Menu Children, intended to let a View list child content based on a parent node.
The idea was to use the Content ID from the URL as the contextual filter argument and restrict the results to the relevant menu.
During testing, I also encountered a PHP 8.5 deprecation warning:
Using null as an array offset is deprecated
After investigating the issue, I traced it to the contributed module calling Drupal's queueTable() without an alias, while the core Views implementation uses that value as an array key.
This is a non-fatal PHP compatibility warning. It does not mean the menu hierarchy itself is broken.
5.3. Looking into Menu Entity Index
I also installed Menu Entity Index to see whether exposing the menu hierarchy through a database index would make the task easier.
The module created the menu_entity_index table, which stores information including:
- Menu name
- Hierarchy level
- Menu link entity ID
- Parent entity ID and UUID
- Target entity type and ID
To check whether the Hardware children were available, I ran:
SELECT
mei.level,
mei.entity_id AS menu_link_id,
mld.title AS menu_link_title,
mld.weight,
mei.target_type,
mei.target_id,
nfd.title AS content_title
FROM menu_entity_index mei
JOIN menu_link_content_data mld
ON mld.id = mei.entity_id
LEFT JOIN node_field_data nfd
ON nfd.nid = mei.target_id
AND nfd.langcode = mei.target_langcode
WHERE mei.menu_name = 'main'
AND mei.parent_uuid =
'2f73edfa-1b5f-4523-bf06-c75280c57917'
AND mei.target_type = 'node'
ORDER BY mld.weight ASC;
The query successfully returned all 27 entries.
So the important part was already there: the migrated menu hierarchy was intact and could be queried.
What I haven't completed yet is the final reusable solution that would turn this information into dynamic child article listings through Views or a small custom module.
Status: Menu hierarchy verified in the index. Reusable Views integration or custom dynamic listing solution still to be completed.
6. Cookie consent and Google Analytics
With the content and navigation taking shape, I also started configuring the services that the old website used.
For cookie consent, I installed EU Cookie Compliance:
composer require 'drupal/eu_cookie_compliance:^1.29'
I also installed the Google Tag module.
The intention is to prevent Google Analytics and Google Tag Manager scripts from running before the visitor has given the required consent.
The relevant Google script URLs are:
https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX
https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXXX
The actual Google Analytics measurement ID and Google Tag Manager container ID need to come from their respective Google accounts.
I used the EU Cookie Compliance JavaScript disabling configuration to prevent the relevant scripts from loading without consent.
This is one of those things where simply seeing the consent banner is not enough. The actual network requests need to be checked to make sure tracking is not happening prematurely.
Status: Configuration was being tested. Both pre-consent and post-consent behaviour still need verification.
7. A few other things along the way
Not everything during the migration was related to content, statistics or menus. There were also a few smaller details worth documenting.
7.1. Favicon
The old Drupal 7 favicon can be found in the original theme or site files.
The new favicon is configured through the active theme's appearance settings.
7.2. AdSense
The old AdSense blocks were present, but their visibility and rendering needed further investigation.
At this stage, I was testing the new website using an IP address rather than its final public hostname.
AdSense approval and hostname configuration may affect ad delivery, but that is separate from whether the Drupal block itself is rendering correctly. Both need to be checked independently.
7.3. What about the old Drupal 7 theme?
I also considered how much of the old website's appearance could be reused.
A Drupal 7 theme cannot simply be installed as a Drupal 10 theme. However, that doesn't mean everything needs to be recreated.
CSS, images, icons, fonts and some JavaScript can usually be reused, subject to compatibility changes.
The PHP template files and preprocessing logic are a different matter. Those need to be ported to Drupal 10's Twig templates and theme APIs.
7.4. Composer and PHP compatibility
Composer is now used to manage Drupal core and contributed modules, which makes keeping track of dependencies considerably more manageable.
During testing, I also encountered a PHP deprecation warning in the system's JsonSchema package involving the old $http_response_header variable.
This turned out to be unrelated to the Drupal content migration, but it is another compatibility detail worth keeping in mind when running the new environment on PHP 8.5.
8. Things worth remembering
A few lessons from the migration are worth keeping in mind as the work continues:
- Keep custom code in modules/custom and themes/custom instead of modifying Drupal core or contributed modules.
- After changing Twig templates, clear Drupal caches before assuming the changes haven't worked.
- Be careful when importing historical counters. The import can add counts to existing records, so don't rerun it blindly.
- Keep database backups before executing DELETE or UPDATE statements.
- When investigating migrated content, comparing the old and new records using NID and content type can save a lot of time.
- Legacy BBCode syntax is part of the content itself. Keeping it supported is preferable to rewriting years of articles.
9. What's left to do?
The migration has come a long way, but there are still a few things I want to verify before considering it complete.
- Statistics: Confirm orphaned counter deletion and verify that the Popular content block no longer generates warnings.
- Theme: Confirm all required template overrides are in the custom subtheme and check block placements and theme settings.
- Menu listings: Decide on a reusable dynamic Views or custom module solution for listing child content.
- Cookie consent: Test Google Analytics behaviour both before and after consent.
- AdSense: Recheck after the website is running on its final public domain.
This is an ongoing migration log, not a finished technical specification. I'll update it as the remaining work is completed, keeping the actual commands and configuration that were successfully tested along the way.
- Log in to post comments
- 28 views