Building a Custom Drupal Migration Process Plugin
Drupal migrations become difficult when source records carry values that are technically present but not ready for the destination. A legacy address may mix street, suburb, state and postcode; a date may arrive as a day-first string; a phone number may include an old international prefix. The Migrate API is well suited to this work because source retrieval, row transformation and destination writes remain separate stages.
A custom Drupal migration process plugin gives that middle stage a named, testable unit. It can validate a record, convert its shape, apply Australian conventions and fail with a useful message while leaving source and destination plugins focused on their own jobs. This approach suits repeatable imports that may need to be rerun after a partial failure or adjusted when a destination field changes.
When a custom process plugin is the right tool
Migrate already provides generic process plugins for common edits such as formatting, substitutions and skipping rows. They cover many imports, but site-specific rules eventually exceed their configuration. Duplicating logic in callbacks or destination preparation makes each rule harder to locate and often repeats the same cleanup for several migration classes.
A dedicated plugin creates a stable interface around domain knowledge. The class can be unit tested without creating a node or user, reused by product, customer and event migrations, and replaced without rewriting the source query or destination entity mapping. Its identifier also appears in migration logs, which helps teams trace a transformed row back to a known processing step.
Keep the responsibility narrow. A process plugin should transform row data rather than create entities, send webhooks or modify the legacy database. Side effects make retries ambiguous and complicate rollback. Destination plugins remain responsible for entity creation, while source plugins continue to retrieve records.
Defining a predictable transformation contract
The transform method receives the current value, the migration executable, source and destination property names, and the complete source row. That final argument is useful when several legacy columns belong to one destination value: a postal code can be read from postcode while the street comes from address_line_1. The return value becomes the new process value.
Define the accepted input and output before coding. For an address transformation, a predictable contract might accept an array containing street, locality, administrative_area and postal_code, then return the element names expected by Drupal’s address field. The class should also define whether an empty source value becomes an empty array, a null value or a validation error.
Choose failure semantics that suit the data. Malformed postcodes and unknown states often deserve a MigrateException, since silently moving bad data into a customer record creates a larger cleanup job. Business exceptions such as a missing marketing-consent timestamp may be skipped with an explicit reason if the import is allowed to continue. Each rule needs one documented outcome.
Implementing the plugin in a custom module
Place the class at src/Plugin/migrate/process/NormaliseAddress.php inside a custom module that depends on drupal:migrate. Drupal 10.2 and later can register migrate plugins with PHP attributes. The namespace must match the module name, and the plugin manager will discover the class once the module is enabled and Drupal’s caches are rebuilt.
<?php
namespace Drupal\acme_migration\Plugin\migrate\process;
use Drupal\migrate\Attribute\MigrateProcessPlugin;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\MigrateProcessPluginBase;
#[MigrateProcessPlugin(
id: 'acme_normalise_address',
label: 'Normalise Australian address',
)]
final class NormaliseAddress extends MigrateProcessPluginBase
{
private const STATES = [
'ACT' => 'ACT',
'NSW' => 'NSW',
'VIC' => 'VIC',
'QLD' => 'QLD',
'SA' => 'SA',
'WA' => 'WA',
'TAS' => 'TAS',
'NT' => 'NT',
'NEW SOUTH WALES' => 'NSW',
'VICTORIA' => 'VIC',
'QUEENSLAND' => 'QLD',
'SOUTH AUSTRALIA' => 'SA',
'WESTERN AUSTRALIA' => 'WA',
'TASMANIA' => 'TAS',
'NORTHERN TERRITORY' => 'NT',
];
public function transform(
$value,
MigrateExecutableInterface $migrate_executable,
$source_property_name,
$destination_property_name,
array $source_values
): array {
$address = is_array($value) ? $value : [];
$street = trim((string) (
$address['street']
?? $address['address_line_1']
?? $source_values['street']
?? ''
));
$locality = trim((string) (
$address['locality']
?? $address['suburb']
?? $source_values['suburb']
?? ''
));
$state_input = strtoupper(str_replace('.', '', trim((string) (
$address['administrative_area']
?? $address['state']
?? $source_values['state']
?? ''
))));
if (!isset(self::STATES[$state_input])) {
throw new MigrateException('Unknown Australian state or territory.');
}
$postcode = preg_replace('/\D+/', '', (string) (
$address['postal_code']
?? $source_values['postcode']
?? $source_values['postal_code']
?? ''
));
if (strlen($postcode) === 3) {
$postcode = str_pad($postcode, 4, '0', STR_PAD_LEFT);
}
if (strlen($postcode) !== 4) {
throw new MigrateException('Expected a four-digit Australian postcode.');
}
return [
'address_line1' => $street,
'locality' => $locality,
'administrative_area' => self::STATES[$state_input],
'postal_code' => $postcode,
'country_code' => 'AU',
];
}
}
The mapping table converts full state names to the abbreviations used by Australian address fields. Removing punctuation from the state input handles values such as N.S.W., while the postcode filter retains digits only. The postcode is kept as a string because converting 0800 to an integer would erase its leading zero and change a valid Northern Territory postcode.
Connecting the plugin to a migration
Assume the source plugin exposes an address array and the destination content type has an address field named field_address. The migration definition can then apply the plugin in its process section:
id: acme_customer_migration
label: 'Acme customer migration'
source:
plugin: acme_customer
high_water_property: updated_at
process:
field_address:
plugin: acme_normalise_address
source: address
destination:
plugin: 'entity:node'
default_bundle: customer
The field_address property receives the transformed array, while source: address identifies the value passed into the plugin. If the legacy record stores the complete address in one text column, parse it before this plugin or create a separate source plugin. Guessing the boundary between state and postcode is less reliable than receiving structured values.
Process plugins run after the source row is read and before the destination prepares the entity. Their order matters when one transformation depends on another, so keep the pipeline declarative in YAML. Migration dependencies can also ensure that prerequisite content types, taxonomies or reference data are imported before the customer migration runs.
Accounting for Australian data and legal requirements
Several Australian details affect transformation rules. Addresses in Sydney, Melbourne, Brisbane and regional areas commonly follow the suburb, state and four-digit postcode pattern. “New South Wales” and “NSW” should converge on one stored value, while ACT and Northern Territory postcodes also need leading zeros preserved.
Date handling reflects another local habit. People often read 04/07/2026 as 4 July, while Australian sites also receive ISO dates and timestamps. Parse each known format explicitly, retain the source timezone and let Drupal convert display values. Daylight-saving boundaries differ across eastern, central and western Australia, so a timezone-free timestamp can move to the wrong local day.
Australian commerce adds another requirement: amounts are normally shown in AUD, while GST may be displayed separately or included in the price. Migrate tax codes and line amounts exactly, using integer minor units or decimal strings rather than floating point. Australian phone records may use familiar 04xx mobile formats, but E.164 storage such as +61412345678 makes comparison and deduplication more reliable.
Legal context matters as well. Where the Privacy Act 1988 applies, the Australian Privacy Principles guide collection, security, correction and retention, while APP 8 may matter if an overseas service provider handles the migration. The Spam Act 2003 is relevant to migrated marketing preferences: preserve dated consent evidence and do not infer consent from an imported newsletter flag. Secure staging, restricted access and a planned deletion date should be recorded as part of the migration.
Testing and operating the transformation safely
Start with a unit test for the acme_normalise_address process plugin. Feed it a Victorian address, an ACT postcode with a leading zero, missing optional fields and malformed input. Assert the complete returned array because the destination contract is the important boundary. A mocked MigrateExecutableInterface keeps the test independent of nodes and database state.
Then run the migration against a small fixture import. Include duplicate rows and a row that fails after earlier rows have succeeded, because a rerun must produce the same destination values without creating duplicate entities. Use the migration status command to review processed, imported and failed counts, and inspect messages without exposing street addresses in logs.
Keep the operation reversible by taking a database backup, recording the source identifier on each destination record and confirming that the destination plugin supports rollback. High-water processing can skip unchanged legacy rows, while a forced rerun tests the full transformation path. Store the plugin version and migration configuration with the release artefacts so production behaviour matches the tested code.
Finally, measure real data rather than trusting sample exports. Run counts by state, postcode shape, source timezone, phone format and GST treatment, then review outliers with the data owner. Invalid records should be isolated and counted, not silently discarded. A migration built around a small, explicit process plugin remains observable, repeatable and safer to operate as the Drupal site changes.