How to Build Magento Data Migration Plugin

In this tutorial, you'll learn how to build a plugin that migrates data, such as products, from Magento to Medusa.

Magento is known for its customization capabilities. However, its monolithic architecture imposes limitations on business requirements, often forcing development teams to implement hacky workarounds. Over time, these customizations become challenging to maintain, especially as the business scales, leading to increased technical debt and slower feature delivery.

Medusa's modular architecture allows you to build a custom digital commerce platform that meets your business requirements without the limitations of a monolithic system. By migrating from Magento to Medusa, you can take advantage of Medusa's modern technology stack to build a scalable and flexible commerce platform that grows with your business.

By following this tutorial, you'll create a Medusa plugin that migrates data from a Magento server to a Medusa application in minimal time. You can re-use this plugin across multiple Medusa applications, allowing you to adopt Medusa across your projects.

Summary#

This tutorial will teach you how to:

  • Install and set up a Medusa application project.
  • Install and set up a Medusa plugin.
  • Implement a Magento Module in the plugin to connect to Magento's APIs and retrieve products.
    • This guide will only focus on migrating product data from Magento to Medusa. You can extend the implementation to migrate other data, such as customers, orders, and more.
  • Trigger data migration from Magento to Medusa in a scheduled job.

You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.

Diagram showcasing the flow of migrating data from Magento to Medusa

Example Repository
Find the full code of the guide in this repository. The repository also includes additional features, such as triggering migrations from the Medusa Admin dashboard.

Step 1: Install a Medusa Application#

You'll first install a Medusa application that exposes core commerce features through REST APIs. You'll later install the Magento plugin in this application to test it out.

Start by installing the Medusa application on your machine with the following command:

Terminal
npx create-medusa-app@latest

You'll be asked for the project's name. You can also optionally choose to install the Next.js starter storefront.

Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the {project-name}-storefront name.

Why is the storefront installed separately?The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Refer to the Medusa Architecture documentation to learn more.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard.

Ran into Errors?Check out the troubleshooting guides for help.

Step 2: Install a Medusa Plugin Project#

A plugin is a package of reusable Medusa customizations that you can install in any Medusa application. You can add in the plugin API Routes, Workflows, and other customizations, as you'll see in this guide. Afterward, you can test it out locally in a Medusa application, then publish it to npm to install and use it in any Medusa application.

NoteRefer to the Plugins documentation to learn more about plugins.

A Medusa plugin is set up in a different project, giving you the flexibility in building and publishing it, while providing you with the tools to test it out locally in a Medusa application.

To create a new Medusa plugin project, run the following command in a directory different than that of the Medusa application:

Where medusa-plugin-magento is the name of the plugin's directory and the name set in the plugin's package.json. So, if you wish to publish it to NPM later under a different name, you can change it here in the command or later in package.json.

Once the installation process is done, a new directory named medusa-plugin-magento will be created with the plugin project files.

Directory structure of a plugin project


Step 3: Set up Plugin in Medusa Application#

Before you start your development, you'll set up the plugin in the Medusa application you installed in the first step. This will allow you to test the plugin during your development process.

In the plugin's directory, run the following command to publish the plugin to the local package registry:

Plugin project
npx medusa plugin:publish

This command uses Yalc under the hood to publish the plugin to a local package registry. The plugin is published locally under the name you specified in package.json.

Next, you'll install the plugin in the Medusa application from the local registry.

NoteIf you've installed your Medusa project before v2.3.1, you must install yalc as a development dependency first.

Run the following command in the Medusa application's directory to install the plugin:

Medusa application
npx medusa plugin:add medusa-plugin-magento

This command installs the plugin in the Medusa application from the local package registry.

