Migrate WordPress to Laravel: The Complete Developer Guide
Step-by-step guide to migrating WordPress sites to Laravel. Cover content migration, database design, SEO preservation, and custom feature implementation.
WordPress powers over 40% of the web, but its one-size-fits-all architecture often becomes a limitation for applications that need custom functionality, better performance, or tighter security. After 14 years of PHP development and numerous migrations from WordPress to Laravel, I have learned that a successful migration requires methodical planning, careful data transformation, and meticulous attention to SEO preservation.
This guide walks you through every step of migrating a WordPress site to a custom Laravel application, from initial planning through post-migration monitoring. Whether you are escaping plugin bloat, seeking better performance, or building features that WordPress cannot support, this guide provides the blueprint for a successful transition.
Why Migrate from WordPress to Laravel
Before committing to a migration, understand the trade-offs. WordPress excels at content management and has an enormous ecosystem, but Laravel offers distinct advantages for certain use cases.
Performance and Scalability
WordPress is database-intensive by design. Every page load queries the wp_posts and wp_postmeta tables, and plugins add their own queries. A typical WordPress page can execute 50-100 database queries. Laravel applications with proper architecture typically execute 5-15 queries for similar functionality.
<?php
// WordPress: Querying a post with metadata (simplified representation)
// This triggers multiple queries across wp_posts, wp_postmeta, wp_terms
$post = get_post($id);
$meta = get_post_meta($id);
$categories = get_the_category($id);
$tags = get_the_tags($id);
// Laravel: Single eager-loaded query
$post = Post::with(['categories', 'tags', 'meta'])->find($id);
With Laravel, you control the query structure. You can optimize for your specific access patterns, implement aggressive caching, and scale horizontally without plugin conflicts. For maximum performance, see my guide on Laravel Octane Performance Optimization.
Security Control
WordPress's popularity makes it a prime target for attacks. According to Sucuri's Annual Website Threat Research Report, WordPress accounts for over 90% of hacked CMS installations. While this partly reflects market share, the plugin ecosystem introduces significant attack surface.
Laravel provides built-in protection against common vulnerabilities:
- CSRF Protection: Automatic token validation on forms
- SQL Injection: Eloquent ORM with prepared statements
- XSS Protection: Blade templating with automatic escaping
- Mass Assignment: Guarded/fillable model properties
- Authentication: Built-in auth scaffolding with best practices
For comprehensive security implementation, see my guide on contact form security best practices.
Customization Freedom
WordPress's hook system is powerful but limiting. Complex customizations often require fighting against WordPress's assumptions rather than building with clean architecture.
Laravel gives you complete control:
<?php
namespace App\Services;
use App\Models\Post;
use App\Events\PostPublished;
use Illuminate\Support\Facades\Cache;
class PostPublishingService
{
public function publish(Post $post): void
{
// Custom business logic without hook spaghetti
$post->update([
'status' => 'published',
'published_at' => now(),
]);
// Clear relevant caches
Cache::tags(['posts', "post:{$post->id}"])->flush();
// Dispatch event for observers
PostPublished::dispatch($post);
}
}
When to Stay with WordPress
Migration is not always the right choice. Stay with WordPress if:
- Content editors need a familiar, non-technical interface
- You rely heavily on specific plugins with no Laravel equivalent
- Development resources are limited
- The site is primarily content with minimal custom functionality
- SEO and content strategy depend on WordPress-specific tools
Planning the Migration
A successful migration begins months before the first line of code. Rushing this phase causes more project failures than any technical challenge.
Content Audit
Document every content type and its relationships:
## Content Inventory
### Posts (wp_posts where post_type = 'post')
- Total count: 847
- Custom fields: author_bio, reading_time, featured_video
- Categories: 12 top-level, 34 children
- Tags: 156 unique
### Pages (wp_posts where post_type = 'page')
- Total count: 23
- Templates used: default, full-width, landing-page
- Custom fields: cta_text, cta_url, sidebar_content
### Custom Post Types
- Portfolio (portfolio): 45 items
- Testimonials (testimonial): 28 items
- Team Members (team_member): 12 items
### Media Library
- Total files: 2,341
- Total size: 4.2 GB
- Formats: jpg, png, webp, pdf, mp4
### Users
- Total: 156
- Roles: administrator (3), editor (5), author (12), subscriber (136)
Feature Mapping
Map WordPress features to Laravel implementations:
| WordPress Feature | Laravel Implementation |
|---|---|
| Posts/Pages | Eloquent Models + Migrations |
| Custom Post Types | Additional Models |
| Categories/Tags | Polymorphic Tagging |
| Custom Fields (ACF) | JSON columns or related tables |
| Media Library | Spatie Media Library or custom |
| Comments | Custom or Disqus/external |
| User Roles | Spatie Permission |
| SEO (Yoast/RankMath) | Custom meta fields |
| Contact Forms | Custom forms + validation |
| E-commerce (WooCommerce) | Custom or dedicated package |
Database Design
WordPress stores everything in wp_posts with metadata in wp_postmeta. This flexibility comes at a performance cost. Design a normalized schema for Laravel:
<?php
// database/migrations/2026_01_25_create_posts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('content');
$table->enum('status', ['draft', 'pending', 'published', 'archived'])
->default('draft');
$table->timestamp('published_at')->nullable()->index();
// Fields that were in wp_postmeta now have dedicated columns
$table->string('featured_image')->nullable();
$table->string('meta_title')->nullable();
$table->string('meta_description')->nullable();
$table->integer('reading_time')->nullable();
$table->json('custom_fields')->nullable(); // Flexible storage for misc data
$table->timestamps();
$table->softDeletes();
// Indexes for common queries
$table->index(['status', 'published_at']);
$table->index('author_id');
});
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
});
Schema::create('category_post', function (Blueprint $table) {
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->primary(['category_id', 'post_id']);
});
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->primary(['post_id', 'tag_id']);
});
}
public function down(): void
{
Schema::dropIfExists('post_tag');
Schema::dropIfExists('tags');
Schema::dropIfExists('category_post');
Schema::dropIfExists('categories');
Schema::dropIfExists('posts');
}
};
This normalized schema provides several advantages:
- Query Performance: No more joining
wp_postmetafor every field access - Type Safety: Proper column types instead of everything as text
- Indexing: Targeted indexes on frequently queried columns
- Validation: Database-level constraints enforce data integrity
For more on Laravel database patterns, see my Laravel API Development Best Practices guide.
Setting Up the Laravel Project
With planning complete, set up your Laravel project with a migration-friendly structure.
Initial Setup
# Create new Laravel project (Laravel 12 as of January 2026)
composer create-project laravel/laravel my-migrated-site
cd my-migrated-site
# Install common packages for content-heavy sites
composer require spatie/laravel-medialibrary
composer require spatie/laravel-sluggable
composer require spatie/laravel-permission
composer require league/commonmark
composer require mews/purifier
# Development dependencies
composer require --dev barryvdh/laravel-debugbar
composer require --dev laravel/pint
Environment Configuration
Configure your environment to connect to both databases during migration:
# Primary Laravel database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_site
DB_USERNAME=laravel
DB_PASSWORD=secret
# WordPress database (for migration scripts)
WP_DB_CONNECTION=mysql
WP_DB_HOST=127.0.0.1
WP_DB_PORT=3306
WP_DB_DATABASE=wordpress_site
WP_DB_USERNAME=wordpress
WP_DB_PASSWORD=secret
WP_TABLE_PREFIX=wp_
Add the WordPress connection to config/database.php:
<?php
// config/database.php
return [
// ... existing configuration
'connections' => [
// ... existing connections
'wordpress' => [
'driver' => 'mysql',
'host' => env('WP_DB_HOST', '127.0.0.1'),
'port' => env('WP_DB_PORT', '3306'),
'database' => env('WP_DB_DATABASE', 'wordpress'),
'username' => env('WP_DB_USERNAME', 'forge'),
'password' => env('WP_DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => env('WP_TABLE_PREFIX', 'wp_'),
'strict' => false,
],
],
];
Model Structure
Create models that map to WordPress structure for reading and Laravel structure for writing:
<?php
// app/Models/WordPress/WpPost.php
namespace App\Models\WordPress;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class WpPost extends Model
{
protected $connection = 'wordpress';
protected $table = 'posts'; // Prefix handled by connection config
protected $primaryKey = 'ID';
public $timestamps = false;
protected $casts = [
'post_date' => 'datetime',
'post_modified' => 'datetime',
];
public function meta(): HasMany
{
return $this->hasMany(WpPostMeta::class, 'post_id', 'ID');
}
public function terms(): BelongsToMany
{
return $this->belongsToMany(
WpTerm::class,
'term_relationships',
'object_id',
'term_taxonomy_id'
);
}
public function getMetaValue(string $key): ?string
{
return $this->meta->where('meta_key', $key)->first()?->meta_value;
}
public function scopePublished($query)
{
return $query->where('post_status', 'publish');
}
public function scopeOfType($query, string $type)
{
return $query->where('post_type', $type);
}
}
<?php
// app/Models/WordPress/WpPostMeta.php
namespace App\Models\WordPress;
use Illuminate\Database\Eloquent\Model;
class WpPostMeta extends Model
{
protected $connection = 'wordpress';
protected $table = 'postmeta';
protected $primaryKey = 'meta_id';
public $timestamps = false;
}
Migrating Content
Content migration is the heart of the project. Build robust, resumable migration commands.
Posts Migration Command
<?php
// app/Console/Commands/MigrateWordPressPosts.php
namespace App\Console\Commands;
use App\Models\Post;
use App\Models\Category;
use App\Models\Tag;
use App\Models\User;
use App\Models\WordPress\WpPost;
use App\Models\WordPress\WpTerm;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class MigrateWordPressPosts extends Command
{
protected $signature = 'migrate:wordpress-posts
{--dry-run : Run without saving}
{--limit= : Limit number of posts to migrate}
{--offset= : Skip first N posts}';
protected $description = 'Migrate posts from WordPress to Laravel';
private array $categoryMap = [];
private array $tagMap = [];
private array $userMap = [];
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$limit = $this->option('limit');
$offset = $this->option('offset') ?? 0;
$this->info($isDryRun ? 'DRY RUN - No data will be saved' : 'LIVE MIGRATION');
// Pre-migrate categories and tags
$this->migrateCategories($isDryRun);
$this->migrateTags($isDryRun);
$this->mapUsers();
// Query WordPress posts
$query = WpPost::query()
->ofType('post')
->published()
->with(['meta', 'terms'])
->orderBy('ID');
if ($limit) {
$query->limit($limit);
}
if ($offset) {
$query->offset($offset);
}
$wpPosts = $query->get();
$this->info("Found {$wpPosts->count()} posts to migrate");
$progressBar = $this->output->createProgressBar($wpPosts->count());
$migrated = 0;
$failed = 0;
$skipped = 0;
foreach ($wpPosts as $wpPost) {
try {
// Check if already migrated
if (Post::where('legacy_wp_id', $wpPost->ID)->exists()) {
$skipped++;
$progressBar->advance();
continue;
}
$postData = $this->transformPost($wpPost);
if (!$isDryRun) {
DB::transaction(function () use ($postData, $wpPost) {
$post = Post::create($postData['attributes']);
// Attach categories
if (!empty($postData['categories'])) {
$post->categories()->attach($postData['categories']);
}
// Attach tags
if (!empty($postData['tags'])) {
$post->tags()->attach($postData['tags']);
}
// Handle featured image
if ($postData['featured_image']) {
$this->migrateFeaturedImage($post, $postData['featured_image']);
}
});
}
$migrated++;
} catch (\Exception $e) {
$failed++;
Log::error("Failed to migrate post {$wpPost->ID}: {$e->getMessage()}", [
'post_id' => $wpPost->ID,
'title' => $wpPost->post_title,
'exception' => $e,
]);
$this->error("Failed: {$wpPost->post_title} - {$e->getMessage()}");
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$this->info("Migration complete:");
$this->info(" Migrated: {$migrated}");
$this->info(" Skipped (already exists): {$skipped}");
$this->info(" Failed: {$failed}");
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
}
private function transformPost(WpPost $wpPost): array
{
// Get SEO meta from Yoast or RankMath
$metaTitle = $wpPost->getMetaValue('_yoast_wpseo_title')
?? $wpPost->getMetaValue('rank_math_title')
?? $wpPost->post_title;
$metaDescription = $wpPost->getMetaValue('_yoast_wpseo_metadesc')
?? $wpPost->getMetaValue('rank_math_description')
?? Str::limit(strip_tags($wpPost->post_content), 155);
// Get featured image
$thumbnailId = $wpPost->getMetaValue('_thumbnail_id');
$featuredImage = null;
if ($thumbnailId) {
$attachment = WpPost::find($thumbnailId);
$featuredImage = $attachment?->guid;
}
// Transform content (convert shortcodes, blocks, etc.)
$content = $this->transformContent($wpPost->post_content);
// Map author
$authorId = $this->userMap[$wpPost->post_author] ?? 1; // Default to admin
// Calculate reading time
$wordCount = str_word_count(strip_tags($content));
$readingTime = max(1, ceil($wordCount / 200));
// Get categories and tags
$categoryIds = [];
$tagIds = [];
foreach ($wpPost->terms as $term) {
if ($term->taxonomy === 'category' && isset($this->categoryMap[$term->term_id])) {
$categoryIds[] = $this->categoryMap[$term->term_id];
} elseif ($term->taxonomy === 'post_tag' && isset($this->tagMap[$term->term_id])) {
$tagIds[] = $this->tagMap[$term->term_id];
}
}
return [
'attributes' => [
'legacy_wp_id' => $wpPost->ID,
'author_id' => $authorId,
'title' => $wpPost->post_title,
'slug' => $wpPost->post_name,
'excerpt' => $wpPost->post_excerpt ?: Str::limit(strip_tags($content), 300),
'content' => $content,
'status' => 'published',
'published_at' => $wpPost->post_date,
'meta_title' => $metaTitle,
'meta_description' => $metaDescription,
'reading_time' => $readingTime,
'created_at' => $wpPost->post_date,
'updated_at' => $wpPost->post_modified,
],
'categories' => $categoryIds,
'tags' => $tagIds,
'featured_image' => $featuredImage,
];
}
private function transformContent(string $content): string
{
// Convert Gutenberg blocks to HTML
$content = $this->convertGutenbergBlocks($content);
// Convert common shortcodes
$content = $this->convertShortcodes($content);
// Clean up WordPress-specific formatting
$content = $this->cleanContent($content);
return $content;
}
private function convertGutenbergBlocks(string $content): string
{
// Remove Gutenberg block comments
$content = preg_replace('/<!-- wp:.*?-->/', '', $content);
$content = preg_replace('/<!-- \/wp:.*?-->/', '', $content);
return $content;
}
private function convertShortcodes(string $content): string
{
// Convert common shortcodes - customize based on your plugins
// [caption] shortcode
$content = preg_replace(
'/\[caption[^\]]*\](.*?)\[\/caption\]/s',
'<figure>$1</figure>',
$content
);
// [gallery] shortcode - you'll need custom handling
$content = preg_replace(
'/\[gallery[^\]]*\]/',
'<!-- Gallery placeholder - requires manual review -->',
$content
);
// Remove unhandled shortcodes with warning
$content = preg_replace_callback(
'/\[(\w+)[^\]]*\](?:.*?\[\/\1\])?/s',
function ($matches) {
Log::warning("Unhandled shortcode: {$matches[0]}");
return "<!-- Unhandled shortcode: {$matches[1]} -->";
},
$content
);
return $content;
}
private function cleanContent(string $content): string
{
// Convert Windows line endings
$content = str_replace("\r\n", "\n", $content);
// Remove extra whitespace
$content = preg_replace('/\n{3,}/', "\n\n", $content);
// Trim
return trim($content);
}
private function migrateCategories(bool $isDryRun): void
{
$this->info('Migrating categories...');
$wpCategories = DB::connection('wordpress')
->table('terms')
->join('term_taxonomy', 'terms.term_id', '=', 'term_taxonomy.term_id')
->where('term_taxonomy.taxonomy', 'category')
->get();
foreach ($wpCategories as $wpCategory) {
if (!$isDryRun) {
$category = Category::firstOrCreate(
['slug' => $wpCategory->slug],
[
'name' => $wpCategory->name,
'description' => $wpCategory->description,
]
);
$this->categoryMap[$wpCategory->term_id] = $category->id;
} else {
$this->categoryMap[$wpCategory->term_id] = $wpCategory->term_id;
}
}
$this->info(" Mapped " . count($this->categoryMap) . " categories");
}
private function migrateTags(bool $isDryRun): void
{
$this->info('Migrating tags...');
$wpTags = DB::connection('wordpress')
->table('terms')
->join('term_taxonomy', 'terms.term_id', '=', 'term_taxonomy.term_id')
->where('term_taxonomy.taxonomy', 'post_tag')
->get();
foreach ($wpTags as $wpTag) {
if (!$isDryRun) {
$tag = Tag::firstOrCreate(
['slug' => $wpTag->slug],
['name' => $wpTag->name]
);
$this->tagMap[$wpTag->term_id] = $tag->id;
} else {
$this->tagMap[$wpTag->term_id] = $wpTag->term_id;
}
}
$this->info(" Mapped " . count($this->tagMap) . " tags");
}
private function mapUsers(): void
{
$this->info('Mapping users...');
$wpUsers = DB::connection('wordpress')
->table('users')
->get();
foreach ($wpUsers as $wpUser) {
// Match by email or create mapping
$user = User::where('email', $wpUser->user_email)->first();
if ($user) {
$this->userMap[$wpUser->ID] = $user->id;
}
}
$this->info(" Mapped " . count($this->userMap) . " users");
}
private function migrateFeaturedImage(Post $post, string $imageUrl): void
{
// Using Spatie Media Library
try {
$post->addMediaFromUrl($imageUrl)
->toMediaCollection('featured');
} catch (\Exception $e) {
Log::warning("Failed to migrate featured image for post {$post->id}: {$e->getMessage()}");
}
}
}
Media Migration Command
Media files require special handling due to their size and the need to preserve URLs for SEO:
<?php
// app/Console/Commands/MigrateWordPressMedia.php
namespace App\Console\Commands;
use App\Models\WordPress\WpPost;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MigrateWordPressMedia extends Command
{
protected $signature = 'migrate:wordpress-media
{--dry-run : Run without downloading}
{--limit= : Limit files to process}';
protected $description = 'Migrate media files from WordPress uploads';
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$limit = $this->option('limit');
// Get all attachments from WordPress
$query = WpPost::query()
->ofType('attachment')
->with('meta');
if ($limit) {
$query->limit($limit);
}
$attachments = $query->get();
$this->info("Found {$attachments->count()} media files");
$progressBar = $this->output->createProgressBar($attachments->count());
$migrated = 0;
$failed = 0;
$skipped = 0;
foreach ($attachments as $attachment) {
$sourceUrl = $attachment->guid;
$attachedFile = $attachment->getMetaValue('_wp_attached_file');
if (!$attachedFile) {
$skipped++;
$progressBar->advance();
continue;
}
// Preserve the uploads directory structure
$destinationPath = "uploads/{$attachedFile}";
if (Storage::disk('public')->exists($destinationPath)) {
$skipped++;
$progressBar->advance();
continue;
}
if (!$isDryRun) {
try {
// Download and store the file
$response = Http::timeout(30)->get($sourceUrl);
if ($response->successful()) {
Storage::disk('public')->put(
$destinationPath,
$response->body()
);
// Also migrate thumbnail sizes if needed
$this->migrateThumbnails($attachment, $attachedFile);
$migrated++;
} else {
$failed++;
$this->error("HTTP {$response->status()}: {$sourceUrl}");
}
} catch (\Exception $e) {
$failed++;
$this->error("Failed: {$sourceUrl} - {$e->getMessage()}");
}
} else {
$migrated++;
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$this->info("Media migration complete:");
$this->info(" Migrated: {$migrated}");
$this->info(" Skipped: {$skipped}");
$this->info(" Failed: {$failed}");
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
}
private function migrateThumbnails(WpPost $attachment, string $attachedFile): void
{
$metadata = @unserialize($attachment->getMetaValue('_wp_attachment_metadata'));
if (!$metadata || !isset($metadata['sizes'])) {
return;
}
$directory = dirname($attachedFile);
$baseUrl = dirname($attachment->guid);
foreach ($metadata['sizes'] as $size => $data) {
$thumbnailFile = $data['file'];
$sourceUrl = "{$baseUrl}/{$thumbnailFile}";
$destinationPath = "uploads/{$directory}/{$thumbnailFile}";
if (Storage::disk('public')->exists($destinationPath)) {
continue;
}
try {
$response = Http::timeout(30)->get($sourceUrl);
if ($response->successful()) {
Storage::disk('public')->put($destinationPath, $response->body());
}
} catch (\Exception $e) {
// Log but don't fail - thumbnails can be regenerated
}
}
}
}
Handling WordPress-Specific Features
WordPress has features with no direct Laravel equivalent. Here is how to handle common ones.
Custom Post Types
Map each custom post type to a dedicated Laravel model:
<?php
// For a Portfolio custom post type
// database/migrations/2026_01_25_create_portfolio_items_table.php
Schema::create('portfolio_items', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('description');
$table->string('client_name')->nullable();
$table->string('project_url')->nullable();
$table->date('completed_at')->nullable();
$table->json('technologies')->nullable();
$table->enum('status', ['draft', 'published'])->default('draft');
$table->timestamps();
});
// app/Models/PortfolioItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
class PortfolioItem extends Model implements HasMedia
{
use InteractsWithMedia;
use HasSlug;
protected $fillable = [
'title',
'description',
'client_name',
'project_url',
'completed_at',
'technologies',
'status',
];
protected $casts = [
'completed_at' => 'date',
'technologies' => 'array',
];
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->generateSlugsFrom('title')
->saveSlugsTo('slug');
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('featured')
->singleFile();
$this->addMediaCollection('gallery');
}
}
Advanced Custom Fields (ACF)
ACF data lives in wp_postmeta. For complex field groups, consider these approaches:
<?php
// Approach 1: JSON column for flexible fields
Schema::create('posts', function (Blueprint $table) {
// ... other columns
$table->json('acf_fields')->nullable();
});
// Migration command extracts ACF fields
private function extractAcfFields(WpPost $wpPost): array
{
$acfFields = [];
foreach ($wpPost->meta as $meta) {
// ACF fields are prefixed with underscore for internal values
if (Str::startsWith($meta->meta_key, '_')) {
continue;
}
// Check if this is an ACF field (has corresponding _fieldname entry)
$hasAcfMarker = $wpPost->meta
->where('meta_key', "_{$meta->meta_key}")
->isNotEmpty();
if ($hasAcfMarker) {
$value = $meta->meta_value;
// Handle serialized data
if ($this->isSerialized($value)) {
$value = @unserialize($value);
}
$acfFields[$meta->meta_key] = $value;
}
}
return $acfFields;
}
// Approach 2: Dedicated columns for known fields
Schema::create('posts', function (Blueprint $table) {
// ... other columns
$table->string('hero_video_url')->nullable();
$table->boolean('show_author_box')->default(true);
$table->json('related_resources')->nullable();
});
WordPress User Passwords
WordPress uses a unique password hashing format. To allow users to log in with their existing passwords:
<?php
// app/Services/WordPressPasswordHasher.php
namespace App\Services;
class WordPressPasswordHasher
{
private string $itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
public function check(string $password, string $hash): bool
{
if (strlen($hash) === 34 && $hash[0] === '$' && $hash[1] === 'P') {
return $this->checkWordPressHash($password, $hash);
}
// Fall back to bcrypt for already-migrated passwords
return password_verify($password, $hash);
}
private function checkWordPressHash(string $password, string $hash): bool
{
$count_log2 = strpos($this->itoa64, $hash[3]);
$count = 1 << $count_log2;
$salt = substr($hash, 4, 8);
$hashed = md5($salt . $password, true);
do {
$hashed = md5($hashed . $password, true);
} while (--$count);
$output = substr($hash, 0, 12);
$output .= $this->encode64($hashed, 16);
return $hash === $output;
}
private function encode64(string $input, int $count): string
{
$output = '';
$i = 0;
do {
$value = ord($input[$i++]);
$output .= $this->itoa64[$value & 0x3f];
if ($i < $count) {
$value |= ord($input[$i]) << 8;
}
$output .= $this->itoa64[($value >> 6) & 0x3f];
if ($i++ >= $count) {
break;
}
if ($i < $count) {
$value |= ord($input[$i]) << 16;
}
$output .= $this->itoa64[($value >> 12) & 0x3f];
if ($i++ >= $count) {
break;
}
$output .= $this->itoa64[($value >> 18) & 0x3f];
} while ($i < $count);
return $output;
}
}
// Override Laravel's hasher in AuthServiceProvider
use Illuminate\Contracts\Hashing\Hasher;
use App\Services\WordPressPasswordHasher;
public function boot(): void
{
Auth::provider('eloquent-wp', function ($app, array $config) {
return new EloquentUserProvider(
new class implements Hasher {
private WordPressPasswordHasher $wpHasher;
public function __construct()
{
$this->wpHasher = new WordPressPasswordHasher();
}
public function check($value, $hashedValue, array $options = []): bool
{
return $this->wpHasher->check($value, $hashedValue);
}
public function make($value, array $options = []): string
{
return password_hash($value, PASSWORD_BCRYPT);
}
public function info($hashedValue): array
{
return password_get_info($hashedValue);
}
public function needsRehash($hashedValue, array $options = []): bool
{
// Rehash WordPress passwords to bcrypt on next login
return str_starts_with($hashedValue, '$P$');
}
},
$config['model']
);
});
}
URL Structure and SEO Preservation
Preserving SEO is critical. Search engines have indexed your WordPress URLs, and changing them without redirects will destroy your rankings.
URL Mapping Strategy
Create a comprehensive URL mapping:
<?php
// database/migrations/2026_01_25_create_redirects_table.php
Schema::create('redirects', function (Blueprint $table) {
$table->id();
$table->string('old_url')->unique()->index();
$table->string('new_url');
$table->integer('status_code')->default(301);
$table->integer('hits')->default(0);
$table->timestamp('last_hit_at')->nullable();
$table->timestamps();
});
// app/Http/Middleware/HandleRedirects.php
namespace App\Http\Middleware;
use App\Models\Redirect;
use Closure;
use Illuminate\Http\Request;
class HandleRedirects
{
public function handle(Request $request, Closure $next)
{
$path = '/' . ltrim($request->path(), '/');
$redirect = Redirect::where('old_url', $path)->first();
if ($redirect) {
// Track hit for analytics
$redirect->increment('hits');
$redirect->update(['last_hit_at' => now()]);
return redirect($redirect->new_url, $redirect->status_code);
}
return $next($request);
}
}
Generating Redirects During Migration
<?php
// Add to MigrateWordPressPosts command
private function createRedirect(WpPost $wpPost, Post $post): void
{
// WordPress URL patterns
$wpPermalink = $this->getWordPressPermalink($wpPost);
$laravelUrl = "/blog/{$post->slug}";
if ($wpPermalink !== $laravelUrl) {
Redirect::firstOrCreate(
['old_url' => $wpPermalink],
[
'new_url' => $laravelUrl,
'status_code' => 301,
]
);
}
// Also handle date-based permalinks if WordPress used them
$dateBasedUrl = $wpPost->post_date->format('/Y/m/d/') . $wpPost->post_name;
if ($dateBasedUrl !== $wpPermalink) {
Redirect::firstOrCreate(
['old_url' => $dateBasedUrl],
[
'new_url' => $laravelUrl,
'status_code' => 301,
]
);
}
}
private function getWordPressPermalink(WpPost $wpPost): string
{
// Match your WordPress permalink structure
// Common patterns: /%postname%/, /%year%/%monthnum%/%postname%/
// For simple /%postname%/ structure:
return "/{$wpPost->post_name}/";
}
SEO Meta Implementation
Preserve SEO metadata from Yoast or RankMath:
<?php
// app/View/Components/SeoMeta.php
namespace App\View\Components;
use Illuminate\View\Component;
class SeoMeta extends Component
{
public function __construct(
public string $title,
public string $description,
public ?string $image = null,
public string $type = 'article',
public ?string $canonicalUrl = null,
) {}
public function render()
{
return view('components.seo-meta');
}
}
// resources/views/components/seo-meta.blade.php
<title>{{ $title }}</title>
<meta name="description" content="{{ $description }}">
<link rel="canonical" href="{{ $canonicalUrl ?? url()->current() }}">
<!-- Open Graph -->
<meta property="og:title" content="{{ $title }}">
<meta property="og:description" content="{{ $description }}">
<meta property="og:type" content="{{ $type }}">
<meta property="og:url" content="{{ $canonicalUrl ?? url()->current() }}">
@if($image)
<meta property="og:image" content="{{ $image }}">
@endif
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $title }}">
<meta name="twitter:description" content="{{ $description }}">
@if($image)
<meta name="twitter:image" content="{{ $image }}">
@endif
// Usage in blade template
<x-seo-meta
:title="$post->meta_title ?? $post->title"
:description="$post->meta_description ?? $post->excerpt"
:image="$post->featured_image_url"
/>
XML Sitemap Generation
Replace WordPress sitemaps with Laravel-generated ones:
<?php
// routes/web.php
Route::get('/sitemap.xml', [SitemapController::class, 'index']);
Route::get('/sitemap-posts.xml', [SitemapController::class, 'posts']);
Route::get('/sitemap-pages.xml', [SitemapController::class, 'pages']);
// app/Http/Controllers/SitemapController.php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Page;
use Illuminate\Http\Response;
class SitemapController extends Controller
{
public function index(): Response
{
$content = view('sitemaps.index', [
'sitemaps' => [
['loc' => url('/sitemap-posts.xml'), 'lastmod' => Post::max('updated_at')],
['loc' => url('/sitemap-pages.xml'), 'lastmod' => Page::max('updated_at')],
],
]);
return response($content)
->header('Content-Type', 'application/xml');
}
public function posts(): Response
{
$posts = Post::query()
->published()
->select(['slug', 'updated_at'])
->orderByDesc('updated_at')
->get();
$content = view('sitemaps.posts', compact('posts'));
return response($content)
->header('Content-Type', 'application/xml');
}
}
Common Challenges and Solutions
After numerous WordPress to Laravel migrations, these are the issues that arise most frequently.
Challenge 1: Shortcode Hell
WordPress sites often have dozens of shortcodes from various plugins. Create a comprehensive shortcode converter:
<?php
// app/Services/ShortcodeConverter.php
namespace App\Services;
class ShortcodeConverter
{
private array $converters = [];
public function __construct()
{
$this->registerDefaultConverters();
}
public function registerConverter(string $shortcode, callable $converter): void
{
$this->converters[$shortcode] = $converter;
}
public function convert(string $content): string
{
// Match all shortcodes
$pattern = '/\[(\w+)([^\]]*)\](?:(.*?)\[\/\1\])?/s';
return preg_replace_callback($pattern, function ($matches) {
$shortcode = $matches[1];
$attributes = $this->parseAttributes($matches[2] ?? '');
$innerContent = $matches[3] ?? '';
if (isset($this->converters[$shortcode])) {
return ($this->converters[$shortcode])($attributes, $innerContent);
}
// Log unknown shortcodes
Log::info("Unknown shortcode: {$shortcode}", $attributes);
return "<!-- Unknown shortcode: {$shortcode} -->";
}, $content);
}
private function parseAttributes(string $attrString): array
{
$attributes = [];
preg_match_all('/(\w+)=["\']([^"\']*)["\']/', $attrString, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$attributes[$match[1]] = $match[2];
}
return $attributes;
}
private function registerDefaultConverters(): void
{
// YouTube embed
$this->registerConverter('youtube', function ($attrs, $content) {
$id = $attrs['id'] ?? $content;
return "<div class=\"video-embed\"><iframe src=\"https://www.youtube.com/embed/{$id}\" allowfullscreen></iframe></div>";
});
// Button
$this->registerConverter('button', function ($attrs, $content) {
$url = $attrs['url'] ?? '#';
$class = $attrs['class'] ?? 'btn btn-primary';
return "<a href=\"{$url}\" class=\"{$class}\">{$content}</a>";
});
// Code block
$this->registerConverter('code', function ($attrs, $content) {
$lang = $attrs['language'] ?? 'plaintext';
return "<pre><code class=\"language-{$lang}\">{$content}</code></pre>";
});
// Columns (common page builder shortcode)
$this->registerConverter('column', function ($attrs, $content) {
$width = $attrs['width'] ?? '50';
return "<div class=\"column\" style=\"width: {$width}%\">{$content}</div>";
});
}
}
Challenge 2: Image URL Rewrites
Content often contains hardcoded WordPress upload URLs:
<?php
// app/Services/ContentUrlRewriter.php
namespace App\Services;
class ContentUrlRewriter
{
private string $oldDomain;
private string $newDomain;
private string $oldUploadsPath;
private string $newUploadsPath;
public function __construct()
{
$this->oldDomain = config('migration.wordpress_domain');
$this->newDomain = config('app.url');
$this->oldUploadsPath = '/wp-content/uploads/';
$this->newUploadsPath = '/storage/uploads/';
}
public function rewrite(string $content): string
{
// Rewrite image src attributes
$content = preg_replace_callback(
'/<img([^>]*)src=["\']([^"\']+)["\']([^>]*)>/i',
[$this, 'rewriteImageSrc'],
$content
);
// Rewrite internal links
$content = preg_replace_callback(
'/<a([^>]*)href=["\']([^"\']+)["\']([^>]*)>/i',
[$this, 'rewriteLink'],
$content
);
return $content;
}
private function rewriteImageSrc(array $matches): string
{
$src = $matches[2];
$newSrc = $this->rewriteUrl($src);
return "<img{$matches[1]}src=\"{$newSrc}\"{$matches[3]}>";
}
private function rewriteLink(array $matches): string
{
$href = $matches[2];
// Only rewrite internal links
if (str_contains($href, $this->oldDomain)) {
$href = str_replace($this->oldDomain, $this->newDomain, $href);
}
return "<a{$matches[1]}href=\"{$href}\"{$matches[3]}>";
}
private function rewriteUrl(string $url): string
{
// Replace domain
$url = str_replace($this->oldDomain, $this->newDomain, $url);
// Replace uploads path
$url = str_replace($this->oldUploadsPath, $this->newUploadsPath, $url);
return $url;
}
}
Challenge 3: Plugin Functionality Replacement
Document which plugins need Laravel equivalents:
| WordPress Plugin | Laravel Solution |
|---|---|
| Yoast SEO | Custom meta fields + artesaos/seotools |
| Contact Form 7 | Custom forms + validation |
| WooCommerce | Lunar, Bagisto, or custom |
| Advanced Custom Fields | JSON columns or dedicated tables |
| Wordfence | Laravel security middleware |
| WP Super Cache | Response caching + Redis |
| Gravity Forms | Custom forms + Livewire |
| The Events Calendar | Custom events system |
Challenge 4: Maintaining Dual Operation
During migration, you may need both systems running:
<?php
// Nginx configuration for gradual cutover
// Proxy certain paths to Laravel, others to WordPress
server {
listen 80;
server_name example.com;
# New Laravel-powered sections
location /blog {
proxy_pass http://laravel-backend;
}
location /api {
proxy_pass http://laravel-backend;
}
# Legacy WordPress sections (during transition)
location / {
proxy_pass http://wordpress-backend;
}
}
Post-Migration Checklist
Before going live, verify everything works:
Content Verification
- All posts migrated with correct content
- Categories and tags preserved
- Author attributions correct
- Featured images display properly
- Internal links work
- Embedded media plays
SEO Verification
- 301 redirects working for all old URLs
- Meta titles and descriptions preserved
- XML sitemap generating correctly
- robots.txt configured
- Google Search Console verified
- Canonical URLs set correctly
Functionality Verification
- User login works (WordPress password compatibility)
- Contact forms submit correctly
- Comments migrated (if applicable)
- Search functionality works
- RSS feeds generating
Performance Verification
- Page load times acceptable
- No N+1 query issues
- Images optimized
- Caching configured
- CDN configured (if applicable)
Monitoring Setup
- Error tracking configured (Sentry, Bugsnag)
- 404 monitoring active
- Performance monitoring active
- Redirect hit tracking
Key Takeaways
Migrating from WordPress to Laravel is a significant undertaking, but the benefits of a custom, performant, and maintainable application often justify the investment:
- Plan extensively: Content audits, feature mapping, and database design before writing migration code
- Build resumable migrations: Commands that can restart from where they left off
- Preserve SEO meticulously: 301 redirects for every changed URL, metadata preservation, sitemap generation
- Handle WordPress quirks: Shortcodes, serialized data, unique password hashing
- Test comprehensively: Automated testing for content integrity and redirect verification
- Monitor post-launch: Track 404s, redirect hits, and search console data for 90 days minimum
The effort invested in planning and careful execution pays dividends in a faster, more secure, and more maintainable application.
Need help migrating your WordPress site to Laravel? I specialize in complex migrations with 14 years of PHP experience building Laravel applications. My Migration Services include comprehensive content audits, SEO preservation strategies, and custom Laravel development. Schedule a free consultation to discuss your migration project.
Related Reading:
- Migrating Legacy Laravel Apps: Lessons from 14 Years
- Laravel API Development Best Practices
- Laravel Octane Performance Optimization
- Claude Code Laravel Development Workflow
- AWS Cost Optimization for PHP Apps
External Resources:
- Laravel 12 Migrations Documentation - Official Laravel Documentation
- Laravel 12 Database Seeding - Official Laravel Documentation
- WP-CLI Database Export - WordPress CLI Reference
- WordPress Database Structure - WP Staging Documentation
- Spatie Laravel Media Library - Media handling package

Richard Joseph Porter
Senior Laravel Developer with 14+ years of experience building scalable web applications. Specializing in PHP, Laravel, Vue.js, and AWS cloud infrastructure. Based in Cebu, Philippines, I help businesses modernize legacy systems and build high-performance APIs.
Need Help Upgrading Your Laravel App?
I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.
Related Articles
Migrate Legacy Laravel Apps: 14 Years of Lessons
Upgrade Laravel 4.x-8.x to modern versions safely. Real-world migration strategies, testing patterns, and lessons from 14 years of PHP development.
Senior Laravel Developer Interview Questions: A Complete Guide
Master 20 technical interview questions for senior Laravel roles. Learn what interviewers look for and how to articulate your expertise clearly.
Laravel API Development Best Practices Guide
Master REST API development with Laravel. Learn authentication, versioning, error handling, rate limiting, and performance optimization from 14 years of PHP experience.