Skip links
Architectural blueprint on drafting table with system diagrams and red pencil

How We Built harborsoftware.com: WordPress Automation at Scale

When we decided to rebuild harborsoftware.com in late 2025, we faced an interesting meta-challenge: we are a software company that builds automation systems for clients, and our own website was a manually maintained WordPress site with inconsistent styling, outdated content, and a deployment process that consisted of someone logging into the WordPress admin panel and clicking buttons for 45 minutes. It was the cobbler’s children problem, and we decided to fix it by applying the same automation principles we use for client work.

Article Overview

How We Built harborsoftware.com: WordPress Automation at …

5 sections · Reading flow

01
Why WordPress, Not Next.js or a Headless CMS
02
The Automation Architecture
03
The Safety Toolset
04
Deployment to Production
05
Lessons for WordPress Automation

HARBOR SOFTWARE · Engineering Insights

The result is a WordPress site where every page is defined as code, every deployment is automated via scripts, every content change is version-controlled in a Git repository, and we can rebuild the entire site from scratch—theme, plugins, content, menus, headers, footers, and all—in under 10 minutes from a fresh WordPress installation. This post describes the technical approach: what we built, why we chose WordPress over a headless CMS or static site generator, and the specific tools and patterns that make WordPress automation reliable rather than fragile.

Why WordPress, Not Next.js or a Headless CMS

The obvious question: why would a software company that builds Next.js and Python applications choose WordPress for its own website? We evaluated Next.js with Sanity, Astro with MDX, and WordPress with Elementor. Three factors drove the decision:

Reason 1: Non-technical team members need to edit content independently. Our marketing and sales teams update case studies, blog posts, team bios, and service descriptions on a weekly basis. They are comfortable with WordPress’s visual editor—they have used it before, they understand blocks and media uploads, and they can make changes without asking an engineer for help. They would not be comfortable submitting pull requests to a Git repository, editing content through a headless CMS’s admin panel that requires understanding content models, references, and preview builds, or waiting for an engineer to merge their content PR before it goes live. WordPress’s editing experience is not the best in the world, but the learning curve is 15 minutes, not 2 hours, and the barrier to making a change is clicking “Update” rather than navigating a deployment pipeline.

Reason 2: The plugin ecosystem solves 80% of our needs out of the box. SEO (Yoast), contact forms (Contact Form 7), page building with visual design (Elementor), caching and performance optimization (LiteSpeed Cache), header/footer templating (Header Footer Elementor), mega menus (Max Mega Menu), analytics integration—all solved by mature, well-maintained plugins with millions of installations and years of production use. Building equivalent functionality in a custom Next.js application would take weeks of development time and create ongoing maintenance burden for security updates, compatibility fixes, and feature requests. The WordPress ecosystem’s breadth is a genuine competitive advantage for marketing websites, even if it comes with trade-offs in developer experience.

Reason 3: WordPress is the devil we know. We have operated WordPress sites for 5 years across multiple client projects and our own properties. We know the failure modes (plugin conflicts, database corruption from malformed meta saves, PHP memory limits on large pages), the security posture (keep plugins updated, use strong admin passwords, disable XML-RPC, harden wp-admin), the performance characteristics (install a caching plugin, use a CDN, optimize images), and the hosting requirements (PHP 8.x, MySQL/MariaDB, enough memory for Elementor’s rendering engine). Choosing a technology stack for a marketing website should optimize for operational predictability, not technical elegance. WordPress is not elegant, but it is predictable, and predictability is what matters for a site that needs to be up and editable 24/7.

The Automation Architecture

Our approach treats the WordPress site as infrastructure that can be provisioned, configured, and content-populated entirely through code and scripts. No manual admin panel clicks in the setup or deployment process. The components:

WP-CLI for Everything

WP-CLI is the command-line interface for WordPress. Every action that can be performed in the WordPress admin panel can also be performed via WP-CLI: installing plugins, creating posts and pages, setting options, managing menus, flushing caches, managing users. We use WP-CLI as the foundation of all our automation scripts.

# Install and activate plugins
wp plugin install elementor woocommerce wordpress-seo 
  header-footer-elementor --activate

# Create a page with specific template and slug
wp post create --post_type=page --post_title="Case Studies" 
  --post_status=publish --post_name=case-studies

# Set the homepage to a specific page
wp option update page_on_front $(wp post list --post_type=page 
  --name=home --field=ID)
wp option update show_on_front page

# Build navigation menus
wp menu create "Main Menu"
wp menu item add-custom main-menu "Home" "/"
wp menu item add-custom main-menu "Case Studies" "/case-studies/"
wp menu item add-custom main-menu "Expertise" "/expertise/"
wp menu item add-custom main-menu "Blog" "/blog/"
wp menu item add-custom main-menu "Contact" "/contact/"
wp menu location assign main-menu menu-1

On our local development environment (LocalWP on Windows), WP-CLI is not in the system PATH, so we use a wrapper that specifies the PHP binary and configuration paths explicitly. On the production server (cPanel hosting), WP-CLI is available as a system command. The scripts are written to work in both environments with a configuration variable that sets the correct WP-CLI invocation pattern.

