The Complete Enterprise Migration Guide: Upgrading Magento 2 Infrastructure directly to Custom Laravel E-commerce
Strategic Rationale: Why Migrate from Magento 2 to Custom Laravel E-commerce?
The decision to migrate from a mature, albeit complex, platform like Magento 2 to a custom Laravel e-commerce solution is driven by several strategic imperatives for enterprise-level businesses. Magento 2, while powerful, often presents significant challenges in terms of performance, scalability, development velocity, and total cost of ownership, especially when heavily customized. A custom Laravel build offers a compelling alternative by providing:
- Agility and Faster Development Cycles: Laravel’s elegant syntax, robust ecosystem (Eloquent ORM, Blade templating, Artisan CLI), and strong community support enable faster feature development and iteration.
- Performance Optimization: A lean, custom-built Laravel application can be meticulously optimized for specific business needs, avoiding the overhead and bloat often associated with monolithic platforms like Magento.
- Reduced Technical Debt: Migrating allows for a strategic re-architecture, shedding legacy complexities and adopting modern best practices in cloud-native design, microservices, and API-first development.
- Cost Efficiency: While initial development investment is required, the long-term operational costs, licensing fees (if applicable), and development resource costs can be significantly lower compared to maintaining and extending a complex Magento instance.
- Enhanced Control and Customization: A custom solution provides complete control over the technology stack, integrations, and feature set, precisely tailored to unique business workflows and competitive differentiators.
This guide focuses on the technical execution of such a migration, assuming a robust Magento 2 infrastructure and targeting a cloud-native, scalable Laravel e-commerce platform.
Phase 1: Data Migration Strategy and Execution
Data is the lifeblood of any e-commerce operation. A successful migration hinges on a meticulous, phased approach to data extraction, transformation, and loading (ETL). We’ll focus on critical entities: Products, Customers, Orders, and CMS Pages/Blocks.
1.1 Product Data Migration
Magento 2’s product catalog can be complex, with various product types (simple, configurable, grouped, virtual, downloadable, bundle), custom attributes, and intricate pricing rules. A direct, one-to-one mapping is rarely feasible. We’ll leverage Magento’s export capabilities and then transform the data for Laravel’s Eloquent models.
Step 1: Export Product Data from Magento 2
Utilize Magento’s built-in CSV export or a custom script for more granular control. Focus on exporting:
- SKU
- Name
- Description (short and long)
- Price (base, special)
- Stock Quantity
- Categories (hierarchical path)
- Attributes (color, size, material, etc.)
- Images (URLs or file paths)
- Product Type
- Parent SKU (for configurable products)
- Associated Product SKUs (for configurable products)
A sample Magento 2 product export CSV might look like this:
sku,name,description,price,special_price,stock_qty,category_ids,attribute_set_id,type,created_at,updated_at SKU001,Awesome T-Shirt,A comfortable cotton t-shirt.,25.00,20.00,100,3,4,simple,2023-01-15 10:00:00,2023-10-26 14:30:00 SKU001-RED,Awesome T-Shirt - Red,Red variant of the awesome t-shirt.,25.00,,50,3,4,configurable,2023-01-15 10:05:00,2023-10-26 14:30:00 SKU001-RED-S,Awesome T-Shirt - Red - S,Small red t-shirt.,25.00,,20,3,4,simple,2023-01-15 10:06:00,2023-10-26 14:30:00 SKU002,Premium Jeans,Durable denim jeans.,75.00,,80,5,4,simple,2023-02-20 11:00:00,2023-10-26 14:35:00
Step 2: Define Laravel E-commerce Models
Design your Eloquent models to mirror the essential product data. For a configurable product scenario, you’ll likely need a `Product` model and potentially a `Variant` model, or handle variations within the `Product` model itself using relationships.
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'sku', 'name', 'description', 'base_price', 'special_price',
'stock_quantity', 'is_active', 'product_type', 'parent_sku'
];
// Relationships for categories, attributes, etc. would be defined here.
// For configurable products, you might have:
public function variants(): HasMany
{
return $this->hasMany(Product::class, 'parent_sku', 'sku');
}
public function parent(): HasOne
{
return $this->hasOne(Product::class, 'sku', 'parent_sku');
}
// Example for attributes (assuming an Attribute model and a pivot table)
public function attributes(): BelongsToMany
{
return $this->belongsToMany(Attribute::class, 'product_attribute_values')
->withPivot('value');
}
}
// app/Models/Category.php (simplified)
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Category extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug', 'parent_id'];
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'category_product');
}
}
// app/Models/Attribute.php (simplified)
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Attribute extends Model
{
use HasFactory;
protected $fillable = ['name', 'type']; // e.g., type: 'select', 'text'
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_attribute_values')
->withPivot('value');
}
}
Step 3: Develop ETL Script (PHP/Laravel Artisan Command)
Create an Artisan command to process the exported CSV. This script will parse the CSV, map Magento attributes to Laravel models, handle data transformations (e.g., category slugs, attribute values), and insert/update records in the Laravel database.
// app/Console/Commands/ImportProducts.php
namespace App\Console\Commands;
use App\Models\Product;
use App\Models\Category;
use App\Models\Attribute;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use League\Csv\Reader;
class ImportProducts extends Command
{
protected $signature = 'import:products {filePath}';
protected $description = 'Imports product data from a Magento 2 CSV export.';
public function handle()
{
$filePath = $this->argument('filePath');
if (!file_exists($filePath)) {
$this->error("File not found: {$filePath}");
return 1;
}
$csv = Reader::createFromPath($filePath, 'r');
$csv->setHeaderOffset(0); // Assumes first row is header
$records = $csv->getRecords();
DB::transaction(function () use ($records) {
foreach ($records as $record) {
// Basic product creation/update
$product = Product::updateOrCreate(
['sku' => $record['sku']],
[
'name' => $record['name'],
'description' => $record['description'],
'base_price' => (float) $record['price'],
'special_price' => isset($record['special_price']) && $record['special_price'] !== '' ? (float) $record['special_price'] : null,
'stock_quantity' => (int) $record['stock_qty'],
'is_active' => true, // Default to active, can be determined from Magento data
'product_type' => $record['type'],
'parent_sku' => $record.['type'] === 'configurable' ? null : ($record['parent_sku'] ?? null),
]
);
// Handle Categories
if (!empty($record['category_ids'])) {
$categoryIds = explode(',', $record['category_ids']); // Assuming comma-separated IDs
$categorySlugs = []; // You'd need a mapping or lookup for category names/slugs
// Example: Fetch or create categories based on IDs/names
// $categories = Category::whereIn('magento_id', $categoryIds)->get();
// $product->categories()->sync($categories->pluck('id'));
// For simplicity, let's assume we have a way to get category IDs in Laravel
// $product->categories()->sync($categoryIds); // If Laravel IDs match Magento IDs
}
// Handle Attributes (Example: Color, Size)
// This requires a more sophisticated mapping based on attribute_set_id and attribute codes
// For instance, if 'color' is attribute ID 10 and 'size' is 11
// $product->attributes()->syncWithoutDetaching([
// 10 => ['value' => $record['color_value']],
// 11 => ['value' => $record['size_value']],
// ]);
}
});
$this->info('Product import completed.');
}
}
Important Considerations for Product Data:
- Attribute Mapping: Develop a robust mapping strategy for Magento’s dynamic attributes to your Laravel model’s structure. This might involve a dedicated `attributes` table and a `product_attribute_values` pivot table.
- Configurable Products: Carefully manage the parent-child relationship. In Laravel, a configurable product might be the parent `Product` record, with its variations (simple products) linked via `parent_sku` or a dedicated `variants` relationship.
- Pricing: Account for different price types (base, tier, group, special) and currency conversions if applicable.
- Images: Decide whether to re-upload images or use CDN URLs from Magento. Implement a robust image handling strategy in Laravel (e.g., using Spatie’s Media Library package).
- Stock Management: Ensure accurate stock counts are migrated and that your Laravel application has a clear strategy for real-time stock updates.
1.2 Customer Data Migration
Customer data includes personal information, addresses, and potentially account history. Security and data integrity are paramount.
Step 1: Export Customer Data from Magento 2
Export customer entities, including:
- Customer ID
- First Name, Last Name
- Email Address
- Password Hash (crucial for migration)
- Account Creation Date
- Customer Group
- Addresses (Street, City, Region, Postcode, Country, Phone)
entity_id,website_id,email,group_id,created_at,updated_at,increment_id,store_id,first_name,last_name,password_hash,dob,gender,taxvat,created_in,rp_token,rp_token_created_at,default_billing,default_shipping 1,1,[email protected],1,2023-01-10 09:00:00,2023-10-25 11:00:00,100000001,1,John,Doe,a1b2c3d4e5f6...,1990-05-15,1,VATID123,Default,NULL,NULL,2,3
Step 2: Define Laravel Customer and Address Models
// app/Models/User.php (or Customer.php)
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name', 'email', 'password', 'magento_id', 'customer_group_id',
'created_at', 'updated_at'
];
protected $hidden = ['password', 'remember_token'];
protected $casts = [
'email_verified_at' => 'datetime',
];
public function addresses()
{
return $this->hasMany(Address::class);
}
// Relationship to CustomerGroup model
public function group()
{
return $this->belongsTo(CustomerGroup::class, 'customer_group_id');
}
}
// app/Models/Address.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Address extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'street', 'city', 'state', 'postal_code', 'country_code',
'phone', 'is_default_billing', 'is_default_shipping', 'magento_address_id'
];
public function user()
{
return $this->belongsTo(User::class);
}
}
Step 3: Develop ETL Script for Customers
The script needs to handle password migration carefully. Magento uses various hashing algorithms. You’ll need to identify the algorithm used and potentially use a library in PHP to verify and re-hash passwords for Laravel’s default bcrypt or Argon2.
// app/Console/Commands/ImportCustomers.php (simplified)
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Address;
use App\Models\CustomerGroup; // Assuming this model exists
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use League\Csv\Reader;
class ImportCustomers extends Command
{
protected $signature = 'import:customers {filePath}';
protected $description = 'Imports customer data from a Magento 2 CSV export.';
public function handle()
{
$filePath = $this->argument('filePath');
// ... file validation ...
$csv = Reader::createFromPath($filePath, 'r');
$csv->setHeaderOffset(0);
DB::transaction(function () use ($csv) {
foreach ($csv->getRecords() as $record) {
// Handle password hashing - THIS IS CRITICAL AND COMPLEX
// You need to know Magento's hashing method (e.g., BCRYPT, SHA256)
// Example: If Magento used SHA256 and you want to migrate to bcrypt
// $plainPassword = decryptMagentoPassword($record['password_hash']); // Custom function needed
// $hashedPassword = Hash::make($plainPassword);
// For simplicity, assuming we can directly use the hash if compatible or have a decryption method
// A common scenario is to force password reset for all users post-migration.
$hashedPassword = Hash::make('temporary_password_reset_needed'); // Force reset
$user = User::updateOrCreate(
['magento_id' => $record['entity_id']], // Use Magento ID for uniqueness during import
[
'name' => $record['first_name'] . ' ' . $record['last_name'],
'email' => $record['email'],
'password' => $hashedPassword, // Use forced reset password
'customer_group_id' => $record['group_id'] ?? 1, // Map Magento group to Laravel group
'created_at' => $record['created_at'],
'updated_at' => $record['updated_at'],
]
);
// Import Addresses
// Magento stores addresses separately. You'll need to join or fetch them.
// Assuming address data is available in the same CSV or a separate one.
// This example assumes address fields are directly in the customer record (less common for Magento)
// A real scenario would involve joining customer_entity_varchar, customer_address_entity, etc.
// For demonstration, let's assume address fields are present:
if (!empty($record['street']) && !empty($record['city'])) {
$address = Address::updateOrCreate(
['user_id' => $user->id, 'magento_address_id' => $record['address_id'] ?? null], // Use Magento address ID if available
[
'street' => $record['street'],
'city' => $record['city'],
'state' => $record['region'] ?? null,
'postal_code' => $record['postcode'] ?? null,
'country_code' => $record['country_id'] ?? null, // e.g., 'US'
'phone' => $record['telephone'] ?? null,
'is_default_billing' => (int) ($record['default_billing'] ?? 0) === (int) ($record['address_id'] ?? 0),
'is_default_shipping' => (int) ($record['default_shipping'] ?? 0) === (int) ($record['address_id'] ?? 0),
]
);
}
}
});
$this->info('Customer import completed. All users will need to reset their passwords.');
}
}
Password Migration Strategy: The safest and most common approach is to migrate customer accounts but force a password reset upon their first login to the new Laravel platform. This avoids complex decryption and hashing compatibility issues.
1.3 Order Data Migration
Migrating historical orders is often complex due to the sheer volume and the intricate relationships (items, payments, shipments, statuses). A common strategy is to migrate recent orders (e.g., last 1-2 years) and archive older ones, or only migrate essential order data for reporting.
Step 1: Export Order Data from Magento 2
This typically requires custom SQL queries or Magento extensions to export comprehensive order data, including:
- Order ID (Increment ID)
- Customer ID
- Order Date
- Order Status
- Total Amount (incl. tax, shipping, discounts)
- Payment Method
- Shipping Method
- Billing Address
- Shipping Address
- Order Items (SKU, Name, Qty, Price)
- Shipments
- Invoices
-- Example SQL query (simplified, requires joining multiple tables)
SELECT
o.increment_id,
o.customer_id,
o.created_at,
o.status,
o.base_total_paid,
o.payment_method, -- This is often complex in Magento
o.shipping_method,
ba.street AS billing_street,
ba.city AS billing_city,
-- ... other billing address fields
sa.street AS shipping_street,
sa.city AS shipping_city,
-- ... other shipping address fields
oi.sku AS item_sku,
oi.name AS item_name,
oi.qty_ordered,
oi.base_price_incl_tax
FROM
sales_order o
LEFT JOIN
sales_order_address ba ON o.billing_address_id = ba.entity_id
LEFT JOIN
sales_order_address sa ON o.shipping_address_id = sa.entity_id
LEFT JOIN
sales_order_item oi ON o.entity_id = oi.order_id
WHERE
o.created_at > '2022-01-01'; -- Filter for recent orders
Step 2: Define Laravel Order Models
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'order_number', 'order_date', 'status', 'total_amount',
'subtotal', 'tax_amount', 'shipping_amount', 'discount_amount',
'payment_method', 'shipping_method', 'billing_address_id', 'shipping_address_id',
'magento_order_id' // Store original Magento ID
];
public function user()
{
return $this->belongsTo(User::class);
}
public function items()
{
return $this->hasMany(OrderItem::class);
}
public function addresses()
{
return $this->hasMany(OrderAddress::class); // Separate model for order addresses
}
// You might have relationships for payments, shipments, invoices
}
// app/Models/OrderItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OrderItem extends Model
{
use HasFactory;
protected $fillable = [
'order_id', 'product_sku', 'product_name', 'quantity', 'unit_price', 'row_total'
];
public function order()
{
return $this->belongsTo(Order::class);
}
public function product()
{
// Optional: Link to the actual product if it exists in the new system
return $this->belongsTo(Product::class, 'product_sku', 'sku');
}
}
// app/Models/OrderAddress.php (to store address snapshot at time of order)
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OrderAddress extends Model
{
use HasFactory;
protected $fillable = [
'order_id', 'type', // 'billing' or 'shipping'
'street', 'city', 'state', 'postal_code', 'country_code', 'phone',
'first_name', 'last_name'
];
public function order()
{
return $this->belongsTo(Order::class);
}
}
Step 3: Develop ETL Script for Orders
This script will be complex, requiring careful handling of nested data (items, addresses). You’ll need to map Magento order statuses to your Laravel order statuses.
// app/Console/Commands/ImportOrders.php (conceptual)
namespace App\Console\Commands;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\OrderAddress;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use League\Csv\Reader; // Or use direct DB queries
class ImportOrders extends Command
{
protected $signature = 'import:orders {filePath}';
protected $description = 'Imports order data from a Magento 2 export.';
public function handle()
{
$filePath = $this->argument('filePath');
// ... file validation ...
// Assuming a CSV export where each row represents an order item,
// and order/address details are repeated. Requires grouping.
// A better approach might be multiple CSVs or direct DB access.
// Let's assume a structure where we first import orders, then items, then addresses.
// This requires multiple passes or a more complex single-pass logic.
// Simplified conceptual logic:
// 1. Read order data, group by order ID.
// 2. For each order group:
// a. Find the corresponding Laravel User.
// b. Create the Order record.
// c. Create Billing and Shipping Address records for the order.
// d. Iterate through order items and create OrderItem records.
$this->info('Order import process initiated. This is a complex operation.');
// ... implementation details ...
DB::transaction(function () {
// ... logic to read CSV/DB, map, and insert ...
// Example:
// $orderData = $this->fetchAndGroupOrderData(); // Custom method
// foreach ($orderData as $orderId => $data) {
// $user = User::where('magento_id', $data['customer_id'])->first();
// if (!$user) continue; // Skip if user not migrated
// $order = Order::create([
// 'user_id' => $user->id,
// 'order_number' => $data['increment_id'],
// // ... map other fields ...
// 'magento_order_id' => $orderId,
// ]);
// // Create addresses
// OrderAddress::create([... 'type' => 'billing', ...]);
// OrderAddress::create([... 'type' => 'shipping', ...]);
// // Create items
// foreach ($data['items'] as $item) {
// OrderItem::create([...]);
// }
// }
});
$this->info('Order import completed.');
}
}
Phase 2: Application Re-platforming and Development
This phase involves building the new Laravel e-commerce application, integrating necessary services, and ensuring feature parity or improvement over the Magento 2 setup.
2.1 Core E-commerce Functionality in Laravel
Leverage Laravel’s features and potentially packages to build:
- Product Catalog: Eloquent models for products, categories, attributes, and variants. Implement search and filtering.
- Shopping Cart: Session-based or database-backed cart functionality. Consider packages like `hardevine/shopping-cart`.
- Checkout Process: Multi-step checkout flow, integrating with payment gateways and shipping providers.
- User Authentication: Laravel’s built-in auth system, extended for customer-specific fields.
- Order Management: Admin panel for viewing and managing orders, customers, and products.
- Content Management: Integrate a headless CMS or build basic CMS features for pages and blocks.
2.2 API-First Architecture and Integrations
Design the Laravel application with an API-first approach. This facilitates integrations with other systems (ERP, CRM, PIM, marketing automation) and enables a headless frontend if desired.
// Example: Laravel Sanctum for API authentication
// routes/api.php
use App\Http\Controllers\Api\ProductController;
use App\Http\Controllers\Api\OrderController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('products', ProductController::class);
Route::apiResource('orders', OrderController::class);
// ... other protected routes
});
// Example: Product API Controller
// app/Http/Controllers/Api/ProductController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
// Implement filtering, sorting, pagination
$products = Product::query()
->when($request->input('category'), function ($query, $category) {
// Assuming a many-to-many relationship with categories
$query->whereHas('categories', function ($q) use ($category) {
$q->where('slug', $category);
});
})
->paginate(15);
return response()->json($products);
}
public function show(Product $product)
{
// Eager load relationships if needed (e.g., variants, attributes)
$product->load('variants', 'attributes');
return response()->json($product);
}
}
Key Integrations:
- Payment Gateways: Stripe, PayPal, Adyen, etc. Use official SDKs or Laravel packages.
- Shipping Providers: UPS, FedEx, USPS APIs for rate calculation and label generation.
- Email Services: Mailgun, SendGrid, AWS SES for transactional emails.
- Search: Elasticsearch or Algolia for advanced product search capabilities.
- Analytics: Google Analytics, custom event tracking.
2.3 Infrastructure and Deployment (Cloud Replatforming)
Target a modern, cloud-native infrastructure. This is central to the “Cloud Replatforming” strategic intent.
Containerization: Dockerize the Laravel application for consistent deployments across environments.
# Dockerfile for Laravel Application
FROM php:8.2-fpm
WORKDIR /var/www/html
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
zip \
acl \
curl \
# Add any other necessary packages
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip bcmath opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy application files
COPY . .
# Set permissions
RUN chown -R www-data:www-data storage bootstrap/cache && chmod -R 775 storage bootstrap/cache
# Install Composer dependencies
RUN composer install --no-dev --optimize-autoloader