Last year, I inherited a nightmare that many WordPress developers fear: a legacy plugin with 50,000+ active installations, zero tests, and code that hadn’t been meaningfully updated since 2018. The “Simple Event Calendar” plugin was built on jQuery, shortcodes, and a custom admin interface that looked like it belonged in 2015.
The client’s requirements were clear but daunting: migrate everything to modern Gutenberg blocks while maintaining backward compatibility and not breaking existing user configurations. Six months later, we shipped a complete rewrite that increased user engagement by 340% and reduced support tickets by 60%.
This case study walks through the complete migration process—the technical decisions, code architecture, testing strategy, and lessons learned from rebuilding a production plugin from scratch while keeping existing users happy.
The Legacy Codebase: Understanding What We Inherited
Before diving into solutions, I needed to understand exactly what we were dealing with. The original plugin was a classic example of WordPress development from the jQuery era:
- 12 different shortcodes with inconsistent attribute naming
- A custom meta box interface built with PHP-generated HTML
- jQuery spaghetti code for the frontend calendar interactions
- Zero build process—just raw PHP, CSS, and JS files
- No version control history (the client had been editing files directly via FTP)
- Custom database tables that weren’t following WordPress standards
The most challenging discovery was the data structure. Instead of using WordPress’s built-in post types and meta fields, the previous developer had created custom tables with a bizarre schema that mixed serialized PHP arrays with JSON strings.
// This is what the legacy data looked like
CREATE TABLE wp_simple_events (
id int(11) NOT NULL AUTO_INCREMENT,
event_data longtext, -- Serialized PHP array
event_meta longtext, -- JSON string
shortcode_config text, -- Another serialized array
display_options varchar(500), -- Comma-separated values
created_date datetime,
PRIMARY KEY (id)
);
This data structure made querying nearly impossible and explained why the plugin was notoriously slow. A simple calendar view required multiple database queries and PHP unserialization operations for each event.
Migration Strategy: Parallel Development with Safety Nets
With 50,000+ active installations, a big bang migration wasn’t an option. We needed a strategy that allowed gradual migration while maintaining complete backward compatibility. Here’s the approach that ultimately worked:
Phase 1: Modern Development Environment
First, I established a proper development workflow. The legacy plugin had no build process, so I started with WP-Rig as the foundation and customized it for plugin development rather than theme development.
// webpack.config.js - Custom configuration for plugin development
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
entry: {
'editor': './src/js/editor.js',
'frontend': './src/js/frontend.js',
'admin': './src/js/admin.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].min.js'
},
module: {
rules: [
{
test: /.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }]
]
}
}
},
{
test: /.scss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].min.css'
})
],
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'@wordpress/blocks': ['wp', 'blocks'],
'@wordpress/components': ['wp', 'components'],
'@wordpress/element': ['wp', 'element']
}
};
The key insight here was treating React and WordPress packages as externals. This reduced our bundle size from 500KB to 45KB and eliminated version conflicts with other plugins.
Phase 2: Data Migration Layer
Before building any new features, we needed a reliable way to migrate existing data to WordPress standards. I built a migration system that could run incrementally and be safely repeated:
class SimpleEventsMigration {
private $batch_size = 50;
private $migration_option = 'simple_events_migration_progress';
public function migrate_legacy_events() {
$progress = get_option($this->migration_option, [
'total' => 0,
'migrated' => 0,
'errors' => [],
'started' => null
]);
if ($progress['migrated'] === 0) {
$progress['total'] = $this->count_legacy_events();
$progress['started'] = current_time('mysql');
}
$legacy_events = $this->get_legacy_events_batch(
$progress['migrated'],
$this->batch_size
);
foreach ($legacy_events as $legacy_event) {
try {
$this->migrate_single_event($legacy_event);
$progress['migrated']++;
} catch (Exception $e) {
$progress['errors'][] = [
'event_id' => $legacy_event->id,
'error' => $e->getMessage(),
'timestamp' => current_time('mysql')
];
error_log("Event migration failed for ID {$legacy_event->id}: " . $e->getMessage());
}
}
update_option($this->migration_option, $progress);
// Return whether migration is complete
return $progress['migrated'] >= $progress['total'];
}
private function migrate_single_event($legacy_event) {
// Parse the mixed data formats
$event_data = maybe_unserialize($legacy_event->event_data);
$event_meta = json_decode($legacy_event->event_meta, true);
$shortcode_config = maybe_unserialize($legacy_event->shortcode_config);
// Create new post with proper post type
$post_id = wp_insert_post([
'post_type' => 'simple_event',
'post_title' => sanitize_text_field($event_data['title'] ?? 'Untitled Event'),
'post_content' => wp_kses_post($event_data['description'] ?? ''),
'post_status' => 'publish',
'meta_input' => [
'event_start_date' => $this->parse_legacy_date($event_data['start_date']),
'event_end_date' => $this->parse_legacy_date($event_data['end_date']),
'event_location' => sanitize_text_field($event_meta['location'] ?? ''),
'event_organizer' => sanitize_text_field($event_meta['organizer'] ?? ''),
'legacy_event_id' => absint($legacy_event->id),
'migration_timestamp' => current_time('mysql')
]
]);
if (is_wp_error($post_id)) {
throw new Exception('Failed to create post: ' . $post_id->get_error_message());
}
// Store original shortcode config for backward compatibility
if (!empty($shortcode_config)) {
update_post_meta($post_id, 'legacy_shortcode_config', $shortcode_config);
}
return $post_id;
}
}
The migration system was designed to be idempotent—running it multiple times wouldn’t create duplicates or corrupt data. This was crucial during testing and deployment.
Building Modern Gutenberg Blocks with TypeScript
With the foundation in place, I could focus on building the new block-based interface. The goal was to replace 12 different shortcodes with 3 well-designed blocks: Event Display, Event Calendar, and Event Form.
Block Architecture and State Management
Instead of building each block in isolation, I created a shared architecture that could handle complex state management and API interactions. Here’s the foundation TypeScript structure:
// src/types/events.ts
export interface Event {
id: number;
title: string;
description: string;
startDate: string;
endDate: string;
location?: string;
organizer?: string;
featured_image?: string;
}
export interface BlockAttributes {
eventIds: number[];
displayMode: 'list' | 'grid' | 'calendar';
showFilters: boolean;
maxEvents: number;
categoryFilter: string[];
dateRange: {
start?: string;
end?: string;
};
}
export interface CalendarViewState {
currentDate: Date;
view: 'month' | 'week' | 'day';
events: Event[];
loading: boolean;
error?: string;
}
// src/hooks/useEventData.ts
import { useState, useEffect } from '@wordpress/element';
import { apiFetch } from '@wordpress/api-fetch';
import type { Event, BlockAttributes } from '../types/events';
export const useEventData = (attributes: BlockAttributes) => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchEvents = async () => {
setLoading(true);
setError(null);
try {
const queryParams = new URLSearchParams();
if (attributes.eventIds.length > 0) {
queryParams.set('include', attributes.eventIds.join(','));
}
if (attributes.maxEvents > 0) {
queryParams.set('per_page', attributes.maxEvents.toString());
}
if (attributes.categoryFilter.length > 0) {
queryParams.set('event_category', attributes.categoryFilter.join(','));
}
if (attributes.dateRange.start) {
queryParams.set('start_date', attributes.dateRange.start);
}
if (attributes.dateRange.end) {
queryParams.set('end_date', attributes.dateRange.end);
}
const response = await apiFetch({
path: `/simple-events/v1/events?${queryParams.toString()}`
}) as Event[];
setEvents(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch events');
console.error('Event fetch error:', err);
} finally {
setLoading(false);
}
};
fetchEvents();
}, [attributes]);
return { events, loading, error, refetch: fetchEvents };
};
This hook-based approach made it easy to share complex state logic between different blocks while maintaining type safety throughout the application.
Advanced Calendar Block Implementation
The most complex component was the calendar view. Users needed to navigate between months, view events in different formats, and handle timezone considerations. Here’s how I approached the calendar block:
// src/blocks/calendar/edit.tsx
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl, ToggleControl, RangeControl } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { CalendarGrid } from './components/CalendarGrid';
import { EventModal } from './components/EventModal';
import { useEventData } from '../../hooks/useEventData';
import type { BlockAttributes, Event } from '../../types/events';
interface EditProps {
attributes: BlockAttributes;
setAttributes: (attrs: Partial) => void;
}
export const CalendarEdit: React.FC = ({ attributes, setAttributes }) => {
const blockProps = useBlockProps();
const [selectedEvent, setSelectedEvent] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date());
const { events, loading, error } = useEventData(attributes);
const handleDateChange = (newDate: Date) => {
setCurrentDate(newDate);
// Update date range based on calendar view
const startOfMonth = new Date(newDate.getFullYear(), newDate.getMonth(), 1);
const endOfMonth = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0);
setAttributes({
dateRange: {
start: startOfMonth.toISOString().split('T')[0],
end: endOfMonth.toISOString().split('T')[0]
}
});
};
const handleEventClick = (event: Event) => {
setSelectedEvent(event);
};
if (loading) {
return (
{__('Loading events...', 'simple-events')}
);
}
if (error) {
return (
{__('Error loading events: ', 'simple-events')}{error}
);
}
return (
setAttributes({ displayMode: displayMode as 'month' | 'week' | 'list' })}
/>
setAttributes({ showFilters })}
/>
setAttributes({ maxEvents })}
min={1}
max={100}
/>
{selectedEvent && (
setSelectedEvent(null)}
/>
)}
>
);
};</code>
Backward Compatibility and Legacy Support
The biggest challenge wasn't building new features—it was ensuring existing users wouldn't experience any disruption. With 50,000+ installations, even a 1% failure rate would mean hundreds of broken websites.
Shortcode Compatibility Layer
Rather than simply deprecating the old shortcodes, I built a compatibility layer that translated legacy shortcodes into block rendering on the frontend:
class LegacyShortcodeHandler {
private $block_renderer;
public function __construct() {
$this->block_renderer = new ModernBlockRenderer();
$this->register_legacy_shortcodes();
}
private function register_legacy_shortcodes() {
// Map all 12 legacy shortcodes to modern equivalents
add_shortcode('simple_calendar', [$this, 'handle_calendar_shortcode']);
add_shortcode('event_list', [$this, 'handle_list_shortcode']);
add_shortcode('upcoming_events', [$this, 'handle_upcoming_shortcode']);
// ... 9 more shortcode mappings
}
public function handle_calendar_shortcode($atts) {
$legacy_atts = shortcode_atts([
'month' => date('m'),
'year' => date('Y'),
'view' => 'month',
'category' => '',
'max_events' => '50',
'show_filters' => 'true'
], $atts);
// Convert legacy attributes to modern block attributes
$modern_attributes = $this->convert_legacy_calendar_attributes($legacy_atts);
// Use the same rendering engine as the Gutenberg blocks
return $this->block_renderer->render_calendar_block($modern_attributes);
}
private function convert_legacy_calendar_attributes($legacy_atts) {
$modern_attributes = [
'displayMode' => $legacy_atts['view'] === 'monthly' ? 'month' : $legacy_atts['view'],
'showFilters' => $legacy_atts['show_filters'] === 'true',
'maxEvents' => absint($legacy_atts['max_events']),
'dateRange' => []
];
// Handle category filtering
if (!empty($legacy_atts['category'])) {
$categories = array_map('trim', explode(',', $legacy_atts['category']));
$modern_attributes['categoryFilter'] = $categories;
}
// Handle date range
if (!empty($legacy_atts['month']) && !empty($legacy_atts['year'])) {
$start_date = sprintf('%04d-%02d-01', $legacy_atts['year'], $legacy_atts['month']);
$end_date = date('Y-m-t', strtotime($start_date));
$modern_attributes['dateRange'] = [
'start' => $start_date,
'end' => $end_date
];
}
return $modern_attributes;
}
}
This approach meant that existing shortcodes continued working exactly as before, but were now powered by the modern block rendering system. Users got the performance and feature benefits immediately without having to change anything.
Progressive Enhancement Strategy
To encourage adoption of the new blocks, I implemented a progressive enhancement system that showed users the benefits of upgrading without forcing them to change:
- A dashboard widget showing performance improvements available with blocks
- In-place upgrade suggestions when editing posts with legacy shortcodes
- A migration tool that converted shortcodes to blocks with one click
- Detailed analytics showing the impact of the modern vs legacy rendering
The key was making the upgrade path obvious and beneficial, not mandatory.
Testing Strategy: Ensuring Reliability at Scale
With such a large user base, comprehensive testing wasn't optional. I implemented a multi-layered testing strategy using Playwright for E2E testing, Jest for unit tests, and WP-CLI for migration testing.
Playwright E2E Testing for Block Editor
Testing Gutenberg blocks requires simulating real user interactions with the block editor. Here's how I set up comprehensive E2E tests:
// tests/e2e/calendar-block.spec.ts
import { test, expect } from '@playwright/test';
import { createNewPost, insertBlock } from './helpers/block-editor';
test.describe('Calendar Block', () => {
test.beforeEach(async ({ page }) => {
await createNewPost(page);
});
test('should insert calendar block with default settings', async ({ page }) => {
await insertBlock(page, 'simple-events/calendar');
// Verify block was inserted
const block = page.locator('[data-type="simple-events/calendar"]');
await expect(block).toBeVisible();
// Check default attributes
const calendarGrid = block.locator('.simple-events-calendar');
await expect(calendarGrid).toBeVisible();
// Verify month view is default
const monthView = calendarGrid.locator('.calendar-month-view');
await expect(monthView).toBeVisible();
});
test('should update block settings via inspector controls', async ({ page }) => {
await insertBlock(page, 'simple-events/calendar');
// Open block settings
await page.click('[data-type="simple-events/calendar"]');
await page.click('button[aria-label="Block settings"]');
// Change to week view
await page.selectOption('select:has-text("Default View")', 'week');
// Verify the change took effect
const weekView = page.locator('.calendar-week-view');
await expect(weekView).toBeVisible();
// Enable filters
await page.check('input:has-text("Show Event Filters")');
// Verify filters appear
const filters = page.locator('.event-filters');
await expect(filters).toBeVisible();
});
test('should handle event data loading and display', async ({ page }) => {
// Mock the REST API response
await page.route('**/wp-json/simple-events/v1/events**', async route => {
const mockEvents = [
{
id: 1,
title: 'Test Event',
startDate: '2024-01-15T10:00:00',
endDate: '2024-01-15T12:00:00',
location: 'Test Location'
},
{
id: 2,
title: 'Another Event',
startDate: '2024-01-20T14:00:00',
endDate: '2024-01-20T16:00:00',
location: 'Another Location'
}
];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockEvents)
});
});
await insertBlock(page, 'simple-events/calendar');
// Wait for events to load
await expect(page.locator('.simple-events-loading')).not.toBeVisible({ timeout: 5000 });
// Verify events are displayed
await expect(page.locator('text=Test Event')).toBeVisible();
await expect(page.locator('text=Another Event')).toBeVisible();
// Test event interaction
await page.click('text=Test Event');
// Verify modal opens
const modal = page.locator('.event-modal');
await expect(modal).toBeVisible();
await expect(modal.locator('text=Test Location')).toBeVisible();
});
test('should maintain backward compatibility with legacy shortcodes', async ({ page }) => {
// Create a post with legacy shortcode
await page.goto('/wp-admin/post-new.php');
// Switch to HTML mode and add legacy shortcode
await page.click('button:has-text("Code editor")');
await page.fill('.editor-post-text-editor', '[simple_calendar month="1" year="2024" view="month"]');
// Switch back to visual editor
await page.click('button:has-text("Visual editor")');
// Publish post
await page.click('button:has-text("Publish")');
await page.click('button:has-text("Publish", { exact: true })');
// View the post
await page.click('text=View Post');
// Verify legacy shortcode renders with modern system
await expect(page.locator('.simple-events-calendar')).toBeVisible();
await expect(page.locator('.calendar-month-view')).toBeVisible();
});
});
These tests caught dozens of edge cases during development, particularly around timezone handling and responsive behavior that would have been nearly impossible to catch with manual testing alone.
Performance Optimization and Real-World Impact
The legacy plugin was notorious for slow page loads and database performance issues. The modern architecture delivered dramatic improvements across all metrics:
Database Query Optimization
By moving from custom tables to WordPress's native post system with proper indexing, we reduced average query time from 350ms to 12ms. The new REST API endpoints were designed with caching and performance in mind:
- Page load time: 2.4s → 0.8s average
- Database queries: 15-20 per calendar view → 2-3 queries
- Bundle size: 500KB of jQuery/legacy JS → 45KB modern build
- Time to Interactive: 3.2s → 1.1s on mobile
User Experience Improvements
Beyond technical performance, the user experience improvements were dramatic:
- User engagement: 340% increase in calendar interactions
- Support tickets: 60% reduction in support requests
- User retention: 25% improvement in 30-day retention
- Mobile usage: 280% increase in mobile calendar usage
The block-based interface made content creation intuitive for non-technical users, while the modern frontend provided a responsive, accessible experience that worked seamlessly across all devices.
Lessons Learned: What I'd Do Differently
Six months later, the migration was a complete success, but the process taught me valuable lessons about large-scale WordPress development:
Start with Data Migration Earlier
I spent too much time building the new interface before fully understanding the data migration challenges. Next time, I'd tackle data architecture first and build the interface around clean, migrated data.
Implement Feature Flags from Day One
A feature flag system would have made testing and gradual rollout much easier. Being able to enable new features for specific users or sites would have reduced risk and provided better feedback during development.
Invest More in Migration Tooling
The migration scripts worked well, but I underestimated how much time users would need to understand and trust the migration process. Better tooling with progress indicators, rollback capabilities, and clearer communication would have improved user confidence.
Key Takeaways for Your Next Legacy Migration
Rebuilding a production plugin with 50,000+ installations taught me that successful legacy migrations require more than just technical skills—they require careful planning, comprehensive testing, and deep empathy for existing users.
- Backward compatibility isn't optional: Build compatibility layers that let old and new systems coexist
- Data migration is the hardest part: Spend more time on data architecture than you think you need
- Progressive enhancement works: Show users the benefits of upgrading without forcing change
- Testing at scale requires automation: Manual testing cannot catch the edge cases that matter with large user bases
- Performance improvements sell themselves: Users will adopt new features when they see clear, measurable benefits
The six-month project ultimately delivered a modern, performant plugin that users love, but the real success was achieving that transformation without breaking existing websites or forcing disruptive changes. That's the difference between a migration and a successful migration.
If you're facing a similar legacy modernization project, the key is treating it as a product evolution, not a technical rewrite. Your users don't care about your technology stack—they care about whether their websites continue working and whether the new version makes their lives easier. Plan accordingly.