Elementor Templates as Code

Elementor stores page layouts as JSON in the _elementor_data post meta field. This JSON defines every container, widget, and styling property on the page—a complete, serialized representation of the visual design. We export these templates from pages we design in the Elementor visual editor, commit them to our repository, and can import them via the Elementor CLI to recreate pages. This means our page designs are version-controlled, diffable (you can see exactly what changed between two versions of a page design), and reproducible.

The critical challenge with Elementor automation is data integrity. Elementor’s JSON structure is deeply nested, with specific requirements for element IDs (8-character hex strings), container hierarchies (every container must have an elements array, even if empty), and widget types (widget settings must match the expected schema for that widget type). Saving malformed JSON—or saving valid JSON without proper escaping—causes Elementor to “collapse” the page into a single text block, destroying the layout entirely. We learned this the hard way (twice, on production pages) and built a safe-save pipeline that validates the JSON structure, creates a backup, saves with proper escaping, and verifies the round-trip before confirming success:

function safe_elementor_save(int $post_id, array $data): bool {
    // Step 1: Validate structure before saving
    if (!validate_elementor_structure($data)) {
        error_log("ABORT: Invalid Elementor structure for post $post_id");
        return false;
    }
    
    // Step 2: Backup current data
    $current = get_post_meta($post_id, '_elementor_data', true);
    $backup_key = '_elementor_data_backup_' . time();
    update_post_meta($post_id, $backup_key, $current);
    
    // Step 3: Encode and save with wp_slash (CRITICAL)
    $json = wp_json_encode($data, JSON_UNESCAPED_UNICODE);
    $result = update_post_meta(
        $post_id, '_elementor_data', wp_slash($json)
    );
    
    // Step 4: Verify round-trip integrity
    $saved = get_post_meta($post_id, '_elementor_data', true);
    $decoded = json_decode($saved, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        // Rollback on failure
        update_post_meta($post_id, '_elementor_data', $current);
        error_log("ROLLBACK: JSON decode failed after save");
        return false;
    }
    
    // Step 5: Verify element ID set is unchanged
    $original_ids = extract_all_element_ids($data);
    $saved_ids = extract_all_element_ids($decoded);
    if ($original_ids !== $saved_ids) {
        update_post_meta($post_id, '_elementor_data', $current);
        error_log("ROLLBACK: Element IDs changed during save");
        return false;
    }
    
    return true;
}

The wp_slash() call is the single most important detail in WordPress Elementor automation. WordPress’s update_post_meta() function calls wp_unslash() on the value before storing it, which strips backslashes from escaped characters. Since JSON strings frequently contain escaped quotes, newlines, and other characters (especially when the JSON contains HTML content from Elementor widgets), saving without wp_slash() produces invalid JSON that Elementor cannot parse. When Elementor encounters invalid JSON in _elementor_data, it falls back to rendering the raw post content—which produces a page with a single text block containing the mangled JSON. This is the number one cause of Elementor page corruption in automated workflows, and we have documented it extensively in our project guidelines to prevent future occurrences.

Content as JSON Files

Blog posts, case studies, and team bios are stored as JSON files in our repository. A deployment script reads these files and creates or updates the corresponding WordPress posts via WP-CLI. This means content changes go through the same version control workflow as code changes: create a branch, make the edit, submit a PR, review the content, merge, and deploy.

// blog-batch-example.json
[
  {
    "title": "Building for Scale: Architecture Decisions That Compound",
    "slug": "building-for-scale-architecture-decisions-that-compound",
    "date": "2025-11-21 09:00:00",
    "content": "<p>In the past 5 years, Harbor Software...</p>"
  }
]

The blog loader script is idempotent: if a post with the specified slug already exists, it updates the existing post’s title, date, and content rather than creating a duplicate. This makes deployments safe to retry and eliminates the “did I already run this?” uncertainty that plagues manual deployment processes. The script also sets post metadata (author, categories, featured image) and handles HTML entity encoding to prevent content corruption during import.

The Build Scripts

We have a collection of PHP scripts, executed via wp eval-file, that build specific components of the site. Each script is self-contained (it includes all the functions it needs or requires a shared config file) and idempotent (running it twice produces the same result as running it once):

  • build-homepage.php — Constructs the homepage layout with 9 sections (hero slider, services overview, case studies grid, expertise highlights, blog posts, trust badges, etc.) using Elementor containers and widgets with deterministic element IDs
  • build-header.php — Creates the global header template with logo, navigation, search, and contact CTA using the Header Footer Elementor plugin
  • build-menus.php — Clears and rebuilds the navigation menu structure from a defined list of pages and categories
  • harbor-blog-loader.php — Imports blog posts from JSON batch files, creating or updating as needed
  • harbor-case-study-pages.php — Creates individual case study pages from structured data files with consistent templates

A full site rebuild runs all scripts in sequence:

#!/bin/bash
set -e  # Exit on any error