Next, register the plugin in the medusa-config.ts file of the Medusa application:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  plugins: [4    {5      resolve: "medusa-plugin-magento",6      options: {7        // TODO add options8      },9    },10  ],11})

You add the plugin to the array of plugins. Later, you'll pass options useful to retrieve data from Magento.

Finally, to ensure your plugin's changes are constantly published to the local registry, simplifying your testing process, keep the following command running in the plugin project during development:

Plugin project
npx medusa plugin:develop

Step 4: Implement Magento Module#

To connect to external applications in Medusa, you create a custom module. A module is a reusable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup.

In this step, you'll create a Magento Module in the Magento plugin that connects to a Magento server's REST APIs and retrieves data, such as products.

NoteRefer to the Modules documentation to learn more about modules.

Create Module Directory#

A module is created under the src/modules directory of your plugin. So, create the directory src/modules/magento.

Diagram showcasing the module directory to create

Create Module's Service#

You define a module's functionalities in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to external systems or the database, which is useful if your module defines tables in the database.

In this section, you'll create the Magento Module's service that connects to Magento's REST APIs and retrieves data.

Start by creating the file src/modules/magento/service.ts in the plugin with the following content:

Diagram showcasing the service file to create

src/modules/magento/service.ts
1type Options = {2  baseUrl: string3  storeCode?: string4  username: string5  password: string6  migrationOptions?: {7    imageBaseUrl?: string8  }9}10
11export default class MagentoModuleService {12  private options: Options13
14  constructor({}, options: Options) {15    this.options = {16      ...options,17      storeCode: options.storeCode || "default",18    }19  }20}

You create a MagentoModuleService that has an options property to store the module's options. These options include:

  • baseUrl: The base URL of the Magento server.
  • storeCode: The store code of the Magento store, which is default by default.
  • username: The username of a Magento admin user to authenticate with the Magento server.
  • password: The password of the Magento admin user.
  • migrationOptions: Additional options useful for migrating data, such as the base URL to use for product images.

The service's constructor accepts as a first parameter the Module Container, which allows you to access resources available for the module. As a second parameter, it accepts the module's options.

Add Authentication Logic#

To authenticate with the Magento server, you'll add a method to the service that retrieves an access token from Magento using the username and password in the options. This access token is used in subsequent requests to the Magento server.

First, add the following property to the MagentoModuleService class:

src/modules/magento/service.ts
1export default class MagentoModuleService {2  private accessToken: {3    token: string4    expiresAt: Date5  }6  // ...7}

You add an accessToken property to store the access token and its expiration date. The access token Magento returns expires after four hours, so you store the expiration date to know when to refresh the token.

Next, add the following authenticate method to the MagentoModuleService class:

src/modules/magento/service.ts
1import { MedusaError } from "@medusajs/framework/utils"2
3export default class MagentoModuleService {4  // ...5  async authenticate() {6    const response = await fetch(`${this.options.baseUrl}/rest/${this.options.storeCode}/V1/integration/admin/token`, {7      method: "POST",8      headers: {9        "Content-Type": "application/json",10      },11      body: JSON.stringify({ username: this.options.username, password: this.options.password }),12    })13
14    const token = await response.text()15
16    if (!response.ok) {17      throw new MedusaError(MedusaError.Types.UNAUTHORIZED, `Failed to authenticate with Magento: ${token}`)18    }19
20    this.accessToken = {21      token: token.replaceAll("\"", ""),22      expiresAt: new Date(Date.now() + 4 * 60 * 60 * 1000), // 4 hours in milliseconds23    }24  }25}

You create an authenticate method that sends a POST request to the Magento server's /rest/{storeCode}/V1/integration/admin/token endpoint, passing the username and password in the request body.

If the request is successful, you store the access token and its expiration date in the accessToken property. If the request fails, you throw a MedusaError with the error message returned by Magento.

Lastly, add an isAccessTokenExpired method that checks if the access token has expired:

src/modules/magento/service.ts
1export default class MagentoModuleService {2  // ...3  async isAccessTokenExpired(): Promise<boolean> {4    return !this.accessToken || this.accessToken.expiresAt < new Date()5  }6}

In the isAccessTokenExpired method, you return a boolean indicating whether the access token has expired. You'll use this in later methods to check if you need to refresh the access token.

Retrieve Products from Magento#

Next, you'll add a method that retrieves products from Magento. Due to limitations in Magento's API that makes it difficult to differentiate between simple products that don't belong to a configurable product and those that do, you'll only retrieve configurable products and their children. You'll also retrieve the configurable attributes of the product, such as color and size.

First, you'll add some types to represent a Magento product and its attributes. Create the file src/modules/magento/types.ts in the plugin with the following content:

Diagram showcasing the types file to create

src/modules/magento/types.ts
1export type MagentoProduct = {2  id: number3  sku: string4  name: string5  price: number6  status: number7  // not handling other types8  type_id: "simple" | "configurable"9  created_at: string10  updated_at: string11  extension_attributes: {12    category_links: {13      category_id: string14    }[]15    configurable_product_links?: number[] 16    configurable_product_options?: {17      id: number18      attribute_id: string19      label: string20      position: number21      values: {22        value_index: number23      }[]24    }[]25  }26  media_gallery_entries: {27    id: number28    media_type: string29    label: string30    position: number31    disabled: boolean32    types: string[]33    file: string34  }[]35  custom_attributes: {36    attribute_code: string37    value: string38  }[]39  // added by module40  children?: MagentoProduct[]41}42
43export type MagentoAttribute = {44  attribute_code: string45  attribute_id: number46  default_frontend_label: string47  options: {48    label: string49    value: string50  }[]51}52
53export type MagentoPagination = {54  search_criteria: {55    filter_groups: [],56    page_size: number57    current_page: number58  }59  total_count: number60}61
62export type MagentoPaginatedResponse<TData> = {63  items: TData[]64} & MagentoPagination

You define the following types:

  • MagentoProduct: Represents a product in Magento.
  • MagentoAttribute: Represents an attribute in Magento.
  • MagentoPagination: Represents the pagination information returned by Magento's API.
  • MagentoPaginatedResponse: Represents a paginated response from Magento's API for a specific item type, such as products.

Next, add the getProducts method to the MagentoModuleService class:

src/modules/magento/service.ts
1export default class MagentoModuleService {2  // ...3  async getProducts(options?: {4    currentPage?: number5    pageSize?: number6  }): Promise<{7    products: MagentoProduct[]8    attributes: MagentoAttribute[]9    pagination: MagentoPagination10  }> {11    const { currentPage = 1, pageSize = 100 } = options || {}12    const getAccessToken = await this.isAccessTokenExpired()13    if (getAccessToken) {14      await this.authenticate()15    }16
17    // TODO prepare query params18  }19}

The getProducts method receives an optional options object with the currentPage and pageSize properties. So far, you check if the access token has expired and, if so, retrieve a new one using the authenticate method.

Next, you'll prepare the query parameters to pass in the request that retrieves products. Replace the TODO with the following:

src/modules/magento/service.ts
1const searchQuery = new URLSearchParams()2// pass pagination parameters3searchQuery.append(4  "searchCriteria[currentPage]", 5  currentPage?.toString() || "1"6)7searchQuery.append(8  "searchCriteria[pageSize]", 9  pageSize?.toString() || "100"10)11
12// retrieve only configurable products13searchQuery.append(14  "searchCriteria[filter_groups][1][filters][0][field]", 15  "type_id"16)17searchQuery.append(18  "searchCriteria[filter_groups][1][filters][0][value]", 19  "configurable"20)21searchQuery.append(22  "searchCriteria[filter_groups][1][filters][0][condition_type]", 23  "in"24)25
26// TODO send request to retrieve products

You create a searchQuery object to store the query parameters to pass in the request. Then, you add the pagination parameters and the filter to retrieve only configurable products.

Next, you'll send the request to retrieve products from Magento. Replace the TODO with the following:

src/modules/magento/service.ts
1const { items: products, ...pagination }: MagentoPaginatedResponse<MagentoProduct> = await fetch(2  `${this.options.baseUrl}/rest/${this.options.storeCode}/V1/products?${searchQuery}`, 3  {4    headers: {5      "Authorization": `Bearer ${this.accessToken.token}`,6    },7  }8).then((res) => res.json())9.catch((err) => {10  console.log(err)11  throw new MedusaError(12    MedusaError.Types.INVALID_DATA, 13    `Failed to get products from Magento: ${err.message}`14  )15})16
17// TODO prepare products

You send a GET request to the Magento server's /rest/{storeCode}/V1/products endpoint, passing the query parameters in the URL. You also pass the access token in the Authorization header.

Next, you'll prepare the retrieved products by retrieving their children, configurable attributes, and modifying their image URLs. Replace the TODO with the following:

src/modules/magento/service.ts
1const attributeIds: string[] = []2
3await promiseAll(4  products.map(async (product) => {5    // retrieve its children6    product.children = await fetch(7      `${this.options.baseUrl}/rest/${this.options.storeCode}/V1/configurable-products/${product.sku}/children`,8      {9        headers: {10          "Authorization": `Bearer ${this.accessToken.token}`,11        },12      }13    ).then((res) => res.json())14    .catch((err) => {15      throw new MedusaError(16        MedusaError.Types.INVALID_DATA, 17        `Failed to get product children from Magento: ${err.message}`18      )19    })20
21    product.media_gallery_entries = product.media_gallery_entries.map(22      (entry) => ({23        ...entry,24        file: `${this.options.migrationOptions?.imageBaseUrl}${entry.file}`,25      }26    ))27
28    attributeIds.push(...(29      product.extension_attributes.configurable_product_options?.map(30        (option) => option.attribute_id) || []31      )32    )33  })34)35
36// TODO retrieve attributes

You loop over the retrieved products and retrieve their children using the /rest/{storeCode}/V1/configurable-products/{sku}/children endpoint. You also modify the image URLs to use the base URL in the migration options, if provided.

In addition, you store the IDs of the configurable products' attributes in the attributeIds array. You'll add a method that retrieves these attributes.

Add the new method getAttributes to the MagentoModuleService class:

src/modules/magento/service.ts
1export default class MagentoModuleService {2  // ...3  async getAttributes({4    ids,5  }: {6    ids: string[]7  }): Promise<MagentoAttribute[]> {8    const getAccessToken = await this.isAccessTokenExpired()9    if (getAccessToken) {10      await this.authenticate()11    }12
13    // filter by attribute IDs14    const searchQuery = new URLSearchParams()15    searchQuery.append(16      "searchCriteria[filter_groups][0][filters][0][field]", 17      "attribute_id"18    )19    searchQuery.append(20      "searchCriteria[filter_groups][0][filters][0][value]", 21      ids.join(",")22    )23    searchQuery.append(24      "searchCriteria[filter_groups][0][filters][0][condition_type]", 25      "in"26    )27
28    const { 29      items: attributes,30    }: MagentoPaginatedResponse<MagentoAttribute> = await fetch(31      `${this.options.baseUrl}/rest/${this.options.storeCode}/V1/products/attributes?${searchQuery}`, 32      {33        headers: {34          "Authorization": `Bearer ${this.accessToken.token}`,35        },36      }37    ).then((res) => res.json())38    .catch((err) => {39      throw new MedusaError(40        MedusaError.Types.INVALID_DATA, 41        `Failed to get attributes from Magento: ${err.message}`42      )43    })44
45    return attributes46  }47}

The getAttributes method receives an object with the ids property, which is an array of attribute IDs. You check if the access token has expired and, if so, retrieve a new one using the authenticate method.

Next, you prepare the query parameters to pass in the request to retrieve attributes. You send a GET request to the Magento server's /rest/{storeCode}/V1/products/attributes endpoint, passing the query parameters in the URL. You also pass the access token in the Authorization header.

Finally, you return the retrieved attributes.

Now, go back to the getProducts method and replace the TODO with the following:

src/modules/magento/service.ts
1const attributes = await this.getAttributes({ ids: attributeIds })2    3return { products, attributes, pagination }

You retrieve the configurable products' attributes using the getAttributes method and return the products, attributes, and pagination information.

You'll use this method in a later step to retrieve products from Magento.

Export Module Definition#

The final piece to a module is its definition, which you export in an index.ts file at its root directory. This definition tells Medusa the name of the module and its service.

So, create the file src/modules/magento/index.ts with the following content:

Diagram showcasing the module definition file to create

src/modules/magento/index.ts
1import { Module } from "@medusajs/framework/utils"2import MagentoModuleService from "./service"3
4export const MAGENTO_MODULE = "magento"5
6export default Module(MAGENTO_MODULE, {7  service: MagentoModuleService,8})

You use the Module function from the Modules SDK to create the module's definition. It accepts two parameters:

  1. The module's name, which is magento.
  2. An object with a required property service indicating the module's service.

You'll later use the module's service to retrieve products from Magento.

Pass Options to Plugin#

As mentioned earlier when you registered the plugin in the Medusa Application's medusa-config.ts file, you can pass options to the plugin. These options are then passed to the modules in the plugin.

So, add the following options to the plugin's registration in the medusa-config.ts file of the Medusa application:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  plugins: [4    {5      resolve: "medusa-plugin-magento",6      options: {7        baseUrl: process.env.MAGENTO_BASE_URL,8        username: process.env.MAGENTO_USERNAME,9        password: process.env.MAGENTO_PASSWORD,10        migrationOptions: {11          imageBaseUrl: process.env.MAGENTO_IMAGE_BASE_URL,12        },13      },14    },15  ],16})

You pass the options that you defined in the MagentoModuleService. Make sure to also set their environment variables in the .env file:

Terminal
MAGENTO_BASE_URL=https://magento.example.comMAGENTO_USERNAME=adminMAGENTO_PASSWORD=passwordMAGENTO_IMAGE_BASE_URL=https://magento.example.com/pub/media/catalog/product

Where:

  • MAGENTO_BASE_URL: The base URL of the Magento server. It can also be a local URL, such as http://localhost:8080.
  • MAGENTO_USERNAME: The username of a Magento admin user to authenticate with the Magento server.
  • MAGENTO_PASSWORD: The password of the Magento admin user.
  • MAGENTO_IMAGE_BASE_URL: The base URL to use for product images. Magento stores product images in the pub/media/catalog/product directory, so you can reference them directly or use a CDN URL. If the URLs of product images in the Medusa server already have a different base URL, you can omit this option.
TipMedusa supports integrating third-party services, such as S3, in a File Module Provider. Refer to the File Module documentation to find other module providers and how to create a custom provider.

You can now use the Magento Module to migrate data, which you'll do in the next steps.


Step 5: Build Product Migration Workflow#

In this section, you'll add the feature to migrate products from Magento to Medusa. To implement this feature, you'll use a workflow.

A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it's a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. Then, you execute the workflow from other customizations, such as in an API route or a scheduled job.

By implementing the migration feature in a workflow, you ensure that the data remains consistent and that the migration process can be rolled back if an error occurs.

NoteRefer to the Workflows documentation to learn more about workflows.

Workflow Steps#

The workflow you'll create will have the following steps:

You only need to implement the getMagentoProductsStep step, which retrieves the products from Magento. The other steps and workflows are provided by Medusa's @medusajs/medusa/core-flows package.

getMagentoProductsStep#

The first step of the workflow retrieves and returns the products from Magento.

In your plugin, create the file src/workflows/steps/get-magento-products.ts with the following content:

Diagram showcasing the get-magento-products file to create

src/workflows/steps/get-magento-products.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { MAGENTO_MODULE } from "../../modules/magento"3import MagentoModuleService from "../../modules/magento/service"4
5type GetMagentoProductsInput = {6  currentPage: number7  pageSize: number8}9
10export const getMagentoProductsStep = createStep(11  "get-magento-products",12  async ({ currentPage, pageSize }: GetMagentoProductsInput, { container }) => {13    const magentoModuleService: MagentoModuleService = 14      container.resolve(MAGENTO_MODULE)15
16    const response = await magentoModuleService.getProducts({17      currentPage,18      pageSize,19    })20
21    return new StepResponse(response)22  }23)

You create a step using createStep from the Workflows SDK. It accepts two parameters:

  1. The step's name, which is get-magento-products.
  2. An async function that executes the step's logic. The function receives two parameters:
    • The input data for the step, which in this case is the pagination parameters.
    • An object holding the workflow's context, including the Medusa Container that allows you to resolve framework and commerce tools.

In the step function, you resolve the Magento Module's service from the container, then use its getProducts method to retrieve the products from Magento.

Steps that return data must return them in a StepResponse instance. The StepResponse constructor accepts as a parameter the data to return.

Create migrateProductsFromMagentoWorkflow#

You'll now create the workflow that migrates products from Magento using the step you created and steps from Medusa's @medusajs/medusa/core-flows package.

In your plugin, create the file src/workflows/migrate-products-from-magento.ts with the following content:

Diagram showcasing the migrate-products-from-magento file to create

src/workflows/migrate-products-from-magento.ts
1import { 2  createWorkflow, transform, WorkflowResponse,3} from "@medusajs/framework/workflows-sdk"4import { 5  CreateProductWorkflowInputDTO, UpsertProductDTO,6} from "@medusajs/framework/types"7import { 8  createProductsWorkflow, 9  updateProductsWorkflow, 10  useQueryGraphStep,11} from "@medusajs/medusa/core-flows"12import { getMagentoProductsStep } from "./steps/get-magento-products"13
14type MigrateProductsFromMagentoWorkflowInput = {15  currentPage: number16  pageSize: number17}18
19export const migrateProductsFromMagentoWorkflowId = 20  "migrate-products-from-magento"21
22export const migrateProductsFromMagentoWorkflow = createWorkflow(23  {24    name: migrateProductsFromMagentoWorkflowId,25    retentionTime: 10000,26    store: true,27  },28  (input: MigrateProductsFromMagentoWorkflowInput) => {29    const { pagination, products, attributes } = getMagentoProductsStep(30      input31    )32    // TODO prepare data to create and update products33  }34)

You create a workflow using createWorkflow from the Workflows SDK. It accepts two parameters:

  1. An object with the workflow's configuration, including the name and whether to store the workflow's executions. You enable storing the workflow execution so that you can view it later in the Medusa Admin dashboard.
  2. A worflow constructor function, which holds the workflow's implementation. The function receives the input data for the workflow, which is the pagination parameters.

In the workflow constructor function, you use the getMagentoProductsStep step to retrieve the products from Magento, passing it the pagination parameters from the workflow's input.

Next, you'll retrieve the Medusa store details and shipping profiles. These are necessary to prepare the data of the products to create or update.

Replace the TODO in the workflow function with the following:

src/workflows/migrate-products-from-magento.ts
1const { data: stores } = useQueryGraphStep({2  entity: "store",3  fields: ["supported_currencies.*", "default_sales_channel_id"],4  pagination: {5    take: 1,6    skip: 0,7  },8})9
10const { data: shippingProfiles } = useQueryGraphStep({11  entity: "shipping_profile",12  fields: ["id"],13  pagination: {14    take: 1,15    skip: 0,16  },17}).config({ name: "get-shipping-profiles" })18
19// TODO retrieve existing products

You use the useQueryGraphStep step to retrieve the store details and shipping profiles. useQueryGraphStep is a Medusa step that wraps Query, allowing you to use it in a workflow. Query is a tool that retrieves data across modules.

Whe retrieving the store details, you specifically retrieve its supported currencies and default sales channel ID. You'll associate the products with the store's default sales channel, and set their variant prices in the supported currencies. You'll also associate the products with a shipping profile.

Next, you'll retrieve products that were previously migrated from Magento to determine which products to create or update. Replace the TODO with the following:

src/workflows/migrate-products-from-magento.ts
1const externalIdFilters = transform({2  products,3}, (data) => {4  return data.products.map((product) => product.id.toString())5})6
7const { data: existingProducts } = useQueryGraphStep({8  entity: "product",9  fields: ["id", "external_id", "variants.id", "variants.metadata"],10  filters: {11    external_id: externalIdFilters,12  },13}).config({ name: "get-existing-products" })14
15// TODO prepare products to create or update

Since the Medusa application creates an internal representation of the workflow's constructor function, you can't manipulate data directly, as variables have no value while creating the internal representation.

NoteRefer to the Workflows documentation to learn more about the workflow constructor function's constraints.

Instead, you can manipulate data in a workflow's constructor function using transform from the Workflows SDK. transform is a function that accepts two parameters:

  • The data to transform, which in this case is the Magento products.
  • A function that transforms the data. The function receives the data passed in the first parameter and returns the transformed data.

In the transformation function, you return the IDs of the Magento products. Then, you use the useQueryGraphStep to retrieve products in the Medusa application that have an external_id property matching the IDs of the Magento products. You'll use this property to store the IDs of the products in Magento.

Next, you'll prepare the data to create and update the products. Replace the TODO in the workflow function with the following:

src/workflows/migrate-products-from-magento.ts
1const { 2  productsToCreate,3  productsToUpdate,4} = transform({5  products,6  attributes,7  stores,8  shippingProfiles,9  existingProducts,10}, (data) => {11  const productsToCreate = new Map<string, CreateProductWorkflowInputDTO>()12  const productsToUpdate = new Map<string, UpsertProductDTO>()13
14  data.products.forEach((magentoProduct) => {15    const productData: CreateProductWorkflowInputDTO | UpsertProductDTO = {16      title: magentoProduct.name,17      description: magentoProduct.custom_attributes.find(18        (attr) => attr.attribute_code === "description"19      )?.value,20      status: magentoProduct.status === 1 ? "published" : "draft",21      handle: magentoProduct.custom_attributes.find(22        (attr) => attr.attribute_code === "url_key"23      )?.value,24      external_id: magentoProduct.id.toString(),25      thumbnail: magentoProduct.media_gallery_entries.find(26        (entry) => entry.types.includes("thumbnail")27      )?.file,28      sales_channels: [{29        id: data.stores[0].default_sales_channel_id,30      }],31      shipping_profile_id: data.shippingProfiles[0].id,32    }33    const existingProduct = data.existingProducts.find((p) => p.external_id === productData.external_id)34
35    if (existingProduct) {36      productData.id = existingProduct.id37    }38
39    productData.options = magentoProduct.extension_attributes.configurable_product_options?.map((option) => {40      const attribute = data.attributes.find((attr) => attr.attribute_id === parseInt(option.attribute_id))41      return {42        title: option.label,43        values: attribute?.options.filter((opt) => {44          return option.values.find((v) => v.value_index === parseInt(opt.value))45        }).map((opt) => opt.label) || [],46      }47    }) || []48
49    productData.variants = magentoProduct.children?.map((child) => {50      const childOptions: Record<string, string> = {}51
52      child.custom_attributes.forEach((attr) => {53        const attrData = data.attributes.find((a) => a.attribute_code === attr.attribute_code)54        if (!attrData) {55          return56        }57
58        childOptions[attrData.default_frontend_label] = attrData.options.find((opt) => opt.value === attr.value)?.label || ""59      })60
61      const variantExternalId = child.id.toString()62      const existingVariant = existingProduct.variants.find((v) => v.metadata.external_id === variantExternalId)63
64      return {65        title: child.name,66        sku: child.sku,67        options: childOptions,68        prices: data.stores[0].supported_currencies.map(({ currency_code }) => {69          return {70            amount: child.price,71            currency_code,72          }73        }),74        metadata: {75          external_id: variantExternalId,76        },77        id: existingVariant?.id,78      }79    })80
81    productData.images = magentoProduct.media_gallery_entries.filter((entry) => !entry.types.includes("thumbnail")).map((entry) => {82      return {83        url: entry.file,84        metadata: {85          external_id: entry.id.toString(),86        },87      }88    })89
90    if (productData.id) {91      productsToUpdate.set(existingProduct.id, productData)92    } else {93      productsToCreate.set(productData.external_id!, productData)94    }95  })96
97  return {98    productsToCreate: Array.from(productsToCreate.values()),99    productsToUpdate: Array.from(productsToUpdate.values()),100  }101})102
103// TODO create and update products

You use transform again to prepare the data to create and update the products in the Medusa application. For each Magento product, you map its equivalent Medusa product's data:

  • You set the product's general details, such as the title, description, status, handle, external ID, and thumbnail using the Magento product's data and custom attributes.
  • You associate the product with the default sales channel and shipping profile retrieved previously.
  • You map the Magento product's configurable product options to Medusa product options. In Medusa, a product's option has a label, such as "Color", and values, such as "Red". To map the option values, you use the attributes retrieved from Magento.
  • You map the Magento product's children to Medusa product variants. For the variant options, you pass an object whose keys is the option's label, such as "Color", and values is the option's value, such as "Red". For the prices, you set the variant's price based on the Magento child's price for every supported currency in the Medusa store. Also, you set the Magento child product's ID in the Medusa variant's metadata.external_id property.
  • You map the Magento product's media gallery entries to Medusa product images. You filter out the thumbnail image and set the URL and the Magento image's ID in the Medusa image's metadata.external_id property.

In addition, you use the existing products retrieved in the previous step to determine whether a product should be created or updated. If there's an existing product whose external_id matches the ID of the magento product, you set the existing product's ID in the id property of the product to be updated. You also do the same for its variants.

Finally, you return the products to create and update.

The last steps of the workflow is to create and update the products. Replace the TODO in the workflow function with the following:

src/workflows/migrate-products-from-magento.ts
1createProductsWorkflow.runAsStep({2  input: {3    products: productsToCreate,4  },5})6
7updateProductsWorkflow.runAsStep({8  input: {9    products: productsToUpdate,10  },11})12
13return new WorkflowResponse(pagination)

You use the createProductsWorkflow and updateProductsWorkflow workflows from Medusa's @medusajs/medusa/core-flows package to create and update the products in the Medusa application.

Workflows must return an instance of WorkflowResponse, passing as a parameter the data to return to the workflow's executor. This workflow returns the pagination parameters, allowing you to paginate the product migration process.

You can now use this workflow to migrate products from Magento to Medusa. You'll learn how to use it in the next steps.


Step 6: Schedule Product Migration#

There are many ways to execute tasks asynchronously in Medusa, such as scheduling a job or handling emitted events.

In this guide, you'll learn how to schedule the product migration at a specified interval using a scheduled job. A scheduled job is an asynchronous function that the Medusa application runs at the interval you specify during the Medusa application's runtime.

NoteRefer to the Scheduled Jobs documentation to learn more about scheduled jobs.

To create a scheduled job, in your plugin, create the file src/jobs/migrate-magento.ts with the following content:

Diagram showcasing the migrate-magento file to create

src/jobs/migrate-magento.ts
1import { MedusaContainer } from "@medusajs/framework/types"2import { migrateProductsFromMagentoWorkflow } from "../workflows"3
4export default async function migrateMagentoJob(5  container: MedusaContainer6) {7  const logger = container.resolve("logger")8    logger.info("Migrating products from Magento...")9    10    let currentPage = 011    const pageSize = 10012    let totalCount = 013  14    do {15      currentPage++16  17      const { 18        result: pagination,19      } = await migrateProductsFromMagentoWorkflow(container).run({20        input: {21          currentPage,22          pageSize,23        },24      })25  26      totalCount = pagination.total_count27    } while (currentPage * pageSize < totalCount)28  29    logger.info("Finished migrating products from Magento")30}31
32export const config = {33  name: "migrate-magento-job",34  schedule: "0 0 * * *",35}

A scheduled job file must export:

  • An asynchronous function that executes the job's logic. The function receives the Medusa container as a parameter.
  • An object with the job's configuration, including the name and the schedule. The schedule is a cron job pattern as a string.

In the job function, you resolve the logger from the container to log messages. Then, you paginate the product migration process by running the migrateProductsFromMagentoWorkflow workflow at each page until you've migrated all products. You use the pagination result returned by the workflow to determine whether there are more products to migrate.

Based on the job's configurations, the Medusa application will run the job at midnight every day.

Test it Out#

To test out this scheduled job, first, change the configuration to run the job every minute:

src/jobs/migrate-magento.ts
1export const config = {2  // ...3  schedule: "* * * * *",4}

Then, make sure to run the plugin:develop command in the plugin if you haven't already:

Terminal
npx medusa plugin:develop

This ensures that the plugin's latest changes are reflected in the Medusa application.

Finally, start the Medusa application that the plugin is installed in:

After a minute, you'll see a message in the terminal indicating that the migration started:

Terminal
info: Migrating products from Magento...

Once the migration is done, you'll see the following message:

Terminal
info: Finished migrating products from Magento

To confirm that the products were migrated, open the Medusa Admin dashboard at http://localhost:9000/app and log in. Then, click on Products in the sidebar. You'll see your magento products in the list of products.

Click on products at the sidebar on the right, then view the products in the table in the middle.


Next Steps#

You've now implemented the logic to migrate products from Magento to Medusa. You can re-use the plugin across Medusa applications. You can also expand on the plugin to:

  • Migrate other entities, such as orders, customers, and categories. Migrating other entities follows the same pattern as migrating products, using workflows and scheduled jobs. You only need to format the data to be migrated as needed.
  • Allow triggering migrations from the Medusa Admin dashboard using Admin Customizations. This feature is available in the Example Repository.

If you're new to Medusa, check out the main documentation, where you'll get a more in-depth learning of all the concepts you've used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break