<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>JSON APIs on The Russian Lullaby</title><link>https://www.therussianlullaby.dev/tags/json-apis/</link><description>Recent content in JSON APIs on The Russian Lullaby</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Wed, 14 Oct 2026 00:00:00 +0200</lastBuildDate><atom:link href="https://www.therussianlullaby.dev/tags/json-apis/index.xml" rel="self" type="application/rss+xml"/><item><title>Drupal Migrations (VI): The Rick and Migraty experience, merging data from different sources</title><link>https://www.therussianlullaby.dev/blog/drupal-migrations-six-rick-and-migraty-experience-merging-data-from-different-sources/</link><pubDate>Wed, 14 Oct 2026 00:00:00 +0200</pubDate><guid>https://www.therussianlullaby.dev/blog/drupal-migrations-six-rick-and-migraty-experience-merging-data-from-different-sources/</guid><enclosure url="https://www.therussianlullaby.dev/images/post/davidjguru_drupal_migrations_populating_content_from_json_api_main.jpg" type="image/jpeg"/><media:content url="https://www.therussianlullaby.dev/images/post/davidjguru_drupal_migrations_populating_content_from_json_api_main.jpg" medium="image" type="image/jpeg"/><media:thumbnail url="https://www.therussianlullaby.dev/images/post/davidjguru_drupal_migrations_populating_content_from_json_api_main.jpg"/><description>Drupal Migrations (VI): the Rick and Migraty experience, merging data from the Rick and Morty API, TVmaze and Fandom with Drupal Migrate API.</description><content:encoded><![CDATA[<p>In previous articles in this series, I have been exploring the Drupal Migrate API from different angles: first as a toolbox, then through simple embedded data and CSV examples, later with a Google Spreadsheet exposed as JSON, and finally by taking a step back to debug the process when reality starts to bite. <strong>Because it always bites</strong>.</p>
<p>This sixth installment returns to the practical side of data migration: <strong>populating Drupal with remote, structured data retrieved from multiple APIs</strong>. The goal is not to build the most spectacular migration in the world in a single sitting, but rather to design a migration process that is simple enough to understand and complex enough to be genuinely useful.</p>
<p><strong>What does this show about Drupal?</strong> It can bring data from several APIs into a structured content model, preserve the relationships between records, and turn that model into pages people can browse and search. For developers building directories, catalogues or other sites that combine external data, <strong>The Rick and Migraty experience provides a practical example using Migrate API, custom content entities and Views.</strong> The linked repository and DDEV setup let you run the project locally and inspect how those pieces fit together.</p>
<p>To achieve this, we will connect to external, open, and freely accessible APIs. Resources such as <a href="https://rickandmortyapi.com/"><strong>the Rick and Morty API</strong></a>, <a href="https://www.tvmaze.com/api"><strong>the TVmaze API</strong></a>, and <a href="https://rickandmorty.fandom.com/api.php"><strong>the Rick and Morty Fandom Wiki</strong></a> will provide the data we need to populate our Drupal entities with characters, locations, episodes, ratings, and descriptions.</p>
<p><strong>The Rick and Morty API will serve as our primary data source, providing the core entities and their relationships. TVmaze will join the party as a secondary enrichment source, supplying episode ratings that we can merge into our existing Drupal entities. Finally, Fandom will contribute narrative descriptions, biographical details, and other contextual information</strong>, helping us build richer profiles of the characters and locations from our favourite animated universe.</p>
<p><strong>Note:</strong> As of October 14, 2026 this article <strong>contains over 52,000 characters and around 10,000 words, with an estimated reading time of 50–60 minutes</strong>.</p>
<hr>
<p><strong>Picture from Unsplash, user <a href="https://unsplash.com/es/@juliankraus">Julian Kraus, @juliankraus</a></strong></p>
<p><strong>Table of Contents</strong></p>
<span class="post-toc" aria-hidden="true"></span>

<!-- raw HTML omitted -->
<p><a href="#0-tldr-i-just-wanna-play-with-some-drupal-stuff">0. TL;DR (<strong>I just wanna play with some Drupal stuff</strong>)</a><br>
<a href="#1-where-the-idea-came-from-origins">1. Where the idea came from (<strong>origins</strong>)</a><br>
<a href="#2-what-were-looking-for-goals">2. What we&rsquo;re looking for (<strong>goals</strong>)</a><br>
<a href="#3-analyzing-the-data-understanding">3. Analyzing the data (<strong>understanding</strong>)</a><br>
<a href="#4-some-initial-constraints-wait">4. Some initial constraints (<strong>wait</strong>)</a><br>
<a href="#5-destination-containers-in-drupal-entities">5. Destination containers in Drupal (<strong>entities</strong>)</a><br>
<a href="#6-designing-the-migration-cooking">6. Designing the migration (<strong>cooking</strong>)</a><br>
<a href="#7-defining-migration-plugins-solutions">7. Defining migration plugins (<strong>solutions</strong>)</a><br>
<a href="#8-more-info-from-the-source-enrichment">8. More info from the source (<strong>enrichment</strong>)</a><br>
<a href="#9-from-data-to-content-browsing">9. From data to content (<strong>browsing</strong>)</a><br>
<a href="#10-the-migration-as-an-operation-running">10. The migration as an operation (<strong>running</strong>)</a><br>
<a href="#11-losing-your-mind-debugging">11. Losing your mind (<strong>debugging</strong>)</a><br>
<a href="#12-things-worth-highlighting-deciding">12. Things worth highlighting (<strong>deciding</strong>)</a><br>
<a href="#13-what-i-learned-reflecting">13. What I learned (<strong>reflecting</strong>)</a><br>
<a href="#14-next-steps-planning">14. Next steps (<strong>planning</strong>)</a><br>
<a href="#15-wq">15. :wq!</a></p>
<!-- raw HTML omitted -->
<h2 id="0-tldr-i-just-wanna-play-with-some-drupal-stuff">0. TL;DR (I just wanna play with some Drupal stuff)</h2>
<p>Okay, okay, I&rsquo;m not judging you. So, <strong>make sure you meet the project prerequisites:</strong></p>
<ol>
<li>Have Git installed on your machine (<strong>git v2.43.0</strong>).</li>
<li>Have DDEV installed on your machine (<strong>ddev v1.25.4</strong>, <strong>Docker v29.9.0</strong>).</li>
<li>Have access to a Linux terminal on your machine.</li>
</ol>
<p>Once you&rsquo;ve checked all that, follow these steps:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">git clone https://gitlab.com/davidjguru/rick-and-migraty.git
</code></pre></div><div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash"><span style="color:#0086b3">cd</span> rick-and-migraty/
</code></pre></div><div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev start
</code></pre></div><div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev composer install
</code></pre></div><p>This will have configured the project in your local development environment (LDE) using DDEV. Now, let&rsquo;s set up the Drupal installation:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush site:install --existing-config <span style="color:#d14">\
</span><span style="color:#d14"></span>  --account-name<span style="color:#000;font-weight:bold">=</span>admin <span style="color:#d14">\
</span><span style="color:#d14"></span>  --account-pass<span style="color:#000;font-weight:bold">=</span>admin <span style="color:#d14">\
</span><span style="color:#d14"></span>  -y
</code></pre></div><div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev launch
</code></pre></div><p>This will complete the website setup and finally launch the project in your web browser, using <strong><code>https://rick-and-migraty.ddev.site</code></strong>:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_zero_home_web.jpg" alt="Home of Rick and Migraty website in your local environment." loading="lazy" decoding="async">
  <span class="post-figure__caption">Main view of Rick and Migraty in your browser</span>
</span>

</p>
<p>On the homepage, what really matters are <strong>the actions the project lets you perform</strong>:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_zero_2_actions.jpg" alt="Main menu for actions in the website." loading="lazy" decoding="async">
  <span class="post-figure__caption">Website action menu: Learn, explore, delete stuff!</span>
</span>

