Featured Post

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.

Richard Joseph Porter
23 min read
laravelwordpressphpmigrationweb-developmentdatabase

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_postmeta for 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:

External Resources:

Richard Joseph Porter - Senior PHP and Laravel Developer, author of technical articles on web development

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.