---
name: drupal-migrations
description: 'Write and run Drupal Migrate API migrations. Use when: creating migration YAML definitions, writing custom source plugins, writing custom process plugins, migrating content from CSV/JSON/SQL sources, migrating from Drupal 7, running migrations with Drush, rolling back migrations, handling media/file migrations, migrating paragraph content, debugging migration errors.'
argument-hint: 'What is being migrated (content type, taxonomy, media, etc.) and the source format (CSV, JSON, SQL, D7, etc.)'
---

# Drupal Migrate API — Square360 Conventions

> **Also load:** [drupal-11-coding-standards](../drupal-11-coding-standards/SKILL.md) for PHP patterns and PHPCS rules.
> **Also load:** [drupal-module-scaffold](../drupal-module-scaffold/SKILL.md) when scaffolding a migration module.

## When to Use
- One-time content migrations (CSV import, legacy system data)
- Drupal 7 → Drupal 11 upgrades
- Ongoing sync from external data sources
- Migrating nodes, taxonomy terms, users, media or paragraphs

---

## Migration Module Structure

Migrations live in a dedicated custom module:

```
web/modules/custom/mysite_migrate/
├── mysite_migrate.info.yml
├── mysite_migrate.module         # Optional
├── config/
│   └── install/
│       └── migrate_plus.migration.migrate_articles.yml
│       └── migrate_plus.migration.migrate_tags.yml
│       └── migrate_plus.migration_group.mysite.yml
└── src/
    └── Plugin/
        └── migrate/
            ├── source/
            │   └── MyCustomSource.php
            └── process/
                └── MyCustomProcess.php
```

---

## Migration YAML Structure

File: `config/install/migrate_plus.migration.migrate_articles.yml`

```yaml
id: migrate_articles
label: 'Import articles from CSV'
migration_group: mysite

# Dependencies — run these migrations first
migration_dependencies:
  required:
    - migrate_tags
    - migrate_media_images

source:
  plugin: csv                         # Source plugin
  path: 'public://migration/articles.csv'
  header_row_count: 1
  ids:
    - id
  column_names:
    0:
      id: 'Unique ID'
    1:
      title: 'Article Title'
    2:
      body: 'Body Text'
    3:
      tags: 'Comma-separated tag names'
    4:
      image_filename: 'Image filename'

process:
  # Simple field copy
  title: title

  # Body with text format
  'body/value': body
  'body/format':
    plugin: default_value
    default_value: full_html

  # Taxonomy term lookup by name
  field_ert_tags:
    plugin: entity_lookup
    entity_type: taxonomy_term
    bundle: tags
    value_key: name
    source: tags

  # Media entity reference via previous migration
  field_erm_hero_image:
    - plugin: migration_lookup
      migration: migrate_media_images
      source: image_filename
    - plugin: skip_on_empty
      method: process

  # Status — publish everything
  status:
    plugin: default_value
    default_value: 1

  uid:
    plugin: default_value
    default_value: 1     # Admin user

destination:
  plugin: entity:node
  default_bundle: article
```

---

## Common Source Plugins

| Plugin | Use case | Module |
|--------|----------|--------|
| `csv` | CSV files | `migrate_source_csv` |
| `url` | JSON/XML from a URL | `migrate_plus` |
| `embedded_data` | Inline data for tests | `migrate` core |
| `sql` | Legacy database query | `migrate` core |
| `d7_node` | Drupal 7 nodes | `drupal/upgrade_status` |

---

## Common Process Plugins

| Plugin | Purpose |
|--------|---------|
| `get` | Copy a source value (default) |
| `default_value` | Set a static value |
| `concat` | Concatenate multiple fields |
| `explode` | Split a string into array |
| `entity_lookup` | Find entity by field value (e.g. term by name) |
| `entity_generate` | Find or create an entity |
| `migration_lookup` | Get ID of an entity from another migration |
| `skip_on_empty` | Skip the field or whole row if empty |
| `callback` | PHP callable transform |
| `str_replace` | String find-and-replace |
| `substr` | Substring extraction |
| `machine_name` | Convert a label to machine name format |

---

## Paragraph Migration Pattern

Paragraphs require migrating the paragraph entity first, then referencing its `target_id` AND `target_revision_id`:

```yaml
# Step 1: migrate_paragraph_author.yml
destination:
  plugin: entity:paragraph
  default_bundle: author_bio

# Step 2: Reference in node migration
process:
  'field_erp_authors/target_id':
    - plugin: migration_lookup
      migration: migrate_paragraph_author
      source: author_id
    - plugin: skip_on_empty
      method: process
    - plugin: extract
      index: [0]

  'field_erp_authors/target_revision_id':
    - plugin: migration_lookup
      migration: migrate_paragraph_author
      source: author_id
    - plugin: skip_on_empty
      method: process
    - plugin: extract
      index: [1]
```

---

## Custom Source Plugin

```php
<?php

declare(strict_types=1);

namespace Drupal\mysite_migrate\Plugin\migrate\source;

use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
use Drupal\migrate\Row;

/**
 * Reads articles from a JSON file.
 *
 * @MigrateSource(
 *   id = "my_json_articles",
 *   source_module = "mysite_migrate",
 * )
 */
final class JsonArticleSource extends SourcePluginBase {

  /**
   * {@inheritdoc}
   */
  public function fields(): array {
    return [
      'id'    => 'Unique identifier',
      'title' => 'Article title',
      'body'  => 'Body content',
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds(): array {
    return [
      'id' => ['type' => 'integer'],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function __toString(): string {
    return 'JSON Articles Source';
  }

  /**
   * {@inheritdoc}
   */
  protected function initializeIterator(): \Iterator {
    $path = $this->configuration['path'];
    $data = json_decode(file_get_contents($path), TRUE);

    foreach ($data['articles'] ?? [] as $item) {
      yield $item;
    }
  }

}
```

---

## Drush Migration Commands

```bash
# List all migrations and their status
lando drush migrate:status

# Run a specific migration
lando drush migrate:import migrate_articles

# Run with a limit
lando drush migrate:import migrate_articles --limit=50

# Run all migrations in a group
lando drush migrate:import --group=mysite

# Update already-migrated rows (re-import changed source data)
lando drush migrate:import migrate_articles --update

# Roll back a migration (deletes all imported entities)
lando drush migrate:rollback migrate_articles

# Show messages/errors for a migration
lando drush migrate:messages migrate_articles

# Reset stuck migrations (status "Importing")
lando drush migrate:reset-status migrate_articles
```

---

## Debugging Tips

1. **Check status:** `lando drush migrate:status migrate_articles` — look for "Unprocessed" vs "Imported" counts and "Failed" count.
2. **Read messages:** `lando drush migrate:messages migrate_articles` — shows per-row errors.
3. **Use `--verbose`** on import to see each row: `lando drush migrate:import migrate_articles --verbose`.
4. **Rollback is safe** on dev — always roll back and re-import if you change the YAML structure.
5. **Config sync:** After editing YAML in `config/install/`, re-import config: `lando drush cim -y`.
6. **Map table inspection:** The `migrate_map_*` and `migrate_message_*` database tables store tracking data.

---

## Square360 Conventions

> **TODO: Team member to complete this section.**

Document here:
- Migration source systems for this project (CSV files, legacy DB, external API, etc.)
- Migration group(s) used and order of execution
- Whether migrations are one-time or recurring (ongoing sync vs import-once)
- File/media migration strategy (where source files are stored, how they're referenced)
- Whether `drush migrate:import --all` is safe to run, or must individual migrations be ordered manually