</p>
<p><strong>Remember!</strong> The installation credentials are <code>user: admin, password: admin</code>, and you can also access the project documentation at the local URL <strong><code>https://docs.rick-and-migraty.ddev.site</code></strong>. <strong>Enjoy!</strong></p>
<p><strong>Like what you&rsquo;re seeing?</strong> Give me a little support by sharing this article, spreading the word about the project repository (<a href="https://gitlab.com/davidjguru/rick-and-migraty"><strong>https://gitlab.com/davidjguru/rick-and-migraty</strong></a>), or giving it a star on GitLab:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_zero_3_repo_star.jpg" alt="Show the author some love!" loading="lazy" decoding="async">
  <span class="post-figure__caption">Support me by giving the project a star! ⭐</span>
</span>

</p>
<h2 id="1-where-the-idea-came-from-origins">1. Where the idea came from (origins)</h2>
<p>For a while, I was working on Drupal migration processes built around the Migrate API. <strong>Some of those professional experiences eventually found their way into the series of articles on Drupal migrations published here</strong>.</p>
<p>Around the same time, the Adult Swim animated series <a href="https://www.adultswim.com/rick-and-morty">Rick and Morty</a> (2013–present) came into my life. For a while, <strong>it was one of my favorite shows (despite its ups and downs)</strong>, thanks to its breakneck pace, complete lack of filters, vibrant colors, and the eccentric relationship between a mad scientist (Rick Sanchez) and his grandson (Morty Smith).</p>
<p>I wanted to do something with all of this, <strong>brewing up a magic potion with a little bit of Drupal, a whole lot of Rick and Morty, and a generous dose of Drupal&rsquo;s Migrate API</strong>. For some time, I had been playing around with the idea of bringing together useful information about the show in a web platform designed to index its enormous number of characters (a number that seems to grow exponentially). So I decided to give it a shot… and that&rsquo;s how I discovered <a href="https://rickandmortyapi.com/">The Rick and Morty API</a>, which got me pretty excited about the whole thing.</p>
<p>And <strong>that&rsquo;s how The Rick and Migraty Experience was born.</strong></p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick-and-migraty-one-1200x675.jpg" alt="The Rick and Migraty Experience main image." loading="lazy" decoding="async">
  <span class="post-figure__caption">Main image for the new Rick and Migraty experience: Wubba Lubba Dub-Dub!</span>
</span>

</p>
<p>Recent events have given me a bit more time on my hands, allowing me to refocus on the idea and finally pick up right where I left off. Luckily, I had kept all the work I had already done: UX research, mockups, wireframes, Drupal entity designs for the data, and use cases for queries. <strong>Lots and lots of queries, all ready to receive lots of JSON responses</strong>.</p>
<p><a href="https://www.therussianlullaby.dev/blog/drupal-migrations-three-migrating-from-google-spreadsheet/">The third article of this series already used a JSON-shaped source</a>, but it did it through a Google Spreadsheet transformation and a contrib parser that knew how to deal with that special structure. This time <strong>I wanted to move the focus to a more API-shaped scenario</strong>.</p>
<p><strong>The Rick and Morty API is a useful playground for Drupal migrations</strong> because it is public, stable (enough) for an article, paginated, relational, and a little bit ridiculous in the right way. That combination is more valuable than it looks. A migration example should not only import three flat rows and call it a day; it should expose some of the structural problems we face in real projects.</p>
<p>In this case, <strong>the goal is to design the migration process before writing all the code</strong>. We will identify the source resources, propose a Drupal destination model, define the order of operations, and list the process plugins or transformations that will probably be needed.</p>
<h2 id="2-what-were-looking-for-goals">2. What we&rsquo;re looking for (goals)</h2>
<p>The main idea, the key objective, is <strong>to build a unified platform where we can explore and keep track of both the different characters populating the show&rsquo;s episodes and the episodes themselves</strong>. A place where we can browse character profiles like this one:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_two_character_sketch.jpg" alt="Wireframe for character profiling in the Rick and Migraty project." loading="lazy" decoding="async">
  <span class="post-figure__caption">Low-fidelity wireframe of a character profile</span>
</span>

</p>
<p>A website that serves as a valuable resource for looking up information about the roughly one thousand existing characters (the numbers vary depending on the source, as many characters inhabit different locations and there isn&rsquo;t always a clear consensus), along with the show&rsquo;s many episodes and seasons. <strong>All wrapped up in a consistent, simple, and visually appealing look and feel</strong>. Turning the original sketches and wireframes into something a little more exciting:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_three_ux_evolution.jpg" alt="The User Experience progression of character profiling." loading="lazy" decoding="async">
  <span class="post-figure__caption">The UX evolution of a character profile, from wireframe to mockup and design</span>
</span>

</p>
<p>On a more granular, specific level, we need a basic, straightforward, intuitive navigation system. A small, easy-to-use search tool to find and filter information, and, alongside the data platform itself, a centralized space to gather project-related documentation and know-how.
But since platforms like this already exist, we need to take things a step further: bringing together information that currently cannot be found in a single place.</p>
<p><strong>To achieve this, we&rsquo;ll design a migration process capable of merging data from multiple sources if necessary.</strong> But before writing migration YAML files, we need to fully understand the nature of the problem —<em>or rather, the technological challenge</em>— we’re trying to solve.</p>
<h2 id="3-analyzing-the-data-understanding">3. Analyzing the data (understanding)</h2>
<p>The first thing we need to think about is how to model what we&rsquo;re looking for: <strong>what information we want to retrieve</strong>, what kind of innovation we want to bring to the process (to create something distinctive, as we discussed earlier), and how we&rsquo;re going to achieve it.</p>
<p>The first question is: <strong>What would we consider valuable information?</strong> Let me list a few ideas:</p>
<ul>
<li>Having a <strong>synopsis for each episode</strong>, providing some context about what happens in it.</li>
<li>Getting a <strong>rating or score for each episode</strong>.</li>
<li>Having a list of the <strong>characters appearing in each episode</strong>.</li>
<li>Having some <strong>background information about characters</strong>, whether biographical or related to their role in the story.</li>
<li>Browse a <strong>list of episodes in which a particular character appears</strong>.</li>
</ul>
<p><strong>This is where a brief data source selection phase begins</strong>. For obvious reasons (since it was part of the original motivation behind this project), we want our primary data source to be <a href="https://rickandmortyapi.com/">The Rick and Morty API</a>. However, considering the requirements we&rsquo;ve outlined above, <strong>this API has some limitations</strong>:</p>
<ul>
<li>Episodes don&rsquo;t include a synopsis or description.</li>
<li>Episodes don&rsquo;t include a rating or score.</li>
<li>Characters don&rsquo;t include a description.</li>
</ul>
<p><strong>This API will be the canonical source for the core domain</strong>. That means it decides
which characters, locations and episodes exist in our Drupal model. Other APIs
can enrich the imported records, but they should not silently change the main
identity of the dataset. However, as you can see from the original data source itself (
<a href="https://rickandmortyapi.com/documentation#get-a-single-episode"><strong>https://rickandmortyapi.com/documentation#get-a-single-episode</strong></a>), we&rsquo;re still missing some information to complete our model:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_five_get_episode_from_API.jpg" alt="Data model for an episode from the Rick and Morty API." loading="lazy" decoding="async">
  <span class="post-figure__caption">Example of a data block retrieved from an episode</span>
</span>

</p>
<p>So, <strong>we need additional data sources</strong> to provide the missing information and enrich our existing data: descriptions (for both episodes and characters) and ratings (for episodes).</p>
<p>To retrieve character biographies and episode synopses, we can use the <a href="https://www.fandom.com/">Fandom</a> Wiki API, which can provide us with this information.</p>
<p><strong>And what about ratings per episode?</strong> Fandom may provide a field called ratings, but these aren&rsquo;t ratings in the sense of episode scores or reviews. On the Rick and Morty Wiki/Fandom episode pages, this field appears to represent audience figures (viewership in millions), rather than a numerical rating.</p>
<p>So, <strong>we need an additional data source to retrieve these episode ratings</strong>. For this, we&rsquo;ll use the <a href="https://www.tvmaze.com/">TVmaze</a> API (TVmaze is a community-driven database and tracking platform for television series and web shows).</p>
<p>This is not only &ldquo;<strong><em>read JSON and create Drupal content</em></strong>&quot;. That sentence is always
too small. In this case we are modelling a small content domain from remote APIs
data: characters, places, episodes, ratings, descriptions, references between records, and the
operational rules that make repeated imports predictable.</p>
<p><strong>So, to sum things up&hellip;</strong> At this point the project is already working with three API surfaces while
modelling the content and the migration:</p>
<table>
<thead>
<tr>
<th><strong>API</strong></th>
<th><strong>Role in the migration</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>https://rickandmortyapi.com/api</code></td>
<td>Canonical source for characters, locations and episodes. Its root endpoint exposes the three main collections: <code>character</code>, <code>location</code> and <code>episode</code>.</td>
</tr>
<tr>
<td><code>https://rickandmorty.fandom.com/api.php</code></td>
<td>Wiki/MediaWiki surface for editorial context that does not belong to the canonical entity identity. This source needs an explicit contract before we let it write into Drupal fields.</td>
</tr>
<tr>
<td><code>https://www.tvmaze.com/api</code></td>
<td>TV metadata API used as a secondary enrichment source for episode ratings and traceability through the TVmaze show and episode endpoints.</td>
</tr>
</tbody>
</table>
<p>That separation matters. When more than one API participates in the same
destination model, <strong>each source needs a clear responsibility</strong>. Otherwise the
migration becomes a quiet argument between remote systems, and Drupal is left
holding the broom.</p>
<p>The three main resources provided by the Rick and Morty API <strong>should map naturally to Drupal content-like entities</strong>:</p>
<ol>
<li><strong>Characters:</strong> Are the most visible records in the dataset. They carry status,
species, gender, origin, last known location, avatar URL and references to
episodes. They are also <strong>the records most readers will expect to browse first</strong>.</li>
<li><strong>Locations:</strong> Provide the <strong>spatial structure of the multiverse</strong>: planets, clusters,
dimensions and other places. They are not just labels attached to characters;
they are entities with their own metadata and reverse relationships through
residents.</li>
<li><strong>Episodes:</strong> Provide <strong>the narrative structure</strong>. They connect many characters, have
season and episode codes, and are the best place to merge TVmaze ratings
because both APIs can agree on season and episode number.</li>
</ol>
<p>For this article, I want to treat them as real structured domain data rather than as disposable sample JSON. That means we will not simply import characters as nodes and ignore everything else. In fact <strong>the interesting part is the relationship graph:</strong></p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_four_relationship_graph.jpg" alt="Diagram of relationships between key concepts." loading="lazy" decoding="async">
  <span class="post-figure__caption">Diagram of relationships between concepts/entities in the migration process</span>
</span>

</p>
<p><strong>As you can see in the image above</strong>:</p>
<ul>
<li>Characters have an origin and a last known location.</li>
<li>Characters appear in episodes.</li>
<li>Episodes contain many characters.</li>
<li>Locations contain residents.</li>
<li>Residents are characters.</li>
</ul>
<p>And yes, <strong>this means we have circular relationships.</strong></p>
<h2 id="4-some-initial-constraints-wait">4. Some initial constraints (wait)</h2>
<p>Those circular relationships are the first architectural constraint. They tell
us that the migration cannot be one single heroic import. So, let&rsquo;s get our thoughts organized.</p>
<p><strong>The first constraint is migration order.</strong> Locations and episodes
need to exist before characters can reference them. Characters need to exist
before episodes can fill their <code>characters</code> field and locations can fill their
<code>residents</code> field. The import order is not a technical afterthought; it is part
of the content model.</p>
<p><strong>The second constraint is source authority.</strong> Rick and Morty API records carry
stable numeric IDs and resource URLs, so those values should become our external
identity layer through fields such as <code>api_id</code> and <code>api_url</code>. TVmaze can enrich
episodes, but it should not create extra Drupal episodes that the canonical
source did not expose. Fandom can provide wiki-shaped editorial context, but it
must not casually overwrite the canonical source either. Before it enters a
destination field, we need to decide whether that field is source identity,
editorial enrichment, display helper data, or only diagnostic metadata.</p>
<p><strong>The third constraint is repeatability.</strong> During development we will run imports
many times, rollback, reset, limit pages, cache responses and sometimes use
fixtures. The scenario is therefore not only a modelling exercise. It is also an
operations exercise: can we run the migration again tomorrow without guessing
what state the database, the map tables or the remote API are in?</p>
<p>Welcome to migrations. <strong>The data is a complete mess, but at least it comes in JSON.</strong></p>
<h2 id="5-destination-containers-in-drupal-entities">5. Destination containers in Drupal (entities)</h2>
<p>My first instinct here is to avoid nodes. Not because nodes are bad, but because this data is imported, structured, external, and strongly relational. <strong>It feels more honest to model it as custom content entities.</strong></p>
<p>In the project this will be a dedicated entity API custom module: <code>rick_and_migraty_api</code></p>
<p>Its job is not to fetch remote data. <strong>Its job is to provide the permanent Drupal model that the migration layer can fill</strong> and that the site can later expose through routes, Views, templates and navigation. The module will define three custom content entities:</p>
<ol>
<li><strong>rick_and_migraty_character</strong></li>
<li><strong>rick_and_migraty_location</strong></li>
<li><strong>rick_and_migraty_episode</strong></li>
</ol>
<p>All three extend the same abstract base class:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">Drupal\rick_and_migraty_api\Entity\RickAndMigratyEntityBase
</code></pre></div><p>That base class centralizes the fields that every imported API resource needs:
<code>api_id</code>, <code>name</code>, <code>api_url</code>, <code>api_created</code>, <code>uid</code>, <code>created</code>, <code>changed</code> and
<code>path</code>. <strong>The important one is <code>api_id</code>: it is required, constrained as unique,
and represents the stable numeric identifier from the Rick and Morty API</strong>. In
practice, that makes it the external identity we can use for updates,
relationship lookups and debugging.</p>
<p>The <code>name</code> field is also the Drupal entity label through <code>entity_keys</code>, while
<code>api_url</code> and <code>api_created</code> keep the imported entity traceable to the original
remote resource. The local <code>created</code> and <code>changed</code> fields tell us when Drupal
created or updated its copy. <strong>The <code>uid</code> field exists because these are real
content entities and Drupal still needs an owner for access checks and edit
forms</strong>.</p>
<p>The base entity also keeps enrichment fields for a later Fandom pass:
<code>fandom_description</code>, <code>fandom_page_id</code>, <code>fandom_url</code> and <code>fandom_source</code>. That
is deliberate. <strong>The canonical entity identity comes from the Rick and Morty API,
but editorial context can arrive later from the Wiki/Fandom API as a separate
update operation</strong>. The model makes that distinction visible instead of pretending
every imported field has the same authority.</p>
<p>Finally, each entity includes a <code>path</code> base field. <strong>That gives Pathauto a place
to store readable aliases</strong> while the internal entity canonical routes remain
stable.</p>
<p><strong>The three entity types also must share the same administrative shape</strong>: they expose
canonical, collection, add, edit and delete routes, use a custom list builder for
administrative overviews, expose Views data, and are protected by a single
permission:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">administer rick and migraty api entities
</code></pre></div><p>One small but important implementation detail: <strong>view access is public, while
administrative operations remain protected</strong>. That matches the goal of the module:
the imported universe should be browsable, but managing imported records still
belongs behind Drupal permissions.</p>
<h3 id="locations">Locations</h3>
<p>Fields for <code>rick_and_migraty_location</code>:</p>
<table>
<thead>
<tr>
<th><strong>Field</strong></th>
<th><strong>Type</strong></th>
<th><strong>Notes</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>location_type</code></td>
<td><code>string</code></td>
<td>Maps the API <code>type</code> property without using the ambiguous field name <code>type</code>.</td>
</tr>
<tr>
<td><code>dimension</code></td>
<td><code>string</code></td>
<td>Stored as text because dimensions are not a separate normalized API resource.</td>
</tr>
<tr>
<td><code>residents</code></td>
<td>entity reference, multiple</td>
<td>References <code>rick_and_migraty_character</code>. Filled after characters exist.</td>
</tr>
</tbody>
</table>
<p>Locations are imported early because characters refer to them through <code>origin</code>
and <code>location.url</code>. The reverse <code>residents</code> relation can wait for a later pass.
That avoids asking the migration to reference characters that have not been
created yet.</p>
<h3 id="episodes">Episodes</h3>
<p>Fields for <code>rick_and_migraty_episode</code>:</p>
<table>
<thead>
<tr>
<th><strong>Field</strong></th>
<th><strong>Type</strong></th>
<th><strong>Notes</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>air_date_raw</code></td>
<td><code>string</code></td>
<td>Preserves the human-readable API value, such as <code>December 2, 2013</code>.</td>
</tr>
<tr>
<td><code>air_date</code></td>
<td><code>datetime</code> date</td>
<td>Optional normalized date for sorting and filtering when parsing is reliable.</td>
</tr>
<tr>
<td><code>episode_code</code></td>
<td><code>string</code></td>
<td>Stores codes such as <code>S01E01</code>.</td>
</tr>
<tr>
<td><code>season</code></td>
<td><code>integer</code></td>
<td>Derived from <code>episode_code</code>.</td>
</tr>
<tr>
<td><code>episode_number</code></td>
<td><code>integer</code></td>
<td>Derived from <code>episode_code</code>.</td>
</tr>
<tr>
<td><code>tvmaze_id</code></td>
<td><code>integer</code></td>
<td>TVmaze episode ID used for rating traceability.</td>
</tr>
<tr>
<td><code>tvmaze_url</code></td>
<td><code>uri</code></td>
<td>Canonical TVmaze episode URL.</td>
</tr>
<tr>
<td><code>rating_average</code></td>
<td><code>decimal</code></td>
<td>Average episode rating imported from TVmaze, stored with one decimal place.</td>
</tr>
<tr>
<td><code>rating_source</code></td>
<td><code>string</code></td>
<td>Provider label for the rating. The expected value is <code>TVmaze</code>.</td>
</tr>
<tr>
<td><code>characters</code></td>
<td>entity reference, multiple</td>
<td>References <code>rick_and_migraty_character</code>. Filled after characters exist.</td>
</tr>
</tbody>
</table>
<p>Episodes are where the project starts to move beyond the Rick and Morty API.
The API gives us the episode identity, title, code and character URLs. TVmaze
enriches that same episode with rating data. That is why the entity stores both
the canonical Rick and Morty API fields and the TVmaze traceability fields.</p>
<p>The <code>episode_code</code> field is intentionally preserved as source text and split
into <code>season</code> and <code>episode_number</code>. Those derived integer fields are more useful
for filtering, sorting, matching TVmaze episodes and building navigation.</p>
<h3 id="characters">Characters</h3>
<p>Fields for <code>rick_and_migraty_character</code>:</p>
<table>
<thead>
<tr>
<th><strong>Field</strong></th>
<th><strong>Type</strong></th>
<th><strong>Notes</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>status</code></td>
<td><code>list_string</code></td>
<td>Allowed values: <code>Alive</code>, <code>Dead</code>, <code>unknown</code>.</td>
</tr>
<tr>
<td><code>species</code></td>
<td><code>string</code></td>
<td>Free text because the API exposes open-ended values.</td>
</tr>
<tr>
<td><code>character_type</code></td>
<td><code>string</code></td>
<td>Maps the API <code>type</code> property without using the ambiguous field name <code>type</code>.</td>
</tr>
<tr>
<td><code>gender</code></td>
<td><code>list_string</code></td>
<td>Allowed values: <code>Female</code>, <code>Male</code>, <code>Genderless</code>, <code>unknown</code>.</td>
</tr>
<tr>
<td><code>origin</code></td>
<td>entity reference</td>
<td>References <code>rick_and_migraty_location</code>. Resolved from <code>origin.url</code> when available.</td>
</tr>
<tr>
<td><code>last_location</code></td>
<td>entity reference</td>
<td>References <code>rick_and_migraty_location</code>. Resolved from <code>location.url</code> when available.</td>
</tr>
<tr>
<td><code>image_url</code></td>
<td><code>uri</code></td>
<td>Keeps the remote avatar URL for traceability.</td>
</tr>
<tr>
<td><code>avatar</code></td>
<td><code>image</code></td>
<td>Downloaded local avatar image, stored under <code>public://rick-and-migraty/characters</code>.</td>
</tr>
<tr>
<td><code>episodes</code></td>
<td>entity reference, multiple</td>
<td>References <code>rick_and_migraty_episode</code>.</td>
</tr>
</tbody>
</table>
<p>Characters are the most connected entity in the model. They point to locations,
they point to episodes, and they also appear in reverse lists on locations and
episodes. For that reason, the character migration should not be the first one
to run. It needs locations and episodes to exist before <code>origin</code>,
<code>last_location</code> and <code>episodes</code> can be resolved safely.</p>
<p>The remote <code>image_url</code> and the local <code>avatar</code> field are intentionally separate.
The URL preserves the API payload. The image field represents a Drupal-managed
file that can be rendered consistently on character pages.</p>
<p><strong>This leaves the module with a graph that mirrors the source API</strong> without forcing
everything into a single import pass:</p>
<ul>
<li><code>rick_and_migraty_character.origin</code> points to <code>rick_and_migraty_location</code></li>
<li><code>rick_and_migraty_character.last_location</code> points to <code>rick_and_migraty_location</code></li>
<li><code>rick_and_migraty_character.episodes</code> points to <code>rick_and_migraty_episode</code></li>
<li><code>rick_and_migraty_episode.characters</code> points back to <code>rick_and_migraty_character</code></li>
<li><code>rick_and_migraty_location.residents</code> points back to <code>rick_and_migraty_character</code></li>
</ul>
<p>And because these are custom content entities, <strong>they can participate naturally in
Views, entity routes, templates and permissions</strong> without pretending to be normal
editorial nodes. Take a look at the custom entities implemented for this project <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/tree/main/web/modules/custom/rick_and_migraty_api/src/Entity"><strong>here in the repository.</strong></a></p>
<p><strong>The project also defines readable Pathauto patterns</strong> for the imported entities:</p>
<table>
<thead>
<tr>
<th><strong>Entity type</strong></th>
<th><strong>Alias pattern</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>Character</td>
<td><code>/rick-and-migraty/characters/[rick_and_migraty_character:name]</code></td>
</tr>
<tr>
<td>Location</td>
<td><code>/rick-and-migraty/locations/[rick_and_migraty_location:name]</code></td>
</tr>
<tr>
<td>Episode</td>
<td><code>/rick-and-migraty/episodes/[rick_and_migraty_episode:name]</code></td>
</tr>
</tbody>
</table>
<p>Those aliases are intentionally plural and reader-friendly. Underneath, the
entity canonical routes are still stable Drupal routes such as
<code>/rick-and-migraty/character/{rick_and_migraty_character}</code>. <strong>That separation lets
the implementation stay predictable while the public URLs remain pleasant.</strong></p>
<h2 id="6-designing-the-migration-cooking">6. Designing the migration (cooking)</h2>
<p>Once we have the destination model figured out (or almost), <strong>the next step is not to start writing YAML files as fast as possible</strong>. The next step is to cook up the migration design: understand how the source data is organized, decide the import order, determine which problems we can solve with YAML and which ones require custom plugins, and only then start connecting source, process, and destination.</p>
<p><strong>We should also be aware that new difficulties and challenges will inevitably arise along the way</strong>. We&rsquo;ll need to iterate, experiment, and find solutions through trial and error. Be patient.</p>
<p>To get our cooking started, let&rsquo;s consider that the Rick and Morty API is a REST API that returns JSON. <strong>It is not JSON:API in the strict sense</strong> of the Drupal core module, but from a migration perspective, the fundamental idea is the same: we receive structured records from a remote source and transform them into Drupal entities.</p>
<p>The API uses <code>GET</code> requests over HTTPS and returns JSON responses. <strong>List endpoints are paginated</strong> and include an <code>info</code> object containing pagination metadata:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-json" data-lang="json">{
  <span style="color:#000080">&#34;info&#34;</span>: {
    <span style="color:#000080">&#34;count&#34;</span>: <span style="color:#099">826</span>,
    <span style="color:#000080">&#34;pages&#34;</span>: <span style="color:#099">42</span>,
    <span style="color:#000080">&#34;next&#34;</span>: <span style="color:#d14">&#34;https://rickandmortyapi.com/api/character/?page=2&#34;</span>,
    <span style="color:#000080">&#34;prev&#34;</span>: <span style="color:#000;font-weight:bold">null</span>
  },
  <span style="color:#000080">&#34;results&#34;</span>: []
}
</code></pre></div><p>The source gives us <strong>three main collections</strong>:</p>
<table>
<thead>
<tr>
<th><strong>Resource</strong></th>
<th><strong>Endpoint</strong></th>
<th><strong>Approximate count</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>Characters</td>
<td><code>/api/character</code></td>
<td>826</td>
</tr>
<tr>
<td>Locations</td>
<td><code>/api/location</code></td>
<td>126</td>
</tr>
<tr>
<td>Episodes</td>
<td><code>/api/episode</code></td>
<td>51</td>
</tr>
</tbody>
</table>
<p>The exact numbers are less important than <strong>the shape of the source data</strong>:</p>
<ul>
<li>List results live in <code>results</code></li>
<li>Pagination lives in <code>info.next</code></li>
<li>Relationships are represented as URLs</li>
<li>Individual objects have stable numeric IDs</li>
<li>The source is read-only from our point of view.</li>
</ul>
<p>A character record makes <strong>the main migration problem</strong> visible:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-json" data-lang="json">{
  <span style="color:#000080">&#34;id&#34;</span>: <span style="color:#099">1</span>,
  <span style="color:#000080">&#34;name&#34;</span>: <span style="color:#d14">&#34;Rick Sanchez&#34;</span>,
  <span style="color:#000080">&#34;status&#34;</span>: <span style="color:#d14">&#34;Alive&#34;</span>,
  <span style="color:#000080">&#34;species&#34;</span>: <span style="color:#d14">&#34;Human&#34;</span>,
  <span style="color:#000080">&#34;type&#34;</span>: <span style="color:#d14">&#34;&#34;</span>,
  <span style="color:#000080">&#34;gender&#34;</span>: <span style="color:#d14">&#34;Male&#34;</span>,
  <span style="color:#000080">&#34;origin&#34;</span>: {
    <span style="color:#000080">&#34;name&#34;</span>: <span style="color:#d14">&#34;Earth (C-137)&#34;</span>,
    <span style="color:#000080">&#34;url&#34;</span>: <span style="color:#d14">&#34;https://rickandmortyapi.com/api/location/1&#34;</span>
  },
  <span style="color:#000080">&#34;location&#34;</span>: {
    <span style="color:#000080">&#34;name&#34;</span>: <span style="color:#d14">&#34;Citadel of Ricks&#34;</span>,
    <span style="color:#000080">&#34;url&#34;</span>: <span style="color:#d14">&#34;https://rickandmortyapi.com/api/location/3&#34;</span>
  },
  <span style="color:#000080">&#34;image&#34;</span>: <span style="color:#d14">&#34;https://rickandmortyapi.com/api/character/avatar/1.jpeg&#34;</span>,
  <span style="color:#000080">&#34;episode&#34;</span>: [
    <span style="color:#d14">&#34;https://rickandmortyapi.com/api/episode/1&#34;</span>,
    <span style="color:#d14">&#34;https://rickandmortyapi.com/api/episode/2&#34;</span>
  ],
  <span style="color:#000080">&#34;url&#34;</span>: <span style="color:#d14">&#34;https://rickandmortyapi.com/api/character/1&#34;</span>,
  <span style="color:#000080">&#34;created&#34;</span>: <span style="color:#d14">&#34;2017-11-04T18:48:46.250Z&#34;</span>
}
</code></pre></div><p>The payload is simple to read, but not flat. Characters point to locations and
episodes by URL. Episodes point back to characters. Locations point to residents,
which are characters again. In other words, <strong>the source is small enough to explain
in an article, but relational enough to force real migration decisions</strong>.</p>
<p>The first design decision is <strong>import order</strong>. If we try to solve the whole graph in
a single heroic import, we are volunteering for pain.</p>
<p><strong>The pipeline we&rsquo;ll implement to guide the migration process will be divided into phases:</strong></p>
<ol>
<li>Import locations.</li>
<li>Import episodes, already enriched with TVmaze rating fields when available.</li>
<li>Download character avatar files.</li>
<li>Import characters, resolving locations, episodes and avatar files.</li>
<li>Update episodes so their <code>characters</code> field can resolve.</li>
<li>Update locations so their <code>residents</code> field can resolve.</li>
</ol>
<p>That sequence is <strong>the practical answer to the circular graph</strong>. Some relationship
fields exist in the first migration YAML already, but <code>migration_lookup</code> uses
<code>no_stub: true</code>, so unresolved references do not create placeholder entities.
<strong>The missing reverse references are completed later with <code>--update</code></strong> once the
character migration has created the local character entities.</p>
<p>And now, with the migration sequence clearly laid out, it&rsquo;s finally <strong>time to start implementing the steps</strong> we&rsquo;ve just outlined. Let&rsquo;s keep going.</p>
<h3 id="locations-first">Locations first</h3>
<p>In the context of this migration, <strong>a location is a key piece of information about a character: it tells us where they originally came from</strong> and where they were based (at least initially, since important characters in the series tend to move between dimensions, planets, and so on).
<strong>Locations are the first stable container</strong> because characters need them for
<code>origin</code> and <code>last_location</code>.</p>
<ul>
<li>Endpoint: <code>/api/location</code></li>
</ul>
<p>Load:</p>
<ul>
<li><code>api_id</code></li>
<li><code>name</code></li>
<li><code>location_type</code></li>
<li><code>dimension</code></li>
<li><code>api_url</code></li>
<li><code>api_created</code></li>
</ul>
<p>The complete location migration definition looks like this:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>rick_and_migraty_location<span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">label</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Rick and Migraty locations&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_group</span>:<span style="color:#bbb"> </span>rick_and_migraty<span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Custom source plugin that follows the Rick and Morty API info.next pager.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_morty_api<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">resource</span>:<span style="color:#bbb"> </span>location<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Avoid status/listing commands walking the public API just to count rows.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">skip_count</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Cache each API page by URL so repeated imports and retries are gentle.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache_ttl</span>:<span style="color:#bbb"> </span><span style="color:#099">86400</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Keep this at 0 by default; raise it locally if the public API throttles.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">request_delay_ms</span>:<span style="color:#bbb"> </span><span style="color:#099">0</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Store the external ID as a first-class field. The migrate map still owns</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># the Drupal entity ID mapping.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_id</span>:<span style="color:#bbb"> </span>id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">name</span>:<span style="color:#bbb"> </span>name<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">location_type</span>:<span style="color:#bbb"> </span>type<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">dimension</span>:<span style="color:#bbb"> </span>dimension<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">residents</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      # Location rows contain an array of resident character URLs.</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_url_id<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>residents<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_character<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_url</span>:<span style="color:#bbb"> </span>url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_created</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>format_date<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>created<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d\TH:i:s.v\Z&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d\TH:i:s&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">uid</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>default_value<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">default_value</span>:<span style="color:#bbb"> </span><span style="color:#099">1</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">destination</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;entity:rick_and_migraty_location&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">validate</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_dependencies</span>:<span style="color:#bbb"> </span>{}<span style="color:#bbb">
</span></code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/migrations/rick_and_migraty_location.yml" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>The first block is <strong>the source definition</strong>. Instead of listing concrete URLs, the
migration asks the custom <code>rick_and_morty_api</code> source plugin for the <code>location</code>
resource. That small <code>resource: location</code> setting is enough for the plugin to
build the first endpoint, follow the API pager, cache responses and expose every
location in <code>results</code> as one migration row.</p>
<p>The direct field mappings are <strong>intentionally boring, and that is a good thing</strong>.
<code>api_id</code> stores the external numeric ID, <code>name</code> keeps the visible label,
<code>location_type</code> receives the API <code>type</code> value, and <code>dimension</code> preserves the
fictional space where the location belongs. Then <code>api_url</code> and <code>api_created</code>
keep the record traceable back to the original API response.</p>
<p>There is <strong>one transformation in this first migration that deserves attention</strong>:
<code>api_created</code> uses <code>format_date</code>. The source gives us a timestamp like
<code>2017-11-10T12:42:04.162Z</code>; Drupal stores it as a cleaner <code>Y-m-d\TH:i:s</code> value
in UTC. It is not a dramatic transformation, but it is exactly the kind of small
normalization that makes imported data easier to query later.</p>
<p>The <code>residents</code> mapping is more interesting because <strong>it is a relationship field</strong>.
Location rows contain an array of character URLs, not local Drupal entity IDs.
So the process pipeline first uses <code>rick_and_migraty_url_id</code> to extract the
numeric character IDs from those URLs, and then uses <code>migration_lookup</code> against
<code>rick_and_migraty_character</code>.</p>
<p>On the first pass, <strong>those lookups cannot resolve yet because characters do not
exist</strong>. That is not a bug in the YAML; it is part of the migration design. The
<code>no_stub: true</code> setting tells Drupal not to create placeholder characters just to
satisfy the relationship too early. Later, after the character migration has
created the real local entities, the project runs this same location migration
with <code>--update</code> to backfill the <code>residents</code> field.</p>
<p>Finally, <strong>the destination is explicit</strong>:
<code>entity:rick_and_migraty_location</code>. We are not creating nodes, taxonomy terms or
generic records here. We are filling the custom content entity that the
<code>rick_and_migraty_api</code> module defined for locations, with validation enabled so
bad rows fail loudly during the import instead of silently creating partial
content.</p>
<h3 id="episodes-second">Episodes second</h3>
<p><strong>Episodes are the second stable container in the graph</strong>. Characters can appear in
many episodes, and episode pages later need to list all appearing characters, but
the episode records themselves do not need characters to exist in order to be
created. That makes episodes safe to import before the character migration.</p>
<ul>
<li>Endpoint: <code>/api/episode</code></li>
</ul>
<p>And the migration stores <strong>both the source-facing
episode data and the normalized values we will need later</strong>.</p>
<p>Load:</p>
<ul>
<li><code>api_id</code></li>
<li><code>name</code></li>
<li><code>air_date_raw</code></li>
<li><code>air_date</code></li>
<li><code>episode_code</code></li>
<li><code>season</code></li>
<li><code>episode_number</code></li>
<li><code>tvmaze_id</code></li>
<li><code>tvmaze_url</code></li>
<li><code>rating_average</code></li>
<li><code>rating_source</code></li>
<li><code>api_url</code></li>
<li><code>api_created</code></li>
</ul>
<p>The complete episode migration definition looks like this:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">63
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">64
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">65
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">66
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>rick_and_migraty_episode<span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">label</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Rick and Migraty episodes&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_group</span>:<span style="color:#bbb"> </span>rick_and_migraty<span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Episodes are imported before character relationships are backfilled.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_morty_api<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">resource</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Avoid status/listing commands walking the public API just to count rows.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">skip_count</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Cache each API page by URL so repeated imports and retries are gentle.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache_ttl</span>:<span style="color:#bbb"> </span><span style="color:#099">86400</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Keep this at 0 by default; raise it locally if the public API throttles.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">request_delay_ms</span>:<span style="color:#bbb"> </span><span style="color:#099">0</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_id</span>:<span style="color:#bbb"> </span>id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">name</span>:<span style="color:#bbb"> </span>name<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">air_date_raw</span>:<span style="color:#bbb"> </span>air_date<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">air_date</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>format_date<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>air_date<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;F j, Y&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">episode_code</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">season</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_episode_part<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">part</span>:<span style="color:#bbb"> </span>season<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">episode_number</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_episode_part<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">part</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">tvmaze_id</span>:<span style="color:#bbb"> </span>tvmaze_id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">tvmaze_url</span>:<span style="color:#bbb"> </span>tvmaze_url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">rating_average</span>:<span style="color:#bbb"> </span>rating_average<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">rating_source</span>:<span style="color:#bbb"> </span>rating_source<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">characters</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      # Episode rows contain an array of character URLs.</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_url_id<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>characters<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_character<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_url</span>:<span style="color:#bbb"> </span>url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_created</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>format_date<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>created<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d\TH:i:s.v\Z&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d\TH:i:s&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">uid</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>default_value<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">default_value</span>:<span style="color:#bbb"> </span><span style="color:#099">1</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">destination</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;entity:rick_and_migraty_episode&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">validate</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_dependencies</span>:<span style="color:#bbb"> </span>{}<span style="color:#bbb">
</span></code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/migrations/rick_and_migraty_episode.yml" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>The first thing to notice is that <strong>the source plugin is still
<code>rick_and_morty_api</code>, but the <code>resource</code> changes to <code>episode</code></strong>. That keeps the
pagination, cache and request-delay behavior in one reusable source plugin while
letting the YAML describe the specific destination entity we want to fill.</p>
<p>The process section then does <strong>three different kinds of work</strong>. Some fields are
direct copies from the source row, such as <code>api_id</code>, <code>name</code>, <code>episode_code</code> and
<code>api_url</code>. Some fields are parsed or normalized: <code>air_date</code> converts a human date
such as <code>December 2, 2013</code> into <code>Y-m-d</code>, while <code>season</code> and <code>episode_number</code> use
the custom <code>rick_and_migraty_episode_part</code> process plugin to split values like
<code>S01E01</code>.</p>
<p><strong>The TVmaze fields are already present in the source row</strong> by the time the process
pipeline runs. When <code>resource: episode</code> is used, the custom source plugin can
load TVmaze episodes for show <code>216</code>, build a lookup keyed by season and episode
number, and add <code>tvmaze_id</code>, <code>tvmaze_url</code>, <code>rating_average</code> and <code>rating_source</code>
to the migration row. That is an important boundary: TVmaze enriches canonical
episode rows, but it does not create episodes.</p>
<p>Finally, <strong>the <code>characters</code> field is present in this YAML even though characters
do not exist yet during the first pass</strong>. The pair
<code>rick_and_migraty_url_id</code> + <code>migration_lookup</code> is <strong>the same relationship pattern</strong>
we saw in locations. Because <code>no_stub: true</code> is set, unresolved character
references are not turned into placeholder entities. The project later reruns the
episode migration with <code>--update</code> after characters exist.</p>
<h3 id="avatars-before-characters">Avatars before characters</h3>
<p><strong>Character avatars are deliberately handled as a separate file migration</strong>. This
may look like an extra step, but it keeps a fragile operation away from the main
character entity import. Downloading remote files is different from mapping JSON
fields: it can be slow, it can fail, and it can be throttled.</p>
<p>The migration reads character rows from <code>/api/character</code>, takes <strong>the remote
<code>image</code> URL</strong>, and downloads the file into Drupal&rsquo;s public filesystem under:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">public://rick-and-migraty/characters/
</code></pre></div><p>Here is the avatar file migration:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>rick_and_migraty_character_avatar<span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">label</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Rick and Migraty character avatars&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_group</span>:<span style="color:#bbb"> </span>rick_and_migraty<span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_morty_api<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">resource</span>:<span style="color:#bbb"> </span>character<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">skip_count</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache_ttl</span>:<span style="color:#bbb"> </span><span style="color:#099">86400</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">request_delay_ms</span>:<span style="color:#bbb"> </span><span style="color:#099">0</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">constants</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">avatar_directory</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;public://rick-and-migraty/characters/&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">avatar_extension</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;.jpeg&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">filename</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>concat<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- id<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- constants/avatar_extension<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">destination_uri</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>concat<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- constants/avatar_directory<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- id<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- constants/avatar_extension<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">uri</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>download<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- image<span style="color:#bbb">
</span><span style="color:#bbb">      </span>- <span style="color:#d14">&#39;@destination_uri&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">file_exists</span>:<span style="color:#bbb"> </span>replace<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">uid</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>default_value<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">default_value</span>:<span style="color:#bbb"> </span><span style="color:#099">1</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">status</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>default_value<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">default_value</span>:<span style="color:#bbb"> </span><span style="color:#099">1</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">destination</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;entity:file&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">validate</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span></code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/migrations/rick_and_migraty_character_avatar.yml" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>The source is again <code>rick_and_morty_api</code>, now with <code>resource: character</code>, because
the avatar URL lives on character payloads. The <code>constants</code> block defines two
<strong>values that do not come from the API</strong>: the destination directory and the file
extension. Keeping them in <code>constants</code> makes the process section easier to read.</p>
<p>The <code>filename</code> and <code>destination_uri</code> fields are <strong>built with <code>concat</code></strong>. A character
with source ID <code>1</code> becomes <code>1.jpeg</code>, and the final destination URI becomes
<code>public://rick-and-migraty/characters/1.jpeg</code>. That deterministic filename is
useful for repeatability: the same source row points to the same local file path
on every run.</p>
<p><strong>The actual download happens in the <code>uri</code> process pipeline</strong>. The <code>download</code> plugin
receives two values: the remote image URL and the calculated destination URI.
With <code>file_exists: replace</code>, repeated local imports can refresh the file instead
of failing because a previous attempt already created it.</p>
<p>The destination is <strong><code>entity:file</code>, not one of our custom Rick and Migraty content
entities</strong>. This migration exists only to create reusable file entities. The
character migration can then resolve <code>avatar/target_id</code> through
<code>migration_lookup</code>.</p>
<h3 id="characters-after-locations-episodes-and-avatars">Characters after locations, episodes and avatars</h3>
<p><strong>Characters are the center of the browsing experience</strong>, but they come after
locations, episodes and avatar files. That order is not arbitrary. A character
needs locations for <code>origin</code> and <code>last_location</code>, episodes for <code>episodes</code>, and a
file entity for <code>avatar</code>.</p>
<p>The endpoint is <code>/api/character</code>, and the migration fills:</p>
<ul>
<li>Character base fields such as <code>api_id</code>, <code>name</code>, <code>status</code>, <code>species</code>,
<code>character_type</code> and <code>gender</code></li>
<li>Location references through <code>origin</code> and <code>last_location</code></li>
<li>The remote <code>image_url</code></li>
<li>The local <code>avatar</code> image field</li>
<li>Episode references through <code>episodes</code></li>
<li>API traceability fields and ownership</li>
</ul>
<p>The implemented character migration looks like this:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">63
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">64
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">65
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">66
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">67
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">68
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">69
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">70
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">71
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">72
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">73
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">74
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">75
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">76
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">77
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">78
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>rick_and_migraty_character<span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">label</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Rick and Migraty characters&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_group</span>:<span style="color:#bbb"> </span>rick_and_migraty<span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Characters depend on locations and episodes for entity references.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_morty_api<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">resource</span>:<span style="color:#bbb"> </span>character<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Avoid status/listing commands walking the public API just to count rows.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">skip_count</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Cache each API page by URL so repeated imports and retries are gentle.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache_ttl</span>:<span style="color:#bbb"> </span><span style="color:#099">86400</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#998;font-style:italic"># Keep this at 0 by default; raise it locally if the public API throttles.</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">request_delay_ms</span>:<span style="color:#bbb"> </span><span style="color:#099">0</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_id</span>:<span style="color:#bbb"> </span>id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">name</span>:<span style="color:#bbb"> </span>name<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">status</span>:<span style="color:#bbb"> </span>status<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">species</span>:<span style="color:#bbb"> </span>species<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">character_type</span>:<span style="color:#bbb"> </span>type<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">gender</span>:<span style="color:#bbb"> </span>gender<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">origin</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      # The API nests the origin URL under origin.url.</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_url_id<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>origin/url<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_location<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">last_location</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      # The API nests the last known location URL under location.url.</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_url_id<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>location/url<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_location<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">image_url</span>:<span style="color:#bbb"> </span>image<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">avatar/target_id</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>migration_lookup<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_character_avatar<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>id<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">avatar/alt</span>:<span style="color:#bbb"> </span>name<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">episodes</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      # Character rows contain an array of episode URLs.</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_url_id<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#d14">-
</span><span style="color:#d14">      plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_episode<span style="color:#bbb">
</span><span style="color:#bbb">      </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_url</span>:<span style="color:#bbb"> </span>url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">api_created</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>format_date<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>created<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d\TH:i:s.v\Z&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_format</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Y-m-d\TH:i:s&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">from_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">to_timezone</span>:<span style="color:#bbb"> </span>UTC<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">uid</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>default_value<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">default_value</span>:<span style="color:#bbb"> </span><span style="color:#099">1</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">destination</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;entity:rick_and_migraty_character&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">validate</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_dependencies</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">required</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span>- rick_and_migraty_character_avatar<span style="color:#bbb">
</span><span style="color:#bbb">    </span>- rick_and_migraty_location<span style="color:#bbb">
</span><span style="color:#bbb">    </span>- rick_and_migraty_episode<span style="color:#bbb">
</span></code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/migrations/rick_and_migraty_character.yml" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>The first group of process mappings is <strong>straightforward</strong>: <code>api_id</code>, <code>name</code>,
<code>status</code>, <code>species</code>, <code>character_type</code> and <code>gender</code> are copied from the character
payload. Notice that the API field named <code>type</code> becomes <code>character_type</code> in
Drupal. That avoids colliding with the common Drupal idea of &ldquo;type&rdquo; while still
preserving the source value.</p>
<p>The <code>origin</code> and <code>last_location</code> mappings show <strong>the relationship pipeline in its
most useful form</strong>. The API nests location URLs under <code>origin/url</code> and
<code>location/url</code>. The custom <code>rick_and_migraty_url_id</code> plugin extracts the numeric
source ID from those URLs, and <code>migration_lookup</code> resolves that source ID against
the <code>rick_and_migraty_location</code> migration map.</p>
<p>The avatar mapping is slightly different because <strong>it points to the file migration,
not to a custom content entity</strong>. <code>avatar/target_id</code> looks up the file entity
created by <code>rick_and_migraty_character_avatar</code> using the same source character
ID. <code>avatar/alt</code> is mapped from the character name so the image field has useful
alternative text.</p>
<p>The <code>episodes</code> field uses <strong>the same URL-ID extraction pattern</strong>, but over an array
of episode URLs. Because episodes were imported before characters, those lookups
can resolve during the first character import pass.</p>
<p>The dependency block at the bottom <strong>documents the execution order explicitly</strong>:
characters require avatar files, locations and episodes. <strong>That gives Migrate
enough information to understand why this migration belongs later in the base
pipeline.</strong></p>
<h3 id="reverse-relationships-after-the-base-graph">Reverse relationships after the base graph</h3>
<p>At this point, <strong>the forward graph exists</strong>:</p>
<ul>
<li>Characters can point to their origin and last known location</li>
<li>Characters can point to the episodes where they appear</li>
<li>Characters can point to their local avatar file</li>
</ul>
<p>But <strong>two useful reverse relationships still need attention</strong>:</p>
<ul>
<li><code>rick_and_migraty_episode.characters</code></li>
<li><code>rick_and_migraty_location.residents</code></li>
</ul>
<p>Those mappings already exist in the episode and location migration YAML. <strong>The
reason they do not fully resolve during the first pass is timing</strong>: episodes and
locations are imported before characters. On that first run, the source rows
contain character URLs, but the local character entities do not exist yet.</p>
<p>That is why both mappings use <strong>the same careful pair of plugins</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#d14">-
</span><span style="color:#d14">  plugin: rick_and_migraty_url_id</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#d14">-
</span><span style="color:#d14">  plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_character<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span></code></pre></div><p>The custom process plugin <strong>extracts character IDs from source URLs</strong>. The
<code>migration_lookup</code> plugin asks Drupal&rsquo;s migrate map where those characters ended
up locally. And <code>no_stub: true</code> prevents Drupal from creating incomplete
placeholder characters when the lookup cannot resolve yet.</p>
<p>Once the character migration has run, the project performs <strong>update passes</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush migrate:import rick_and_migraty_episode --update
ddev drush migrate:import rick_and_migraty_location --update
</code></pre></div><p>This is the moment where <strong>the graph becomes bidirectional in Drupal</strong>, but without
forcing the first import pass to know everything at once. The same YAML can be
used both for the initial import and for the later relationship completion.</p>
<h3 id="source-strategy-do-not-hardcode-pagination">Source strategy: do not hardcode pagination</h3>
<p>All the migration definitions above share the same quiet assumption: <strong>none of
them wants to know how many pages the remote API has today</strong>. The YAML should say
what resource it needs, not carry a manually maintained list of URLs.</p>
<p>The Drupal side keeps that responsibility inside <strong>a dedicated migration module</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">rick_and_migraty_migrate
</code></pre></div><p>That module <strong>owns the source plugin, migration YAML, process plugins and
project-specific Drush helpers</strong>. The interesting question inside it is how much
of the remote API behavior should live in reusable PHP code, and how much should
be repeated in every migration file.</p>
<p>The Rick and Morty API returns <strong>paginated envelopes</strong>. Each response includes an
<code>info.next</code> URL and a <code>results</code> array. A generic <code>url</code> source plus JSON parser
can read an endpoint, but the migration still needs a comfortable way to walk
through every page and expose every item in <code>results</code> as a source row.</p>
<p>One possible strategy would be to use <strong>a generic URL source and list every page
explicitly</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">data_fetcher_plugin</span>:<span style="color:#bbb"> </span>http<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">data_parser_plugin</span>:<span style="color:#bbb"> </span>json<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">urls</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span>- <span style="color:#d14">&#39;https://rickandmortyapi.com/api/location?page=1&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span>- <span style="color:#d14">&#39;https://rickandmortyapi.com/api/location?page=2&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span>- <span style="color:#d14">&#39;https://rickandmortyapi.com/api/location?page=3&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">item_selector</span>:<span style="color:#bbb"> </span>results<span style="color:#bbb">
</span></code></pre></div><p>That can work for a small demo, but <strong>it is brittle</strong>. If the page count changes,
the migration definition must change. If we need character, location and episode
migrations, the same pagination concern starts leaking into multiple YAML files.
And if we want local guardrails, such as caching or limiting imports to one page
while testing, that logic has to be repeated or wrapped somewhere else.</p>
<p>The project uses a better option: <strong>a custom Migrate source plugin</strong> named: <code>rick_and_morty_api</code></p>
<p>Each migration then <strong>chooses a resource with one small setting</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_morty_api<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">resource</span>:<span style="color:#bbb"> </span>location<span style="color:#bbb">
</span></code></pre></div><p>The same source plugin can read <strong><code>character</code>, <code>location</code> or <code>episode</code></strong>. Internally
it builds the first URL from the configured resource, fetches the response,
merges rows from <code>results</code>, follows <code>info.next</code>, and stops either when there is
no next page or when a local page limit says &ldquo;enough for this run&rdquo;.</p>
<p>In practical terms, <strong>the source plugin does four jobs for the YAML files</strong>:</p>
<ol>
<li>Starts from the first endpoint</li>
<li>Reads <code>info.next</code></li>
<li>Yields each item in <code>results</code></li>
<li>Stops when <code>next</code> is <code>null</code></li>
</ol>
<p>That makes the migration definitions smaller and <strong>turns pagination into reusable
code</strong>. The YAML remains focused on the migration contract: source resource,
process mappings, destination plugin and dependencies.</p>
<p>The source plugin also has <strong>local-development guardrails</strong>. That matters because
migrations are not only code. They are operations we will run again and again
while developing, debugging and rolling back:</p>
<table>
<thead>
<tr>
<th><strong>Setting</strong></th>
<th><strong>Default</strong></th>
<th><strong>Why it exists</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>base_url</code></td>
<td><code>https://rickandmortyapi.com/api</code></td>
<td>Allows pointing the source to another compatible API base URL.</td>
</tr>
<tr>
<td><code>skip_count</code></td>
<td><code>true</code></td>
<td>Avoid paging through the entire API just to calculate migration totals.</td>
</tr>
<tr>
<td><code>cache</code></td>
<td><code>true</code></td>
<td>Reuse remote responses during repeated local runs.</td>
</tr>
<tr>
<td><code>cache_ttl</code></td>
<td><code>86400</code></td>
<td>Keep source responses for one day.</td>
</tr>
<tr>
<td><code>request_delay_ms</code></td>
<td><code>0</code></td>
<td>Add a delay between uncached requests when throttling appears.</td>
</tr>
<tr>
<td><code>max_pages</code></td>
<td>unset</td>
<td>Limit smoke tests to a small page subset.</td>
</tr>
<tr>
<td><code>fixture_base_path</code></td>
<td>unset</td>
<td>Use deterministic local JSON fixtures instead of remote HTTP.</td>
</tr>
<tr>
<td><code>enrich_tvmaze_ratings</code></td>
<td><code>true</code></td>
<td>Merge TVmaze rating data into episode source rows.</td>
</tr>
<tr>
<td><code>tvmaze_base_url</code></td>
<td><code>https://api.tvmaze.com</code></td>
<td>Allows TVmaze calls to be redirected in controlled environments.</td>
</tr>
<tr>
<td><code>tvmaze_show_id</code></td>
<td><code>216</code></td>
<td>Fixed TVmaze show ID for Rick and Morty.</td>
</tr>
</tbody>
</table>
<p>For local tests, <strong>several settings can be overridden with environment variables</strong>
instead of editing committed migration YAML:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev <span style="color:#0086b3">exec</span> env <span style="color:#008080">RICK_AND_MIGRATY_API_MAX_PAGES</span><span style="color:#000;font-weight:bold">=</span><span style="color:#099">1</span> <span style="color:#d14">\
</span><span style="color:#d14"></span>  <span style="color:#008080">RICK_AND_MIGRATY_API_REQUEST_DELAY_MS</span><span style="color:#000;font-weight:bold">=</span><span style="color:#099">250</span> <span style="color:#d14">\
</span><span style="color:#d14"></span>  drush migrate:import rick_and_migraty_location
</code></pre></div><p>The implementation supports <strong>overrides for the Rick and Morty API base URL,
request delay, page limit, fixture path, TVmaze base URL and TVmaze show ID</strong>. The
committed migrations can stay stable, while local runs can become faster,
slower, fixture-backed or pointed at another endpoint when needed.</p>
<p>This is also where <strong>the TVmaze enrichment belongs architecturally</strong>. The episode
YAML maps fields such as <code>tvmaze_id</code>, <code>tvmaze_url</code>, <code>rating_average</code> and
<code>rating_source</code>, but it does not need to know how those values were collected.
The source plugin enriches episode rows before the process pipeline receives
them. So, by the time the YAML runs, TVmaze data behaves like extra source
columns on the canonical episode row.</p>
<p>That is <strong>a small but important architectural decision</strong>. It lets the migration be
polite with a public API while still giving us a fast feedback loop. It also
keeps the definition of each migration readable: locations, episodes, avatars
and characters all use the same source plugin, but each one declares only the
resource and fields it actually cares about.</p>
<p>At this point, the design is no longer just &ldquo;import JSON into Drupal&rdquo;. It is a
sequence:</p>
<ol>
<li>Import canonical containers</li>
<li>Derive normalized fields</li>
<li>Resolve references from URLs</li>
<li>Download avatars as a batchable file migration</li>
<li>Enrich selected records from secondary sources</li>
<li>Keep remote requests cacheable, limitable and repeatable</li>
<li>Complete reverse relationships with update passes once the graph exists</li>
</ol>
<p><strong>That is the recipe</strong>. Now we can look at the custom plugins that turn those design
decisions into reusable migration code. Take a look at the migration definition files implemented for this project <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/tree/main/web/modules/custom/rick_and_migraty_migrate/migrations"><strong>here in the repository.</strong></a></p>
<h2 id="7-defining-migration-plugins-solutions">7. Defining migration plugins (solutions)</h2>
<p>The previous section describes the order of the migration. This section is about
<strong>the small pieces of custom code that make that order readable</strong>.</p>
<p>At some point, a migration stops being only YAML. That is not a failure of the
Migrate API. It is one of its strengths. <strong>YAML is excellent for declaring how a
row becomes an entity</strong>, but project-specific rules need names, boundaries and
tests. In Rick and Migraty, the custom plugins are exactly that: <strong>named solutions
to repeated migration problems.</strong></p>
<p>This is the moment where the implementation becomes more honest. If we keep
forcing every rule into YAML, the migration definitions become noisy and fragile.
If we move every tiny mapping into PHP, the YAML stops documenting the import.
So the useful question is not &ldquo;<strong><em>YAML or PHP?</em></strong>&quot;, but <strong><em>which responsibility belongs
where?&hellip;</em></strong></p>
<p>The project currently relies on four custom migration plugins:</p>
<table>
<thead>
<tr>
<th><strong>Plugin</strong></th>
<th><strong>Type</strong></th>
<th><strong>What it protects us from</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>rick_and_morty_api</code></td>
<td>Source</td>
<td>Hardcoding paginated API URLs, repeating cache logic, and making every migration know how <code>info.next</code> works.</td>
</tr>
<tr>
<td><code>rick_and_migraty_fandom</code></td>
<td>Source</td>
<td>Treating Fandom as a second canonical dataset instead of an enrichment layer over existing Drupal entities.</td>
</tr>
<tr>
<td><code>rick_and_migraty_url_id</code></td>
<td>Process</td>
<td>Repeating URL parsing logic every time an API relationship has to become an entity reference.</td>
</tr>
<tr>
<td><code>rick_and_migraty_episode_part</code></td>
<td>Process</td>
<td>Keeping <code>S03E07</code> forever as a string when the site really needs season and episode numbers.</td>
</tr>
</tbody>
</table>
<p>None of these plugins is especially large. <strong>That is part of the point</strong>. They solve
small problems that appear many times, and <strong>they keep the migration definitions
focused on the content model instead of low-level plumbing.</strong></p>
<p>There is a useful self-audit hidden here: <strong>custom plugins should not be the
default answer</strong>. Drupal core and contrib already provide a lot of migration
machinery. In this project we use standard process plugins such as
<code>format_date</code>, <code>concat</code>, <code>download</code>, <code>default_value</code> and <code>migration_lookup</code>, and
<code>migrate_plus</code> also gives us a generic <code>url</code> source, JSON parsers and process
plugins such as <code>preg_match</code>&hellip;</p>
<p><strong><em>So why not use <code>migrate_plus:url</code> for everything?</em></strong> For a tiny proof of concept,
we could. But the canonical <strong><em>Rick and Morty</em></strong> source is not only <strong>&ldquo;one JSON file at
one URL&rdquo;</strong>. It has API pagination through <code>info.next</code>, cache and request-delay
concerns, local fixture support, page limits for smoke tests, and TVmaze
enrichment for episode rows. <strong>If that behavior lives in YAML, every migration
starts to carry operational policy instead of only mapping data.</strong></p>
<p>The <strong>Fandom</strong> source has a similar reason to stay custom. It starts from existing
Drupal entities, resolves a wiki page through page IDs, aliases or exact
matches, skips ambiguous results, parses MediaWiki HTML and emits update rows
for existing destination entities. <strong>A generic URL source plugin can fetch JSON, but it
does not know that Fandom is an enrichment layer over records that already
exist in Drupal</strong>.</p>
<p>The honest trade-off is this: <strong>the source plugins are custom because they
define source contracts</strong>, not because we wanted to write PHP for fun. The small
process plugins deserve more scrutiny. <code>rick_and_migraty_url_id</code> still earns its
place because it handles both scalar relationship URLs and arrays of URLs in a
single readable step. <code>rick_and_migraty_episode_part</code>, however, could be
replaced by <code>migrate_plus</code>'s <code>preg_match</code> plugin:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">season</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>preg_match<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">pattern</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;/^S(\d+)E(\d+)$/&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">group_index</span>:<span style="color:#bbb"> </span><span style="color:#099">1</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">episode_number</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>preg_match<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">pattern</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;/^S(\d+)E(\d+)$/&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">group_index</span>:<span style="color:#bbb"> </span><span style="color:#099">2</span><span style="color:#bbb">
</span></code></pre></div><p>That substitution would reduce custom code. The reason to keep the custom
plugin would be readability and type intention, not technical necessity. And
that is exactly the kind of distinction I want to keep visible in a migration
project: <strong>custom code is acceptable when it names a real project contract; it
is suspicious when it merely hides a transformation already provided by core or
contrib</strong>.</p>
<h3 id="the-canonical-source-plugin">The canonical source plugin</h3>
<p>The <code>rick_and_morty_api</code> source plugin is responsible for <strong>the primary data
source</strong>. It receives a resource name, starts from the corresponding endpoint,
follows the API pagination through <code>info.next</code>, and yields each item from
<code>results</code> as a Migrate row.</p>
<p>The migration YAML can therefore say:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_morty_api<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">resource</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">skip_count</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">cache_ttl</span>:<span style="color:#bbb"> </span><span style="color:#099">86400</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">request_delay_ms</span>:<span style="color:#bbb"> </span><span style="color:#099">0</span><span style="color:#bbb">
</span></code></pre></div><p>That is much easier to maintain than listing every page manually. More
importantly, it gives us <strong>one place to handle operational concerns</strong>:</p>
<table>
<thead>
<tr>
<th><strong>Setting</strong></th>
<th><strong>Why it exists</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>skip_count</code></td>
<td>Avoids walking the whole public API just because <code>migrate:status</code> wants totals.</td>
</tr>
<tr>
<td><code>cache</code> and <code>cache_ttl</code></td>
<td>Make repeated local imports gentler and faster.</td>
</tr>
<tr>
<td><code>request_delay_ms</code></td>
<td>Lets local runs slow down when the public API starts throttling.</td>
</tr>
<tr>
<td><code>max_pages</code></td>
<td>Makes smoke tests possible without importing the full dataset.</td>
</tr>
<tr>
<td><code>fixture_base_path</code></td>
<td>Lets the migration read local JSON snapshots instead of making HTTP requests.</td>
</tr>
<tr>
<td><code>tvmaze_base_url</code></td>
<td>Allows TVmaze calls to be redirected in controlled environments.</td>
</tr>
<tr>
<td><code>tvmaze_show_id</code></td>
<td>Keeps episode enrichment attached to a fixed, explicit TVmaze show.</td>
</tr>
</tbody>
</table>
<p>This is one of those implementation details that feels modest, but changes the
daily experience of the project. <strong>Running a migration ten times while developing
should not mean hitting a public API ten times for the same pages</strong>.</p>
<p>The full source plugin lives in the migration module as
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Plugin/migrate/source/RickAndMortyApi.php"><code>RickAndMortyApi.php</code></a>.
The important thing for the article is not every line of that class, but the
contract it gives to the YAML: <strong>choose a resource, receive rows, keep remote
concerns centralized</strong>.</p>
<h3 id="the-url-language-problem">The URL language problem</h3>
<p>The Rick and Morty API expresses <strong>relationships as URLs</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">https://rickandmortyapi.com/api/location/20
https://rickandmortyapi.com/api/episode/1
https://rickandmortyapi.com/api/character/42
</code></pre></div><p>Drupal entity references do not need those URLs. <strong>They need local target entity
IDs</strong>. The bridge between those two worlds is the <code>rick_and_migraty_url_id</code>
process plugin.</p>
<p>Here is the implemented plugin:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">45
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">46
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">47
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">48
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">49
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">50
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">51
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">52
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">53
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">54
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">55
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">56
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">57
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">58
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">59
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">60
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">61
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">62
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">63
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">64
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">65
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">66
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">67
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">68
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">69
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">70
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">71
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">72
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">73
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">74
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">75
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">76
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">77
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">78
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">79
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-php" data-lang="php"><span style="color:#999;font-weight:bold;font-style:italic">&lt;?php</span>