echo "Building Harbor Software website..."

wp eval-file build-header.php
wp eval-file build-menus.php
wp eval-file build-homepage.php
wp eval-file harbor-blog-loader.php
wp eval-file harbor-case-study-pages.php

# Regenerate Elementor CSS from updated _elementor_data
wp elementor flush-css
wp cache flush

echo "Build complete."

The Safety Toolset

Automating WordPress page building is inherently risky because a single malformed write to _elementor_data can destroy a page’s layout. A manual edit in the Elementor UI has built-in undo and revision history. A programmatic write has neither unless you build it yourself. We built a safety toolset (14 PHP scripts in a wp-scripts/ directory) that enforces safe operations:

  • inventory.php: Generates a complete map of every Elementor element on a page—element IDs, types, widget types, content previews, CSS classes, nesting depth. This is the “before” snapshot that we compare against after any modification to verify nothing was accidentally changed or deleted.
  • patch.php: Applies a single settings change to a specific element (identified by element ID), with mandatory dry-run mode, automatic backup creation, and post-save validation including element ID set comparison. The patch script modifies content (heading text, image URLs, button links, shortcode strings) but never structure (container hierarchy, widget order, element types).
  • batch-patch.php: Applies multiple patches in a single atomic operation—all succeed or all fail, with automatic rollback on any error. This prevents partial updates that leave a page in an inconsistent state.
  • backup.php: Creates and manages timestamped backups of Elementor data. Every write operation creates a backup first. Backups can be listed, restored, and pruned by age.
  • validate.php: Verifies that Elementor data is structurally valid—every element has an 8-character hex ID, every container has an elements array, widget types are recognized, and the element tree is well-formed with no orphaned nodes.

The core safety principle we follow: layout is immutable, content is mutable. Our automation scripts can change what a heading says, what image is displayed, or what URL a button links to. They cannot add new sections, remove existing ones, reorder containers, or change the page’s structural layout. Structural changes require either rebuilding the page from scratch using build-page.php with a new page definition, or manual editing in the Elementor visual editor. This constraint eliminates the most dangerous class of automation errors—accidentally destroying a page’s layout—at the cost of requiring manual intervention for structural changes, which are infrequent (monthly at most) and high-stakes enough to warrant human oversight anyway.

Deployment to Production

The production site runs on cPanel shared hosting (harborsoftware.com). Deployment is handled via SSH, with WP-CLI available system-wide on the server. The deployment process:

  1. Build and test locally on LocalWP
  2. Push content JSON files and build scripts to the Git repository
  3. SSH to the production server and pull the latest scripts
  4. Run the build scripts via wp eval-file
  5. Flush Elementor CSS and caches
  6. Verify the site visually in a browser

For blog post updates (the most common deployment), the process is even simpler: push the new blog batch JSON file, SSH to the server, run the blog loader script, and flush caches. Total deployment time for a blog post batch: under 2 minutes.

Lessons for WordPress Automation

If you are automating WordPress deployments—whether for a single site or for managing multiple client sites—these are the hard-won lessons from our experience:

  1. Always use wp_slash() when saving serialized data to post meta. This cannot be stated too many times. It is the single most common cause of data corruption in automated WordPress workflows. Test it. Verify the round-trip. Add an assertion that the saved data decodes without JSON errors.
  2. Make every script idempotent. Running a script twice should produce the same result as running it once. This means checking for existing content before creating new content (using wp post list --name=slug --field=ID to find existing posts), using upsert patterns for database operations, and designing your scripts to converge on the desired state rather than blindly appending to the current state.
  3. Back up before every write to Elementor data. Automated writes are riskier than manual edits because they can affect more data more quickly and without the visual feedback that the Elementor editor provides. A mandatory backup step before every write operation gives you a rollback path that costs nothing (a few kilobytes of storage per backup) and saves everything (your page layout, your afternoon, and your client relationship).
  4. Validate after every write. Do not assume the write succeeded because the PHP function returned without an error. Read the data back from the database, decode the JSON, validate the structure, compare element IDs against the pre-write inventory. If anything changed that should not have changed, roll back immediately and investigate.
  5. Never modify post_content on Elementor pages. Elementor ignores post_content entirely for rendering—it reads from _elementor_data in post meta. Modifying post_content does not change what the user sees, but it can confuse WordPress’s revision system, break SEO plugin analysis, and cause issues with search indexing. All Elementor content changes go through _elementor_data.

The harborsoftware.com rebuild took approximately 120 hours of engineering time spread over 6 weeks, including the development of the reusable safety toolset. The site loads in 1.8 seconds on desktop (measured by Lighthouse), scores 92 on Lighthouse performance, and can be completely rebuilt from a fresh WordPress installation in 8 minutes. The automation toolset we built during the process is now reusable across client WordPress projects—we have already deployed it on two other sites, reducing their deployment time from manual multi-hour processes to scripted single-digit-minute processes. For a marketing website, that is a level of reproducibility and operational confidence that manual WordPress management cannot match.

Leave a comment

Explore
Drag