A small, idiomatic Drupal 10/11 custom module that models and imports external publication content into nodes using the Migrate API. It ships a custom content type, fields and taxonomy as exported configuration, custom source and process plugins, a Drush runner, a normalizing service, and a Twig-themed admin report.
It is intentionally a demo: a self-contained, end-to-end example of how a
real-world "import a legacy/external feed into a clean content model" job is
done the Drupal way. The sample feed lives in assets/articles.json, so the
migration runs against shipped data with no external service.
- Drupal
^10 || ^11 - PHP
>= 8.1 drupal/migrate_plus^6anddrupal/migrate_tools^6(migration groups +drush migrate:*commands)- Drush
^11 || ^12 || ^13
From a Composer-managed Drupal site:
# Add the module (adjust the VCS/path source as needed).
composer require levelbrook/external_content_migrate
# Enable it and its migrate dependencies.
drush en external_content_migrate -y
# Import the configured content model + migration group.
drush config:import --partial --source=modules/contrib/external_content_migrate/config/install -yEnabling the module installs (via config/install/): the External Article
content type, its fields (body, field_author, field_topic,
field_source_url, field_reading_minutes), the Article Topics taxonomy
vocabulary, default form/view displays, and the external_content migration
group.
The module registers the external_articles migration plus convenience Drush
commands:
# Status of the migration (source rows / imported / unprocessed / nodes).
drush ecm:status
# Import new/changed source rows into External Article nodes.
drush ecm:import
# Re-import everything, updating existing nodes in place (idempotent).
drush ecm:import --update
# Remove imported nodes and clear the map table.
drush ecm:rollbackThe same migration is also runnable with stock tooling:
drush migrate:status --group=external_content
drush migrate:import external_articles --update
drush migrate:rollback external_articlesThen visit /admin/reports/external-content (permission: View the External
Content migration report) for a status table and Twig-rendered previews of the
most recent imports.
To point the migration at your own data, edit source.path in
migrations/external_articles.yml (absolute path, stream wrapper, or path
relative to the module) to a JSON file shaped like assets/articles.json.
| Required skill | Where it lives |
|---|---|
| Custom module | external_content_migrate.info.yml, .module, composer.json |
| Migrate API — source plugin | src/Plugin/migrate/source/ExternalArticleJson.php (file-backed, keyed on external_id for idempotent re-import) |
| Migrate API — process plugin | src/Plugin/migrate/process/NormalizeText.php (DI'd plugin: clean / summary / word_count / reading_minutes / topic) |
| Migration config | migrations/external_articles.yml, config/install/migrate_plus.migration_group.external_content.yml |
| Configuration management | config/install/* — node.type, field.storage.*, field.field.*, taxonomy.vocabulary, form/view displays |
| Entities / fields / taxonomy | External Article node + 5 fields + Article Topics vocabulary; topics resolved with entity_generate |
| Twig theming | templates/external-article-summary.html.twig + hook_theme() in the .module |
| Services / DI | external_content_migrate.services.yml → ContentNormalizer (+ interface) and MigrationReportService |
| Drush | src/Commands/ExternalContentMigrateCommands.php + drush.services.yml |
| Controller / routing / permissions / menu | src/Controller/MigrationReportController.php, *.routing.yml, *.permissions.yml, *.links.menu.yml |
| Tests | tests/src/Unit/* (PHPUnit, @group external_content_migrate) |
# From the Drupal site root:
./vendor/bin/phpunit -c web/modules/contrib/external_content_migrate \
web/modules/contrib/external_content_migrate/tests/src/UnitThe unit tests cover the normalizer and the process plugin (pure logic, no database required).
MIT — see LICENSE. Copyright (c) 2026 Levelbrook LLC.