<span style="color:#000;font-weight:bold">declare</span>(strict_types<span style="color:#000;font-weight:bold">=</span><span style="color:#099">1</span>);

<span style="color:#000;font-weight:bold">namespace</span> Drupal\rick_and_migraty_migrate\Plugin\migrate\process;

<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\Attribute\MigrateProcess;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\MigrateExecutableInterface;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\ProcessPluginBase;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\Row;

<span style="color:#d14">/**
</span><span style="color:#d14"> * Extracts numeric Rick and Morty API IDs from resource URLs.
</span><span style="color:#d14"> *
</span><span style="color:#d14"> * The API represents many relationships as URLs, for example
</span><span style="color:#d14"> * `https://rickandmortyapi.com/api/location/20`. Migrate lookup plugins need
</span><span style="color:#d14"> * the stable source ID (`20`), not the full URL, so this plugin converts either
</span><span style="color:#d14"> * a single URL or an array of URLs into integer IDs.
</span><span style="color:#d14"> *
</span><span style="color:#d14"> * Example:
</span><span style="color:#d14"> *
</span><span style="color:#d14"> * @code
</span><span style="color:#d14"> * process:
</span><span style="color:#d14"> *   origin:
</span><span style="color:#d14"> *     plugin: migration_lookup
</span><span style="color:#d14"> *     migration: rick_and_migraty_location
</span><span style="color:#d14"> *     source:
</span><span style="color:#d14"> *       plugin: rick_and_migraty_url_id
</span><span style="color:#d14"> *       source: origin/url
</span><span style="color:#d14"> * @endcode
</span><span style="color:#d14"> */</span>
<span style="color:#998;font-style:italic">#[MigrateProcess(&#39;rick_and_migraty_url_id&#39;)]
</span><span style="color:#998;font-style:italic"></span><span style="color:#000;font-weight:bold">final</span> <span style="color:#000;font-weight:bold">class</span> <span style="color:#458;font-weight:bold">RickAndMigratyUrlId</span> <span style="color:#000;font-weight:bold">extends</span> ProcessPluginBase {

  <span style="color:#d14">/**
</span><span style="color:#d14">   * {@inheritdoc}
</span><span style="color:#d14">   */</span>
  <span style="color:#000;font-weight:bold">public</span> <span style="color:#000;font-weight:bold">function</span> <span style="color:#900;font-weight:bold">transform</span>(<span style="color:#008080">$value</span>, MigrateExecutableInterface <span style="color:#008080">$migrate_executable</span>, Row <span style="color:#008080">$row</span>, <span style="color:#008080">$destination_property</span>)<span style="color:#000;font-weight:bold">:</span> mixed {
    <span style="color:#000;font-weight:bold">if</span> (is_array(<span style="color:#008080">$value</span>)) {
      <span style="color:#998;font-style:italic">// Relationship arrays such as episode.characters should stay arrays, but
</span><span style="color:#998;font-style:italic"></span>      <span style="color:#998;font-style:italic">// empty or malformed URLs are filtered out before lookup.
</span><span style="color:#998;font-style:italic"></span>      <span style="color:#000;font-weight:bold">return</span> array_values(array_filter(array_map(
        <span style="color:#000;font-weight:bold">static</span> fn (mixed <span style="color:#008080">$item</span>)<span style="color:#000;font-weight:bold">:</span> <span style="color:#000;font-weight:bold">?</span>int <span style="color:#000;font-weight:bold">=&gt;</span> self<span style="color:#000;font-weight:bold">::</span><span style="color:#008080">extractId</span>(<span style="color:#008080">$item</span>),
        <span style="color:#008080">$value</span>,
      )));
    }

    <span style="color:#000;font-weight:bold">return</span> self<span style="color:#000;font-weight:bold">::</span><span style="color:#008080">extractId</span>(<span style="color:#008080">$value</span>);
  }

  <span style="color:#d14">/**
</span><span style="color:#d14">   * Extracts the trailing numeric segment from an API URL.
</span><span style="color:#d14">   *
</span><span style="color:#d14">   * @param mixed $value
</span><span style="color:#d14">   *   A URL-like scalar value from the source row.
</span><span style="color:#d14">   *
</span><span style="color:#d14">   * @return int|null
</span><span style="color:#d14">   *   The numeric API ID, or NULL when the value cannot identify a resource.
</span><span style="color:#d14">   */</span>
  <span style="color:#000;font-weight:bold">private</span> <span style="color:#000;font-weight:bold">static</span> <span style="color:#000;font-weight:bold">function</span> <span style="color:#900;font-weight:bold">extractId</span>(mixed <span style="color:#008080">$value</span>)<span style="color:#000;font-weight:bold">:</span> <span style="color:#000;font-weight:bold">?</span>int {
    <span style="color:#000;font-weight:bold">if</span> (<span style="color:#000;font-weight:bold">!</span>is_scalar(<span style="color:#008080">$value</span>)) {
      <span style="color:#000;font-weight:bold">return</span> <span style="color:#000;font-weight:bold">NULL</span>;
    }

    <span style="color:#008080">$value</span> <span style="color:#000;font-weight:bold">=</span> trim((string) <span style="color:#008080">$value</span>);
    <span style="color:#000;font-weight:bold">if</span> (<span style="color:#008080">$value</span> <span style="color:#000;font-weight:bold">===</span> <span style="color:#d14">&#39;&#39;</span>) {
      <span style="color:#000;font-weight:bold">return</span> <span style="color:#000;font-weight:bold">NULL</span>;
    }

    <span style="color:#998;font-style:italic">// The API uses resource URLs ending in the numeric ID. Matching only the
</span><span style="color:#998;font-style:italic"></span>    <span style="color:#998;font-style:italic">// trailing segment keeps the plugin independent from the configured host.
</span><span style="color:#998;font-style:italic"></span>    <span style="color:#000;font-weight:bold">if</span> (preg_match(<span style="color:#d14">&#39;/\/(\d+)$/&#39;</span>, <span style="color:#008080">$value</span>, <span style="color:#008080">$matches</span>) <span style="color:#000;font-weight:bold">!==</span> <span style="color:#099">1</span>) {
      <span style="color:#000;font-weight:bold">return</span> <span style="color:#000;font-weight:bold">NULL</span>;
    }

    <span style="color:#000;font-weight:bold">return</span> (int) <span style="color:#008080">$matches</span>[<span style="color:#099">1</span>];
  }

}
</code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Plugin/migrate/process/RickAndMigratyUrlId.php" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>The plugin does one thing: <strong>it extracts the trailing numeric segment from a Rick
and Morty API URL</strong>. If it receives a single URL, it returns one integer. If it
receives an array of URLs, it maps the same operation over the array and filters
out empty or malformed values.</p>
<p>For a single relationship, the pipeline is easy to read:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">origin</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#d14">-
</span><span style="color:#d14">    plugin: rick_and_migraty_url_id</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>origin/url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#d14">-
</span><span style="color:#d14">    plugin: migration_lookup</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">migration</span>:<span style="color:#bbb"> </span>rick_and_migraty_location<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">no_stub</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span></code></pre></div><p>The first plugin extracts the source ID from the URL. The second plugin asks
<strong>the Migrate API map table</strong> which local entity was created from that source ID.
The same pattern works for arrays such as <code>episode.characters</code>,
<code>location.residents</code>, and <code>character.episode</code>.</p>
<p>That is a good example of <strong>a process plugin doing just enough</strong>. It does not know
about characters, locations, episodes or fields. It only knows how to translate
the source API&rsquo;s relationship language into IDs that Drupal can look up.</p>
<h3 id="turning-episode-codes-into-structure">Turning episode codes into structure</h3>
<p>Episode codes are another <strong>small but important transformation</strong>. The source API
gives us values like:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">S03E07
</code></pre></div><p>That is useful for humans, but <strong>not enough for sorting, filtering or joining
with TVmaze</strong>. The destination model stores both the original code and the
structured parts:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">season</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_episode_part<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">part</span>:<span style="color:#bbb"> </span>season<span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">episode_number</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_episode_part<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">part</span>:<span style="color:#bbb"> </span>episode<span style="color:#bbb">
</span></code></pre></div><p>The implemented plugin is intentionally strict:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">29
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">30
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">31
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">32
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">33
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">34
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">35
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">36
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">37
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">38
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">39
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">40
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">41
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">42
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">43
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">44
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-php" data-lang="php"><span style="color:#999;font-weight:bold;font-style:italic">&lt;?php</span>

<span style="color:#000;font-weight:bold">declare</span>(strict_types<span style="color:#000;font-weight:bold">=</span><span style="color:#099">1</span>);

<span style="color:#000;font-weight:bold">namespace</span> Drupal\rick_and_migraty_migrate\Plugin\migrate\process;

<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\Attribute\MigrateProcess;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\MigrateExecutableInterface;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\MigrateException;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\ProcessPluginBase;
<span style="color:#000;font-weight:bold">use</span> Drupal\migrate\Row;

<span style="color:#d14">/**
</span><span style="color:#d14"> * Extracts season or episode numbers from an API episode code.
</span><span style="color:#d14"> *
</span><span style="color:#d14"> * The source API stores episode position as a compact string like `S03E07`.
</span><span style="color:#d14"> * Drupal Views and filters are easier to build when season and episode number
</span><span style="color:#d14"> * are available as separate integer fields.
</span><span style="color:#d14"> *
</span><span style="color:#d14"> * Configuration keys:
</span><span style="color:#d14"> * - part: Required. Either `season` or `episode`.
</span><span style="color:#d14"> */</span>
<span style="color:#998;font-style:italic">#[MigrateProcess(&#39;rick_and_migraty_episode_part&#39;)]
</span><span style="color:#998;font-style:italic"></span><span style="color:#000;font-weight:bold">final</span> <span style="color:#000;font-weight:bold">class</span> <span style="color:#458;font-weight:bold">RickAndMigratyEpisodePart</span> <span style="color:#000;font-weight:bold">extends</span> ProcessPluginBase {

  <span style="color:#d14">/**
</span><span style="color:#d14">   * {@inheritdoc}
</span><span style="color:#d14">   */</span>
  <span style="color:#000;font-weight:bold">public</span> <span style="color:#000;font-weight:bold">function</span> <span style="color:#900;font-weight:bold">transform</span>(<span style="color:#008080">$value</span>, MigrateExecutableInterface <span style="color:#008080">$migrate_executable</span>, Row <span style="color:#008080">$row</span>, <span style="color:#008080">$destination_property</span>)<span style="color:#000;font-weight:bold">:</span> <span style="color:#000;font-weight:bold">?</span>int {
    <span style="color:#008080">$part</span> <span style="color:#000;font-weight:bold">=</span> <span style="color:#008080">$this</span><span style="color:#000;font-weight:bold">-&gt;</span><span style="color:#008080">configuration</span>[<span style="color:#d14">&#39;part&#39;</span>] <span style="color:#000;font-weight:bold">??</span> <span style="color:#000;font-weight:bold">NULL</span>;
    <span style="color:#000;font-weight:bold">if</span> (<span style="color:#000;font-weight:bold">!</span>in_array(<span style="color:#008080">$part</span>, [<span style="color:#d14">&#39;season&#39;</span>, <span style="color:#d14">&#39;episode&#39;</span>], <span style="color:#000;font-weight:bold">TRUE</span>)) {
      <span style="color:#000;font-weight:bold">throw</span> <span style="color:#000;font-weight:bold">new</span> MigrateException(<span style="color:#d14">&#39;The rick_and_migraty_episode_part plugin requires part: season or part: episode.&#39;</span>);
    }

    <span style="color:#000;font-weight:bold">if</span> (<span style="color:#000;font-weight:bold">!</span>is_scalar(<span style="color:#008080">$value</span>) <span style="color:#000;font-weight:bold">||</span> preg_match(<span style="color:#d14">&#39;/^S(\d+)E(\d+)$/&#39;</span>, (string) <span style="color:#008080">$value</span>, <span style="color:#008080">$matches</span>) <span style="color:#000;font-weight:bold">!==</span> <span style="color:#099">1</span>) {
      <span style="color:#000;font-weight:bold">return</span> <span style="color:#000;font-weight:bold">NULL</span>;
    }

    <span style="color:#998;font-style:italic">// Match groups are stable because the expression is anchored to the whole
</span><span style="color:#998;font-style:italic"></span>    <span style="color:#998;font-style:italic">// API code: group 1 is season, group 2 is episode number.
</span><span style="color:#998;font-style:italic"></span>    <span style="color:#000;font-weight:bold">return</span> (int) (<span style="color:#008080">$part</span> <span style="color:#000;font-weight:bold">===</span> <span style="color:#d14">&#39;season&#39;</span> <span style="color:#000;font-weight:bold">?</span> <span style="color:#008080">$matches</span>[<span style="color:#099">1</span>] <span style="color:#000;font-weight:bold">:</span> <span style="color:#008080">$matches</span>[<span style="color:#099">2</span>]);
  }

}
</code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Plugin/migrate/process/RickAndMigratyEpisodePart.php" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>It requires <code>part: season</code> or <code>part: episode</code>. It accepts values shaped like
<code>S03E07</code>. If the value does not match, it returns <code>NULL</code> instead of inventing a
number. That behavior matters because <strong>bad source values should not silently
become fake structure</strong>.</p>
<p>The result is simple:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">episode_code = S03E07
season = 3
episode_number = 7
</code></pre></div><p>This is the kind of transformation that <strong>makes the rest of the project easier</strong>.
Views can group by season, pages can sort episodes naturally, and TVmaze ratings
can be matched by season and number instead of by title.</p>
<p>Take a look at the custom plugins (process and source) implemented for this project <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/tree/main/web/modules/custom/rick_and_migraty_migrate/src/Plugin/migrate"><strong>here in the repository.</strong></a></p>
<h2 id="8-more-info-from-the-source-enrichment">8. More info from the source (enrichment)</h2>
<p>The project uses three external sources, but <strong>they do not all have the same
authority</strong>.</p>
<p><strong>The Rick and Morty API is the canonical source for the base dataset</strong>: characters,
locations, episodes and their relationship graph. TVmaze and Fandom are useful
because they add information the primary API does not provide, but they should
not be allowed to quietly redefine what the project considers a character, a
location or an episode.</p>
<p>That distinction keeps the migration sane. It gives us a rule we can repeat
throughout the implementation: <strong>secondary sources enrich existing records</strong>;
they do not create the canonical dataset.</p>
<h3 id="tvmaze-enriches-episode-rows">TVmaze enriches episode rows</h3>
<p>The <strong>Rick and Morty API</strong> gives us <strong>episode identity, episode code, air date and
the list of character appearances</strong>. It does not provide ratings. TVmaze fills
that gap.</p>
<p>The important decision is <strong>to avoid fuzzy searching during normal imports</strong>. The
show has already been selected, and the selected TVmaze show ID is explicit:</p>
<table>
<thead>
<tr>
<th><strong>Field</strong></th>
<th><strong>Value</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>TVmaze show ID</td>
<td><code>216</code></td>
</tr>
<tr>
<td>TVmaze show name</td>
<td><code>Rick and Morty</code></td>
</tr>
<tr>
<td>TVmaze show URL</td>
<td><code>https://www.tvmaze.com/shows/216/rick-and-morty</code></td>
</tr>
</tbody>
</table>
<p>The source plugin can load:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">https://api.tvmaze.com/shows/216/episodes
</code></pre></div><p>and <strong>build a lookup by season and episode number</strong>. Then each Rick and Morty
episode row can arrive in the process pipeline already enriched with:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">tvmaze_id</span>:<span style="color:#bbb"> </span>tvmaze_id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">tvmaze_url</span>:<span style="color:#bbb"> </span>tvmaze_url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">rating_average</span>:<span style="color:#bbb"> </span>rating_average<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">rating_source</span>:<span style="color:#bbb"> </span>rating_source<span style="color:#bbb">
</span></code></pre></div><p>Notice the merge key: <strong>season and episode number, not title</strong>. Titles are too
editorial to be a safe join key. Punctuation changes, alternate titles and small
wording differences can create bugs that look innocent for far too long.</p>
<p>So the association becomes:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">Rick and Morty API: S01E01
season: 1
episode_number: 1
TVmaze show: 216
TVmaze episode: season 1, number 1
</code></pre></div><p>That gives us ratings while keeping <strong>the Rick and Morty API in charge of episode
identity</strong>.</p>
<h3 id="fandom-enriches-existing-entities">Fandom enriches existing entities</h3>
<p>Fandom is a different kind of source. <strong>It is rich, useful and editorial</strong>, but it is
also messy in the way wikis are allowed to be messy. Search results can be
ambiguous, page titles can differ from API names, and an episode called <code>Pilot</code>
is not exactly a unique phrase on the Internet.</p>
<p>For that reason, <strong>the Fandom migrations run after the base entities exist</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">rick_and_migraty_character_fandom
rick_and_migraty_location_fandom
rick_and_migraty_episode_fandom
</code></pre></div><p>They update existing Drupal entities. <strong>They do not create new canonical
records</strong>.</p>
<p>The <code>rick_and_migraty_fandom</code> source plugin <strong>starts from Drupal entities</strong>, resolves
a Fandom page through an explicit page ID, alias or exact search, reads the
MediaWiki API, extracts useful description text and emits rows containing the
existing entity ID plus Fandom metadata.</p>
<p>The character enrichment migration is a compact example:</p>







<div class="code-snippet">
  <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>rick_and_migraty_character_fandom<span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">label</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Rick and Migraty character Fandom enrichment&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_group</span>:<span style="color:#bbb"> </span>rick_and_migraty<span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">source</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span>rick_and_migraty_fandom<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">entity_type</span>:<span style="color:#bbb"> </span>rick_and_migraty_character<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">skip_count</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">page_id_map</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">1</span>:<span style="color:#bbb"> </span><span style="color:#099">2073</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">2</span>:<span style="color:#bbb"> </span><span style="color:#099">2074</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>entity_id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_description</span>:<span style="color:#bbb"> </span>fandom_description<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_page_id</span>:<span style="color:#bbb"> </span>fandom_page_id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_url</span>:<span style="color:#bbb"> </span>fandom_url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_source</span>:<span style="color:#bbb"> </span>fandom_source<span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">destination</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;entity:rick_and_migraty_character&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">validate</span>:<span style="color:#bbb"> </span><span style="color:#000;font-weight:bold">true</span><span style="color:#bbb">
</span><span style="color:#bbb">
</span><span style="color:#bbb"></span><span style="color:#000;font-weight:bold">migration_dependencies</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">required</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span>- rick_and_migraty_character<span style="color:#bbb">
</span></code></pre></td></tr></table>
</div>
</div>
  
  <p class="code-snippet__source">
    Source: <a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/migrations/rick_and_migraty_character_fandom.yml" rel="noopener noreferrer" target="_blank">GitLab source file</a>
  </p>
  
</div>


<p>The source plugin itself lives in
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Plugin/migrate/source/RickAndMigratyFandom.php"><code>RickAndMigratyFandom.php</code></a>.
It uses two supporting services:
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Fandom/FandomClient.php"><code>FandomClient.php</code></a>
for cached MediaWiki API requests, and
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Fandom/FandomDescriptionExtractor.php"><code>FandomDescriptionExtractor.php</code></a>
for turning parsed wiki HTML into readable introductory paragraphs.</p>
<p>The destination remains the same entity type:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">destination</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">plugin</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;entity:rick_and_migraty_character&#39;</span><span style="color:#bbb">
</span></code></pre></div><p>and <strong>the process mapping updates enrichment fields</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">process</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">id</span>:<span style="color:#bbb"> </span>entity_id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_description</span>:<span style="color:#bbb"> </span>fandom_description<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_page_id</span>:<span style="color:#bbb"> </span>fandom_page_id<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_url</span>:<span style="color:#bbb"> </span>fandom_url<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">fandom_source</span>:<span style="color:#bbb"> </span>fandom_source<span style="color:#bbb">
</span></code></pre></div><p>The rule is deliberately conservative: <strong>when a match is ambiguous, skip it and
log it</strong>. A missing description is easier to repair than a confident wrong one.</p>
<p>The YAML makes that contract visible. Explicit <code>page_id_map</code> entries can pin
known difficult cases. <code>aliases</code> can help when the Fandom title is not the same
as the API label. Exact search can handle the simple cases. And the destination
still points to the existing custom entity, using <code>id: entity_id</code>, so enrichment
lands on the already-imported Drupal record.</p>
<h2 id="9-from-data-to-content-browsing">9. From data to content (browsing)</h2>
<p>A migration is not finished when rows exist in database tables. <strong>It becomes
useful when people can browse, query and understand the imported data</strong>.</p>
<p>That is why the custom entity model matters. Once <strong>characters, locations and
episodes exist as real Drupal entities</strong>, the site can expose them as pages,
routes, Views and relationship-driven navigation.</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_seven_main_list_item.jpg" alt="The complete list of items." loading="lazy" decoding="async">
  <span class="post-figure__caption">Complete list of items with filtering and searching box</span>
</span>

</p>
<p>For a character page, <strong>the useful questions are immediate</strong>:</p>
<ul>
<li>Who is this character?</li>
<li>What is their status, species and gender?</li>
<li>Where are they from?</li>
<li>Where were they last seen?</li>
<li>Which episodes include them?</li>
<li>What extra context did Fandom provide?</li>
</ul>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_eight_alien_rick_profile.jpg" alt="Profile for character." loading="lazy" decoding="async">
  <span class="post-figure__caption">Example for character - Alien Rick</span>
</span>

</p>
<p>For a location page, <strong>the questions change</strong>:</p>
<ul>
<li>What kind of place is this?</li>
<li>Which dimension does it belong to?</li>
<li>Which characters are residents?</li>
</ul>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_nine_birdperson_location.jpg" alt="Profile for a location." loading="lazy" decoding="async">
  <span class="post-figure__caption">Example for location: the memories of Birdperson</span>
</span>

</p>
<p>And for an episode page:</p>
<ul>
<li>Which season and episode number is it?</li>
<li>When did it air?</li>
<li>What rating did TVmaze provide?</li>
<li>Which characters appear in it?</li>
</ul>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_ten_anatomy_park_episode.jpg" alt="View of an episode." loading="lazy" decoding="async">
  <span class="post-figure__caption">View of an episode page: The Anatomy Park</span>
</span>

</p>
<p>The same data can also become Views: <strong>characters by dimension, episodes by
season, locations with many residents, or a small browser for characters by
status</strong>. This is where the migration stops being a backend exercise and becomes
a content product.</p>
<p>The theme templates show that idea in practice:</p>
<ul>
<li><a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/themes/custom/rick_and_migraty_theme/templates/content/rick-and-migraty-character.html.twig"><code>rick-and-migraty-character.html.twig</code></a> renders profile badges, the avatar, Fandom overview text, episode links and source metadata.</li>
<li><a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/themes/custom/rick_and_migraty_theme/templates/content/rick-and-migraty-location.html.twig"><code>rick-and-migraty-location.html.twig</code></a> renders location metadata, residents and Fandom context.</li>
<li><a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/themes/custom/rick_and_migraty_theme/templates/content/rick-and-migraty-episode.html.twig"><code>rick-and-migraty-episode.html.twig</code></a> renders season/episode data, TVmaze rating badges and character appearances.</li>
</ul>
<p>That means the migration is not only filling fields. <strong>It is feeding a browsing
experience</strong>. The fields chosen in the destination model become the variables the
theme can use: <code>avatar_url</code>, <code>episode_links</code>, <code>resident_cards</code>,
<code>appearance_cards</code>, <code>fandom.description</code>, <code>profile.api_url</code>, and so on.</p>
<p>The project also includes a browsing route:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-yaml" data-lang="yaml"><span style="color:#000;font-weight:bold">rick_and_migraty_migrate.browse</span>:<span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">path</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;/browse-all-content&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">defaults</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">_controller</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;\Drupal\rick_and_migraty_migrate\Controller\BrowseContentController::browse&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">_title</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;Browse all content&#39;</span><span style="color:#bbb">
</span><span style="color:#bbb">  </span><span style="color:#000;font-weight:bold">requirements</span>:<span style="color:#bbb">
</span><span style="color:#bbb">    </span><span style="color:#000;font-weight:bold">_access</span>:<span style="color:#bbb"> </span><span style="color:#d14">&#39;TRUE&#39;</span><span style="color:#bbb">
</span></code></pre></div><p>That controller, implemented in
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Controller/BrowseContentController.php"><code>BrowseContentController.php</code></a>,
does two interesting things for this article. First, it provides <strong>a searchable
matrix of imported entities</strong> through <code>/browse-all-content</code>. Second, it exposes
browser-visible migration steps and status endpoints so the project can show the
import process as an operation, not just as a terminal command.</p>
<p>It also explains why flattening everything into text fields would have been a
poor shortcut. <strong>The interesting part of the dataset is the graph</strong>. Characters
link to episodes, episodes link back to characters, locations list residents,
and enrichment fields add context without stealing identity from the canonical
source.</p>
<h2 id="10-the-migration-as-an-operation-running">10. The migration as an operation (running)</h2>
<p>Implementation is one half of a migration. <strong>Operation is the other half</strong>.</p>
<p>This is especially true when the migration touches several remote services,
downloads files, enriches entities in later passes, and needs to be run again
and again during development. At that point, the project needs more than YAML
definitions. It needs <strong>repeatable entry points</strong>.</p>
<p>This project now supports several ways to run the process, each with a different
purpose:</p>
<table>
<thead>
<tr>
<th><strong>Entry point</strong></th>
<th><strong>Best use</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>scripts/rick-and-migraty-migrate.sh</code></td>
<td>Repeatable local runs, especially with fixtures.</td>
</tr>
<tr>
<td><code>ddev drush ramt:migrate:*</code></td>
<td>Developer operations: reset, import, avatar batches.</td>
</tr>
<tr>
<td>Browser modal</td>
<td>A visual control panel for running and observing the migration step by step.</td>
</tr>
</tbody>
</table>
<p>The shell script lives at
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/scripts/rick-and-migraty-migrate.sh"><code>scripts/rick-and-migraty-migrate.sh</code></a>.
It is the highest-level local runner: it rebuilds caches, optionally resets
previous imported content, chooses fixtures or live API calls, imports base
content, generates aliases, runs Fandom enrichment and prints validation counts.</p>
<p>For a local run based on existing fixtures:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">scripts/rick-and-migraty-migrate.sh --fixtures
</code></pre></div><p>If fixtures need to be generated first:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">scripts/rick-and-migraty-migrate.sh --build-fixtures
</code></pre></div><p>Fixtures live under:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">.ddev/.tmp-fixtures/rick-and-migraty-api
</code></pre></div><p>They let <strong>the canonical Rick and Morty JSON rows come from stable local files</strong>
instead of the public API. That is very useful because it separates two problems
that otherwise get mixed together:</p>
<ol>
<li>Is the migration code broken?</li>
<li>Or is a remote service slow, throttling, unavailable or changing?</li>
</ol>
<p>For faster structural checks, <strong>expensive phases can be skipped</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">scripts/rick-and-migraty-migrate.sh --fixtures --no-avatars --no-fandom
</code></pre></div><p>The normal Drush cycle is intentionally short:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush ramt:migrate:reset
ddev drush ramt:migrate:import
</code></pre></div><p>Those commands are implemented in
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Drush/Commands/RickAndMigratyMigrateCommands.php"><code>RickAndMigratyMigrateCommands.php</code></a>.
The import helper follows the same order we designed earlier: locations,
episodes, avatars, characters, episode relationship update, and location
relationship update.</p>
<p>The reset command is important. <strong>It cleans imported entities, avatar file
entities, physical avatar files, aliases and Migrate API map/message rows. That
last part matters because deleting imported entities from the UI does not clear
Migrate API bookkeeping. Drupal can look empty while <code>migrate:status</code> still
believes the rows are already imported</strong>.</p>
<p>That cleanup is delegated to
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/MigrationResetter.php"><code>MigrationResetter.php</code></a>,
which makes the reset operation explicit: delete custom entities, remove avatar
file entities and physical files, delete <code>/rick-and-migraty/%</code> aliases, truncate
<code>migrate_map_*</code> and <code>migrate_message_*</code> tables, and invalidate caches.</p>
<p>Avatar downloads are handled as <strong>their own operation</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush ramt:migrate:avatars --batch-size<span style="color:#000;font-weight:bold">=</span><span style="color:#099">10</span> --sleep<span style="color:#000;font-weight:bold">=</span><span style="color:#099">12</span> --retries<span style="color:#000;font-weight:bold">=</span><span style="color:#099">3</span>
</code></pre></div><p>That command shape tells the truth about the work. <strong>Downloading hundreds of
remote files is not the same kind of operation as mapping JSON fields. It needs
batching, retries and pauses</strong>. The helper imports avatars with <code>--idlist</code>
ranges, adds a local request delay, and retries failed batches before giving up.</p>
<p>That separation gives us a practical recovery path: <strong>if avatar downloads fail
halfway through, we can retry the file phase without pretending the whole
migration model is broken</strong>.</p>
<h3 id="rate-limits-are-part-of-the-migration-design">Rate limits are part of the migration design</h3>
<p>During development, the project also ran into a very concrete reminder that
remote APIs are not infinite: <strong><code>429 Too Many Requests</code> responses</strong>. That kind of
error is easy to treat as an annoyance, but in a migration it is actually useful
feedback. <strong>It tells us that the import process is asking too much and too quickly,
or too repeatedly from a service we do not control</strong>.</p>
<p>The response was not to hide the error. The response was to <strong>turn rate limiting
into a design constraint</strong>:</p>
<ul>
<li>Source responses are cached by URL, so repeated local runs do not keep asking
for the same pages</li>
<li>Fixtures can replace remote API calls when the goal is to test mapping logic</li>
<li><code>RICK_AND_MIGRATY_API_MAX_PAGES</code> can limit smoke tests to a small subset</li>
<li><code>request_delay_ms</code> can slow down uncached API requests</li>
<li>Avatar downloads run in batches with <code>--sleep</code> and <code>--retries</code></li>
<li>Avatar imports use <code>--idlist</code> ranges, so a failed batch can be retried without
replaying the whole migration</li>
</ul>
<p>This is why the avatar phase is deliberately sequenced instead of being hidden
inside the character import. <strong>Remote file downloads are the most aggressive part
of the pipeline</strong>: hundreds of HTTP requests, binary transfers, local file
entities, filesystem writes and retry behavior. <strong>Keeping that work separate makes
the migration more polite and much easier to recover.</strong></p>
<p>In other words, <strong><code>429</code> code was not only an error code. It was a design review from the
remote API.</strong></p>
<h3 id="the-browser-is-a-control-panel">The browser is a control panel</h3>
<p>The browser flow is useful, but <strong>it should not pretend to be the migration
engine</strong>.</p>
<p>The first version tried to run too much work in one long request. <strong>That is fragile</strong>
because PHP-FPM, nginx, proxies and browsers all have opinions about long-running
HTTP requests, and none of those opinions are sentimental. 😉</p>
<p>The current flow uses <strong>smaller POST steps</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-text" data-lang="text">/rick-and-migraty/run-migration-step/{step}
</code></pre></div><p>The route is declared in
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/rick_and_migraty_migrate.routing.yml"><code>rick_and_migraty_migrate.routing.yml</code></a>
and handled by
<a href="https://gitlab.com/davidjguru/rick-and-migraty/-/blob/main/web/modules/custom/rick_and_migraty_migrate/src/Controller/BrowseContentController.php"><code>BrowseContentController.php</code></a>.</p>
<p>This is the modal window:</p>
<p>
<span class="post-figure">
  <img src="../../images/post/davidjguru_drupal_11_migrations_rick_and_migraty_six_modal_window.jpg" alt="Modal window showing progress from the migration." loading="lazy" decoding="async">
  <span class="post-figure__caption">Modal window showing progress and some trace from migration</span>
</span>

</p>
<p>The modal shows <strong>progress, recent trace entries and a final summary</strong>. The same
trace is written to watchdog through the <code>rick_and_migraty_migrate</code> logger
channel, so a symptom seen in the browser can be investigated from Drush:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush watchdog:show --type<span style="color:#000;font-weight:bold">=</span>rick_and_migraty_migrate --count<span style="color:#000;font-weight:bold">=</span><span style="color:#099">50</span>
</code></pre></div><p>Avatar work is split internally into ranges such as <code>avatars-1</code>, <code>avatars-2</code>
and so on. The user sees one &ldquo;<strong><em>Processing avatars</em></strong>&rdquo; phase, but the backend gets
<strong>smaller units of work that can fail, log and recover more clearly</strong>.</p>
<p>That distinction is important: the browser is not a replacement for Drush. <strong>It is
a guided control panel over the same operational steps</strong>, useful for observing the
pipeline and for making the migration experience visible.</p>
<h2 id="11-losing-your-mind-debugging">11. Losing your mind (debugging)</h2>
<p>When a migration fails, the tempting move is to stare at the YAML until it
confesses. Sometimes that works. Often, <strong>it just makes the YAML nervous.</strong> 😬</p>
<p>A better routine is <strong>to debug from the outside in</strong>.</p>
<ul>
<li><strong>First</strong>, inspect the source:</li>
</ul>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">curl -s <span style="color:#d14">&#39;https://rickandmortyapi.com/api/character&#39;</span> | jq <span style="color:#d14">&#39;.info&#39;</span>
curl -s <span style="color:#d14">&#39;https://rickandmortyapi.com/api/character&#39;</span> | jq <span style="color:#d14">&#39;.results[0]&#39;</span>
curl -s <span style="color:#d14">&#39;https://rickandmortyapi.com/api/location&#39;</span> | jq <span style="color:#d14">&#39;.results[0].residents&#39;</span>
curl -s <span style="color:#d14">&#39;https://rickandmortyapi.com/api/episode&#39;</span> | jq <span style="color:#d14">&#39;.results[0].characters&#39;</span>
curl -s <span style="color:#d14">&#39;https://api.tvmaze.com/shows/216/episodes&#39;</span> | jq <span style="color:#d14">&#39;.[0] | {id, name, season, number, rating}&#39;</span>
</code></pre></div><p>The goal here is not to debug Drupal yet. The goal is to answer a simpler
question first: <strong>does the source still look like the migration expects?</strong> If the
remote shape changed, or if an endpoint is slow or unavailable, the YAML is not
the first suspect.</p>
<ul>
<li><strong>Then</strong> inspect <strong>the migration state</strong>:</li>
</ul>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush migrate:status
ddev drush migrate:messages rick_and_migraty_location
ddev drush migrate:messages rick_and_migraty_episode
ddev drush migrate:messages rick_and_migraty_character
</code></pre></div><p><code>migrate:status</code> tells us what Drupal thinks has been imported. The message
tables tell us which rows failed and why. This is where the earlier reset
discussion matters: <strong>Migrate API state is not the same thing as visible
content</strong>.</p>
<p>If the issue is a missing reverse relationship after a partial run, retry the
update passes directly:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush migrate:import rick_and_migraty_episode --update
ddev drush migrate:import rick_and_migraty_location --update
</code></pre></div><p>Those commands are narrow on purpose. They do not re-download avatars, rebuild
fixtures or re-import the full graph. They only ask the already-defined episode
and location migrations to <strong>complete relationships that now have resolvable
character targets</strong>.</p>
<p>If the problem is <strong>Fandom</strong> enrichment, <strong>debug that phase without rerunning the
base imports</strong>:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush migrate:messages rick_and_migraty_character_fandom
ddev drush migrate:messages rick_and_migraty_location_fandom
ddev drush migrate:messages rick_and_migraty_episode_fandom
</code></pre></div><p>and retry only the enrichment migrations:</p>
<div class="highlight"><pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-bash" data-lang="bash">ddev drush migrate:import rick_and_migraty_character_fandom --update --feedback<span style="color:#000;font-weight:bold">=</span><span style="color:#099">50</span>
ddev drush migrate:import rick_and_migraty_location_fandom --update --feedback<span style="color:#000;font-weight:bold">=</span><span style="color:#099">25</span>
ddev drush migrate:import rick_and_migraty_episode_fandom --update --feedback<span style="color:#000;font-weight:bold">=</span><span style="color:#099">25</span>
</code></pre></div><p>This is another reason the enrichment phase runs separately. A <strong>Fandom</strong> parsing
problem should not force us to re-import all canonical content. <strong>The base graph
and the editorial enrichment are different debugging surfaces</strong>.</p>
<p>When a row still refuses to behave, <strong>the checklist is familiar</strong>:</p>
<ul>
<li>Check YAML indentation</li>
<li>Inspect source selectors</li>
<li>Inspect <code>migrate_map_*</code> tables</li>
<li>Inspect <code>migrate_message_*</code> tables</li>
<li>Confirm that dependencies ran in the expected order</li>
<li>Use Xdebug in DDEV when a custom process plugin becomes suspicious</li>
</ul>
<p>For this project, I would add a few extra checks:</p>
<ul>
<li>Confirm <code>RICK_AND_MIGRATY_API_FIXTURE_BASE_PATH</code> when using fixtures</li>
<li>Confirm <code>RICK_AND_MIGRATY_API_MAX_PAGES</code> is not limiting a run unexpectedly</li>
<li>Inspect watchdog entries for <code>rick_and_migraty_migrate</code></li>
<li>Check whether the failed row belongs to the base import, avatar download,
relationship backfill or Fandom enrichment.</li>
</ul>
<p>The boring places are usually where <strong>the useful clues live</strong>: a selector, a map
table, a missing dependency, an empty string, or a value that looked like an
integer until it met real source data.</p>
<h2 id="12-things-worth-highlighting-deciding">12. Things worth highlighting (deciding)</h2>
<p>This project has a handful of decisions that should stay documented because
<strong>they shape the implementation more than any individual YAML file</strong>. If someone
returns to the project later, these are the decisions I would want them to see
before touching the migrations.</p>
<ul>
<li>
<p>First, <strong>circular relationships are expected</strong>. Characters point to locations and
episodes, while locations and episodes point back to characters. The migration
does not try to solve that in a single heroic pass. It imports the base graph
first, then completes reverse references with update passes.</p>
</li>
<li>
<p>Second, <strong>pagination is part of the source contract</strong>. If a migration only
imports the first API page, it is not a migration. It is a demo that got tired.
The source plugin owns pagination because every canonical resource needs the same
behavior, and because the YAML should not care how many pages the remote API has
today.</p>
</li>
<li>
<p>Third, <strong>external IDs are not optional</strong>. The source <code>id</code> is stored as <code>api_id</code>
and used consistently as the stable bridge between remote records, map tables,
fixtures, updates and debugging. Drupal entity IDs are local implementation
details; the API ID is the identity we can explain.</p>
</li>
<li>
<p>Fourth, <strong>images deserve their own phase</strong>. Character avatars are downloaded
through a separate migration and can be processed in batches. The entity still
preserves the remote <code>image_url</code>, but local file handling is operationally
different from field mapping and should be treated that way.</p>
</li>
<li>
<p>Fifth, <strong>public APIs should be treated politely</strong>. Caching, fixtures, page limits
and request delays are not decorative settings. The <code>429 Too Many Requests</code>
responses we saw during development made that very clear: politeness is not only
etiquette, it is part of making the migration repeatable.</p>
</li>
<li>
<p>Finally, <strong>enrichment sources are not canonical sources</strong>. TVmaze contributes
ratings. Fandom contributes descriptions. Neither of them gets to redefine the
base Rick and Morty dataset.</p>
</li>
</ul>
<p>That list is not just documentation. <strong>It is also a design guardrail</strong>. When the
project grows, every new migration should be checked against those decisions:</p>
<ul>
<li><strong>Does it preserve source authority?</strong></li>
<li><strong>Does it keep relationships explicit?</strong></li>
<li><strong>Does it remain repeatable?</strong></li>
<li><strong>Does it avoid turning remote services into a development
tax?</strong></li>
</ul>
<h2 id="13-what-i-learned-reflecting">13. What I learned (reflecting)</h2>
<p>If I had to extract a few reusable principles from the <strong>Rick and Migraty</strong> experience, they would
be these.</p>
<p><strong>Build a source profile before writing too much YAML.</strong> Know the pages, fields,
empty values, relationship shapes and awkward cases before deciding that the
Drupal model is obvious. A source profile does not need to be formal, but it
should answer practical questions: where are IDs, where are relationships, where
are dates, where are files, where are empty values, and which fields are only
display labels.</p>
<p><strong>Preserve the graph when the graph is the value.</strong> In this project, characters,
locations and episodes are not just three lists. Their relationships are the
interesting part, so the destination model should make those relationships
queryable. If the destination model flattens the graph into text, the migration
may succeed technically while losing the point of the dataset.</p>
<p><strong>Keep transformations narrow.</strong> One plugin extracts IDs from URLs. One plugin
extracts season or episode number. One source plugin owns pagination. Small
pieces are easier to explain, test and replace. They also make the YAML more
readable because each custom plugin name carries meaning.</p>
<p><strong>Sequence imports deliberately.</strong> Locations and episodes need to exist before
characters can reference them. Characters need to exist before reverse
relationships can be completed. That order is not an implementation detail; it
is part of the model. If the order feels awkward, the relationship graph is
probably trying to tell us something.</p>
<p><strong>Treat operations as part of the design.</strong> Resets, fixtures, avatar batches, update
passes and browser traces are not nice extras. They are what make the migration
usable after the first successful import.</p>
<p>And perhaps most importantly: <strong>do not let secondary data sources blur the
contract</strong>. A migration can merge data from several places without becoming vague
about which source owns which truth. In this case, Rick and Morty API owns the
canonical graph, TVmaze enriches episode ratings, and Fandom enriches narrative
context.</p>
<p><strong>That is the part I would reuse in another project even if the subject matter were
completely different.</strong> The names would change, the APIs would change, the fields
would change, but the pattern would remain:</p>
<ol>
<li><strong>Choose the canonical source</strong></li>
<li><strong>Model the graph</strong></li>
<li><strong>Isolate enrichments</strong></li>
<li><strong>Make reruns boring</strong>.</li>
</ol>
<h2 id="14-next-steps-planning">14. Next steps (planning)</h2>
<p>This project brought together pagination, relationships, enrichment, avatars,
fixtures and repeatable execution in a working Drupal migration. <strong>The next steps
build on that implementation</strong>:</p>
<ul>
<li><strong>Keep the article and MkDocs documentation aligned with the code</strong> as the
project evolves, updating examples and screenshots when behavior changes.</li>
<li><strong>Explore avatar migration in a follow-up article</strong>, because remote file
handling, batching and recovery deserve a closer look.</li>
<li><strong>Review the coverage of the canonical dataset</strong> and decide how to handle
missing episodes and characters while preserving stable external IDs and
explicit source responsibilities.</li>
</ul>
<p>There is also a possible follow-up article hiding in plain sight: <strong>the browser
control panel.</strong> It connects migration execution, trace state, status polling,
Drush commands and user-facing feedback. That is not strictly part of the
Migrate API, but it is very much part of making a migration usable by humans.</p>
<p>For now, though, <strong>the important thing is that the design is visible.</strong> We have a
canonical source, a destination model, a phased import order, custom plugins for
repeated problems, enrichment passes, operational helpers and a debugging
routine. <strong>That is enough for this sixth stop in the migration series.</strong></p>
<p>Trying to put every implementation detail into this post would turn it into a
manual. The useful version is narrower: <strong>enough code to make the design concrete,
enough explanation to understand the tradeoffs, and enough operational detail to
avoid pretending that migrations end when the YAML validates</strong>.</p>
<p><strong>Documentation note:</strong> The structure and intent of this article follow the
documentation principles described by <a href="https://diataxis.fr/">Diátaxis</a>, using
the distinction between tutorials, how-to guides, explanations and reference
material to keep practical instructions and conceptual context in their proper
place.</p>
<p><strong>Language note:</strong> AI assistance was used for grammatical review and for
polishing English expressions. The technical decisions, implementation details
and project experience described here remain my own.</p>
<h2 id="15-wq">15. :wq!</h2>
<h5 id="recommended-song-fabrizio-de-andré---ballata-dellamore-cieco">Recommended song: Fabrizio De André - Ballata dell&rsquo;amore cieco</h5>


<div class="embed-responsive embed-responsive-16by9">
  <iframe
    class="embed-responsive-item"
    src="https://www.youtube-nocookie.com/embed/aX8KPb5yFzI"
    title="YouTube video"
    loading="lazy"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    allowfullscreen></iframe>
</div>

]]></content:encoded></item></channel></rss>