www/apps/resources/app/integrations/guides/magento/page.mdx
import { Card, Prerequisites, WorkflowDiagram } from "docs-ui" import { Github } from "@medusajs/icons"
export const metadata = {
title: 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.
<Prerequisites items={[ { text: "Magento 2.x with admin credentials.", } ]} />
This tutorial will teach you how to:
You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.
<Card title="Example Repository" text="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." href="https://github.com/medusajs/examples/tree/main/migrate-from-magento" icon={Github} />
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.
<Prerequisites items={[ { text: "Node.js v20+", link: "https://nodejs.org/en/download" }, { text: "Git CLI tool", link: "https://git-scm.com/downloads" }, { text: "PostgreSQL", link: "https://www.postgresql.org/download/" } ]} />
Start by installing the Medusa application on your machine with the following command:
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 as a monorepository in a directory with your project's name. The backend is installed in the apps/backend directory. If you chose to install the Next.js Starter Storefront, it'll be in the apps/storefront directory.
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.
<Note title="Ran into Errors?">Check out the troubleshooting guides for help.
</Note> <Note>In this guide, all file paths of backend customizations are relative to the apps/backend directory of your Medusa 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.
<Note>Refer to the Plugins documentation to learn more about plugins.
</Note>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:
npx create-medusa-app@latest medusa-plugin-magento --plugin
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.
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:
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.
<Note>If you've installed your Medusa project before v2.3.1, you must install yalc as a development dependency first.
</Note>Run the following command in the apps/backend directory to install the plugin:
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:
module.exports = defineConfig({
// ...
plugins: [
{
resolve: "medusa-plugin-magento",
options: {
// TODO add options
},
},
],
})
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:
npx medusa plugin:develop
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.
<Note>Refer to the Modules documentation to learn more about modules.
</Note>A module is created under the src/modules directory of your plugin. So, create the directory src/modules/magento.
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:
type Options = {
baseUrl: string
storeCode?: string
username: string
password: string
migrationOptions?: {
imageBaseUrl?: string
}
}
export default class MagentoModuleService {
private options: Options
constructor({}, options: Options) {
this.options = {
...options,
storeCode: options.storeCode || "default",
}
}
}
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.
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:
export default class MagentoModuleService {
private accessToken: {
token: string
expiresAt: Date
}
// ...
}
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:
import { MedusaError } from "@medusajs/framework/utils"
export default class MagentoModuleService {
// ...
async authenticate() {
const response = await fetch(`${this.options.baseUrl}/rest/${this.options.storeCode}/V1/integration/admin/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: this.options.username, password: this.options.password }),
})
const token = await response.text()
if (!response.ok) {
throw new MedusaError(MedusaError.Types.UNAUTHORIZED, `Failed to authenticate with Magento: ${token}`)
}
this.accessToken = {
token: token.replaceAll("\"", ""),
expiresAt: new Date(Date.now() + 4 * 60 * 60 * 1000), // 4 hours in milliseconds
}
}
}
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:
export default class MagentoModuleService {
// ...
async isAccessTokenExpired(): Promise<boolean> {
return !this.accessToken || this.accessToken.expiresAt < new Date()
}
}
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.
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:
export type MagentoProduct = {
id: number
sku: string
name: string
price: number
status: number
// not handling other types
type_id: "simple" | "configurable"
created_at: string
updated_at: string
extension_attributes: {
category_links: {
category_id: string
}[]
configurable_product_links?: number[]
configurable_product_options?: {
id: number
attribute_id: string
label: string
position: number
values: {
value_index: number
}[]
}[]
}
media_gallery_entries: {
id: number
media_type: string
label: string
position: number
disabled: boolean
types: string[]
file: string
}[]
custom_attributes: {
attribute_code: string
value: string
}[]
// added by module
children?: MagentoProduct[]
}
export type MagentoAttribute = {
attribute_code: string
attribute_id: number
default_frontend_label: string
options: {
label: string
value: string
}[]
}
export type MagentoPagination = {
search_criteria: {
filter_groups: [],
page_size: number
current_page: number
}
total_count: number
}
export type MagentoPaginatedResponse<TData> = {
items: TData[]
} & 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:
export default class MagentoModuleService {
// ...
async getProducts(options?: {
currentPage?: number
pageSize?: number
}): Promise<{
products: MagentoProduct[]
attributes: MagentoAttribute[]
pagination: MagentoPagination
}> {
const { currentPage = 1, pageSize = 100 } = options || {}
const getAccessToken = await this.isAccessTokenExpired()
if (getAccessToken) {
await this.authenticate()
}
// TODO prepare query params
}
}
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:
const searchQuery = new URLSearchParams()
// pass pagination parameters
searchQuery.append(
"searchCriteria[currentPage]",
currentPage?.toString() || "1"
)
searchQuery.append(
"searchCriteria[pageSize]",
pageSize?.toString() || "100"
)
// retrieve only configurable products
searchQuery.append(
"searchCriteria[filter_groups][1][filters][0][field]",
"type_id"
)
searchQuery.append(
"searchCriteria[filter_groups][1][filters][0][value]",
"configurable"
)
searchQuery.append(
"searchCriteria[filter_groups][1][filters][0][condition_type]",
"in"
)
// 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:
const { items: products, ...pagination }: MagentoPaginatedResponse<MagentoProduct> = await fetch(
`${this.options.baseUrl}/rest/${this.options.storeCode}/V1/products?${searchQuery}`,
{
headers: {
"Authorization": `Bearer ${this.accessToken.token}`,
},
}
).then((res) => res.json())
.catch((err) => {
console.log(err)
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Failed to get products from Magento: ${err.message}`
)
})
// 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:
const attributeIds: string[] = []
await promiseAll(
products.map(async (product) => {
// retrieve its children
product.children = await fetch(
`${this.options.baseUrl}/rest/${this.options.storeCode}/V1/configurable-products/${product.sku}/children`,
{
headers: {
"Authorization": `Bearer ${this.accessToken.token}`,
},
}
).then((res) => res.json())
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Failed to get product children from Magento: ${err.message}`
)
})
product.media_gallery_entries = product.media_gallery_entries.map(
(entry) => ({
...entry,
file: `${this.options.migrationOptions?.imageBaseUrl}${entry.file}`,
}
))
attributeIds.push(...(
product.extension_attributes.configurable_product_options?.map(
(option) => option.attribute_id) || []
)
)
})
)
// 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:
export default class MagentoModuleService {
// ...
async getAttributes({
ids,
}: {
ids: string[]
}): Promise<MagentoAttribute[]> {
const getAccessToken = await this.isAccessTokenExpired()
if (getAccessToken) {
await this.authenticate()
}
// filter by attribute IDs
const searchQuery = new URLSearchParams()
searchQuery.append(
"searchCriteria[filter_groups][0][filters][0][field]",
"attribute_id"
)
searchQuery.append(
"searchCriteria[filter_groups][0][filters][0][value]",
ids.join(",")
)
searchQuery.append(
"searchCriteria[filter_groups][0][filters][0][condition_type]",
"in"
)
const {
items: attributes,
}: MagentoPaginatedResponse<MagentoAttribute> = await fetch(
`${this.options.baseUrl}/rest/${this.options.storeCode}/V1/products/attributes?${searchQuery}`,
{
headers: {
"Authorization": `Bearer ${this.accessToken.token}`,
},
}
).then((res) => res.json())
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Failed to get attributes from Magento: ${err.message}`
)
})
return attributes
}
}
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:
const attributes = await this.getAttributes({ ids: attributeIds })
return { 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.
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:
import { Module } from "@medusajs/framework/utils"
import MagentoModuleService from "./service"
export const MAGENTO_MODULE = "magento"
export default Module(MAGENTO_MODULE, {
service: MagentoModuleService,
})
You use the Module function from the Modules SDK to create the module's definition. It accepts two parameters:
magento.service indicating the module's service.You'll later use the module's service to retrieve products from Magento.
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:
module.exports = defineConfig({
// ...
plugins: [
{
resolve: "medusa-plugin-magento",
options: {
baseUrl: process.env.MAGENTO_BASE_URL,
username: process.env.MAGENTO_USERNAME,
password: process.env.MAGENTO_PASSWORD,
migrationOptions: {
imageBaseUrl: process.env.MAGENTO_IMAGE_BASE_URL,
},
},
},
],
})
You pass the options that you defined in the MagentoModuleService. Make sure to also set their environment variables in the .env file:
MAGENTO_BASE_URL=https://magento.example.com
MAGENTO_USERNAME=admin
MAGENTO_PASSWORD=password
MAGENTO_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.Medusa 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.
</Note>You can now use the Magento Module to migrate data, which you'll do in the next steps.
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.
<Note>Refer to the Workflows documentation to learn more about workflows.
</Note>The workflow you'll create will have the following steps:
<WorkflowDiagram workflow={{ name: "migrateProductsFromMagentoWorkflow", steps: [ { type: "step", name: "getMagentoProductsStep", description: "Retrieve products from Magento using the Magento Module.", depth: 1, }, { type: "step", name: "useQueryGraphStep", description: "Retrieve Medusa store details, which you'll need when creating the products.", depth: 1, link: "/references/helper-steps/useQueryGraphStep" }, { type: "step", name: "useQueryGraphStep", description: "Retrieve a shipping profile, which you'll associate the created products with.", depth: 1, link: "/references/helper-steps/useQueryGraphStep" }, { type: "step", name: "useQueryGraphStep", description: "Retrieve Magento products that are already in Medusa to update them, instead of creating them.", depth: 1, link: "/references/helper-steps/useQueryGraphStep" }, { type: "workflow", name: "createProductsWorkflow", description: "Create products in the Medusa application.", depth: 1, link: "/references/medusa-workflows/createProductsWorkflow" }, { type: "workflow", name: "updateProductsWorkflow", description: "Update existing products in the Medusa application.", depth: 1, link: "/references/medusa-workflows/updateProductsWorkflow" } ] }} hideLegend />
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.
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:
If you get a type error on resolving the Magento Module, run the Medusa application once with the npm run dev or yarn dev command to generate the necessary type definitions, as explained in the Automatically Generated Types guide.
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { MAGENTO_MODULE } from "../../modules/magento"
import MagentoModuleService from "../../modules/magento/service"
type GetMagentoProductsInput = {
currentPage: number
pageSize: number
}
export const getMagentoProductsStep = createStep(
"get-magento-products",
async ({ currentPage, pageSize }: GetMagentoProductsInput, { container }) => {
const magentoModuleService: MagentoModuleService =
container.resolve(MAGENTO_MODULE)
const response = await magentoModuleService.getProducts({
currentPage,
pageSize,
})
return new StepResponse(response)
}
)
You create a step using createStep from the Workflows SDK. It accepts two parameters:
get-magento-products.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.
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:
import {
createWorkflow, transform, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
CreateProductWorkflowInputDTO, UpsertProductDTO,
} from "@medusajs/framework/types"
import {
createProductsWorkflow,
updateProductsWorkflow,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
import { getMagentoProductsStep } from "./steps/get-magento-products"
type MigrateProductsFromMagentoWorkflowInput = {
currentPage: number
pageSize: number
}
export const migrateProductsFromMagentoWorkflowId =
"migrate-products-from-magento"
export const migrateProductsFromMagentoWorkflow = createWorkflow(
{
name: migrateProductsFromMagentoWorkflowId,
retentionTime: 10000,
store: true,
},
(input: MigrateProductsFromMagentoWorkflowInput) => {
const { pagination, products, attributes } = getMagentoProductsStep(
input
)
// TODO prepare data to create and update products
}
)
You create a workflow using createWorkflow from the Workflows SDK. It accepts two 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:
const { data: stores } = useQueryGraphStep({
entity: "store",
fields: ["supported_currencies.*", "default_sales_channel_id"],
pagination: {
take: 1,
skip: 0,
},
})
const { data: shippingProfiles } = useQueryGraphStep({
entity: "shipping_profile",
fields: ["id"],
pagination: {
take: 1,
skip: 0,
},
}).config({ name: "get-shipping-profiles" })
// 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.
When 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:
const externalIdFilters = transform({
products,
}, (data) => {
return data.products.map((product) => product.id.toString())
})
const { data: existingProducts } = useQueryGraphStep({
entity: "product",
fields: ["id", "external_id", "variants.id", "variants.metadata"],
filters: {
external_id: externalIdFilters,
},
}).config({ name: "get-existing-products" })
// 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.
<Note>Refer to the Workflows documentation to learn more about the workflow constructor function's constraints.
</Note>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:
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:
export const prepareHighlights = [
["2", "productsToCreate", "The products to create."],
["3", "productsToUpdate", "The products to update."],
["4", "transform", "Prepare the product data."],
["11", "productsToCreate", "Create a map to store the products to create."],
["12", "productsToUpdate", "Create a map to store the products to update."],
["17", "description", "Try to retrieve description from the Magento product's custom attributes."],
["21", "handle", "Try to retrieve the product's handle from the Magento product's custom attributes."],
["24", "external_id", "Set the Magento product's ID in the Medusa product's external_id property."],
["25", "thumbnail", "Try to retrieve the product's thumbnail from the Magento product's media gallery entries."],
["28", "sales_channels", "Associate the product with the default sales channel."],
["31", "shipping_profile_id", "Associate the product with a shipping profile."],
["33", "existingProduct", "Find the existing product in Medusa that matches the Magento product's ID."],
["39", "options", "Map the Magento product's configurable product options to Medusa product options."],
["49", "variants", "Map the Magento product's children to Medusa product variants."],
["62", "existingVariant", "Find the existing variant in the existing product, if set."],
["68", "prices", "Set the variant's prices based on the Magento child's price for every supported currency in the Medusa store."],
["77", "id", "Set the ID of the existing variant, if available."],
["81", "images", "Map the Magento product's media gallery entries to Medusa product images."],
["91", "set", "Add to the products to update."],
["93", "set", "Add to the products to create."],
]
const {
productsToCreate,
productsToUpdate,
} = transform({
products,
attributes,
stores,
shippingProfiles,
existingProducts,
}, (data) => {
const productsToCreate = new Map<string, CreateProductWorkflowInputDTO>()
const productsToUpdate = new Map<string, UpsertProductDTO>()
data.products.forEach((magentoProduct) => {
const productData: CreateProductWorkflowInputDTO | UpsertProductDTO = {
title: magentoProduct.name,
description: magentoProduct.custom_attributes.find(
(attr) => attr.attribute_code === "description"
)?.value,
status: magentoProduct.status === 1 ? "published" : "draft",
handle: magentoProduct.custom_attributes.find(
(attr) => attr.attribute_code === "url_key"
)?.value,
external_id: magentoProduct.id.toString(),
thumbnail: magentoProduct.media_gallery_entries.find(
(entry) => entry.types.includes("thumbnail")
)?.file,
sales_channels: [{
id: data.stores[0].default_sales_channel_id,
}],
shipping_profile_id: data.shippingProfiles[0].id,
}
const existingProduct = data.existingProducts.find((p) => p.external_id === productData.external_id)
if (existingProduct) {
productData.id = existingProduct.id
}
productData.options = magentoProduct.extension_attributes.configurable_product_options?.map((option) => {
const attribute = data.attributes.find((attr) => attr.attribute_id === parseInt(option.attribute_id))
return {
title: option.label,
values: attribute?.options.filter((opt) => {
return option.values.find((v) => v.value_index === parseInt(opt.value))
}).map((opt) => opt.label) || [],
}
}) || []
productData.variants = magentoProduct.children?.map((child) => {
const childOptions: Record<string, string> = {}
child.custom_attributes.forEach((attr) => {
const attrData = data.attributes.find((a) => a.attribute_code === attr.attribute_code)
if (!attrData) {
return
}
childOptions[attrData.default_frontend_label] = attrData.options.find((opt) => opt.value === attr.value)?.label || ""
})
const variantExternalId = child.id.toString()
const existingVariant = existingProduct.variants.find((v) => v.metadata.external_id === variantExternalId)
return {
title: child.name,
sku: child.sku,
options: childOptions,
prices: data.stores[0].supported_currencies.map(({ currency_code }) => {
return {
amount: child.price,
currency_code,
}
}),
metadata: {
external_id: variantExternalId,
},
id: existingVariant?.id,
}
})
productData.images = magentoProduct.media_gallery_entries.filter((entry) => !entry.types.includes("thumbnail")).map((entry) => {
return {
url: entry.file,
metadata: {
external_id: entry.id.toString(),
},
}
})
if (productData.id) {
productsToUpdate.set(existingProduct.id, productData as UpsertProductDTO)
} else {
productsToCreate.set(productData.external_id!, productData)
}
})
return {
productsToCreate: Array.from(productsToCreate.values()),
productsToUpdate: Array.from(productsToUpdate.values()),
}
})
// 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:
metadata.external_id property.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:
createProductsWorkflow.runAsStep({
input: {
products: productsToCreate,
},
})
updateProductsWorkflow.runAsStep({
input: {
products: productsToUpdate,
},
})
return 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.
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.
<Note>Refer to the Scheduled Jobs documentation to learn more about scheduled jobs.
</Note>To create a scheduled job, in your plugin, create the file src/jobs/migrate-magento.ts with the following content:
import { MedusaContainer } from "@medusajs/framework/types"
import { migrateProductsFromMagentoWorkflow } from "../workflows"
export default async function migrateMagentoJob(
container: MedusaContainer
) {
const logger = container.resolve("logger")
logger.info("Migrating products from Magento...")
let currentPage = 0
const pageSize = 100
let totalCount = 0
do {
currentPage++
const {
result: pagination,
} = await migrateProductsFromMagentoWorkflow(container).run({
input: {
currentPage,
pageSize,
},
})
totalCount = pagination.total_count
} while (currentPage * pageSize < totalCount)
logger.info("Finished migrating products from Magento")
}
export const config = {
name: "migrate-magento-job",
schedule: "0 0 * * *",
}
A scheduled job file must export:
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.
To test out this scheduled job, first, change the configuration to run the job every minute:
export const config = {
// ...
schedule: "* * * * *",
}
Then, make sure to run the plugin:develop command in the plugin if you haven't already:
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:
npm run dev
After a minute, you'll see a message in the terminal indicating that the migration started:
info: Migrating products from Magento...
Once the migration is done, you'll see the following message:
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.
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:
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.