• Pricing
Talk to Us
Menu
Pricing

Every CMS migration looks trivial from a distance. You export rows, you POST them, you go home. Up close it’s a different story — line endings drift, tags arrive as comma-delimited strings, images are scattered across a decade of folders, and somewhere between “plan” and “production” an API verb decides to replace a document when you meant to merge it.

This is a working write-up of how we moved a Laravel-powered blog onto PagePilot, a modern visual page-builder platform. The focus isn’t scope or scale — it’s the shape of a migration that survives contact with reality.

Context

Two shapes of content, one bridge between them

The source was a Laravel application backed by MySQL. Content lived across three tables — posts, categories, and tags — exported to JSON via phpMyAdmin. Hero images sat on disk under relative paths, referenced by string from the post rows.

The target was different in almost every way. PagePilot models content as Pages that contain Sections. Each Page carries SEO fields, a structured metadata object, hero images as storage-backed objects with signed URLs, tags, and status — and each Section carries both raw HTML and a contentMetadata document tree that the visual editor renders from.


I · Media first

Move the images before anything that refers to them

The first real step wasn’t about content at all — it was about media. Every image referenced in the source got uploaded to PagePilot’s object storage, and the result was captured in a small JSON map keyed by the old path. That map became the single source of truth every later step could consult without re-uploading a byte.

{
  "media/images/blogpost/image/some-file.jpg": {
    "filename":    "uuid.jpg",
    "size":        164294,
    "mime":        "image/jpeg",
    "key":         "tenant/<id>/page/heroImage/uuid.jpg",
    "newUrl":      "https://storage.../signed-url"
  }
}

The slowest, most side-effectful step is the one you want to run once, cache, and never repeat.


II · The HTML cleanup

Laravel HTML carries its history with it

Legacy content tends to collect small infidelities: escaped slashes, Windows line endings, trailing whitespace, three blank lines where one would do. None of these break anything — until they do, in an unfamiliar editor. A tiny normalizer handled every post:

function formatHtml(raw) {
  if (!raw) return '';
  let html = String(raw);
  html = html.replace(/\\\//g, '/');
  html = html.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  html = html.replace(/[ \t]+\n/g, '\n');
  html = html.replace(/\n{3,}/g, '\n\n');
  html = html.replace(/>\s+</g, '>\n<');
  return html.trim();
}

The cleaned HTML was then wrapped in a PagePilot email-layout scaffold — the styled table-and-text-block structure the visual editor expects — so the runtime got valid structure and the editor got something meaningful to render.


III · The document tree

Give the editor a real root to render from

PagePilot’s editor doesn’t read HTML — it reads a contentMetadata tree. The minimum viable tree is an EmailLayout root that references a single Text block — simple, but enough for the editor to round-trip.

const contentMetadata = {
  document: {
    root: {
      type: 'EmailLayout',
      data: {
        backdropColor: '#F5F5F5', canvasColor: '#FFFFFF',
        textColor: '#262626', fontFamily: 'MODERN_SANS',
        childrenIds: [blockId], maxWidth: '100%',
        props: {}, style: {},
      },
    },
    [blockId]: {
      type: 'Text',
      data: { props: { text: innerHtml }, style: { fontWeight: 'normal' } },
    },
  },
};

IV · Tags and categories

Canonicalize the small things before you batch

Tags arrived as a single comma-delimited string, with whatever capitalization the original author had typed that day. A case-insensitive lookup against the canonical tag table gave every post the same version of “Painting Tips”.

function buildTags(post, TAG_INDEX) {
  if (!post.tags_ids) return [];
  return String(post.tags_ids)
    .split(',')
    .map(s => s.trim())
    .filter(Boolean)
    .map(name => TAG_INDEX.get(name.toLowerCase()) || name);
}

Categories were worse — stored as numeric foreign keys, needed as names. Instead of looking them up inside every API call, we did a one-time preparation pass that rewrote the source into a denormalized file with category_name baked in.

const catMap = new Map(categories.map(c => [String(c.id), c.name]));

const prepared = posts.map(p => {
  const { category_id, ...rest } = p;
  return { ...rest, category_name: catMap.get(String(category_id)) || null };
});

V · The payload

One builder, one shape, one place to change things

Every normalized piece fed into a single payload builder. Keeping this in one function mattered more than any specific detail — when the shape drifted, we changed it once, not in three places.

function buildPayload(post) {
  const innerHtml   = formatHtml(post.description);
  const wrappedHtml = wrapContent(innerHtml);
  const title       = post.name || post.page_title;
  const status      = post.status === 'live' ? 'live' : 'draft';

  const mapped = post.image ? IMAGE_MAP[post.image] : null;
  const heroImage = mapped ? [{
    name: mapped.filename, sizeInBytes: mapped.size,
    mimeType: mapped.mime, privateUrl: mapped.key,
    downloadUrl: mapped.newUrl, storageId: 'pageHeroImage',
  }] : [];

  const section = { title, content: wrappedHtml, contentMetadata, tenant: TENANT_ID };

  return {
    data: {
      name: title, slug: post.slug, editor: 'visual',
      heroImage, thumbnailImage: heroImage, sections: [section],
      tags: buildTags(post), groups: ['blogs'],
      title, metaTitle: post.page_title || title,
      metaDescription: post.short_description || '',
      seo: { title, description: post.short_description || '' },
      isActive: status === 'live', status, tenant: TENANT_ID,
    },
  };
}

VI · The write path

Idempotent upserts, keyed by _id

The platform exposes a single endpoint — upsert-page-item — that creates a page on first call and updates in place when an existing _id is present inside data. That single property — idempotence keyed by ID — is what made every later decision easier.

async function upsert(payload) {
  const res = await fetch(
    `${API_BASE}/tenant/${TENANT_ID}/upsert-page-item`,
    {
      method: 'POST',
      headers: {
        'content-type':  'application/json;charset=UTF-8',
        'authorization': `Bearer ${AUTH_TOKEN}`,
      },
      body: JSON.stringify(payload),
    }
  );
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

A small concurrency pool kept the API load predictable — no queues, no retries, no rate-limit dance. Just a bounded number of workers pulling from an index:

async function runPool(items, limit, worker) {
  let idx = 0;
  const runners = Array.from({ length: limit }, async () => {
    while (idx < items.length) {
      const i = idx++;
      await worker(items[i], i).catch(e => ({ __error: e.message }));
    }
  });
  await Promise.all(runners);
}

VII · The guardrails

Small habits that paid for themselves

  • Dry-run mode on every script. --dry-run printed the exact payload without sending it. If something looked off, we caught it in a terminal — not in the UI.
  • Single-record mode. A --slug flag let us validate one post against the live UI before a batch run. This habit turned the PUT incident into an inconvenience instead of a rollback.
  • Logs as ground truth. Every write appended a line to a per-script log with status, slug, and target ID. When something failed, we didn’t debate what had happened — we grepped.
  • Stable keys everywhere. Slug for matching source to target, _id for updating in place. Any script could be re-run end-to-end without fear.

In closing

Make the migration feel like a series of boring commits

The easy version of this story is “we wrote a script and ran it.” The honest version is that HTML hid line-ending quirks, tags hid inconsistent casing, categories hid in a foreign-key join, images hid in folders nobody had opened in three years, and an API endpoint hid a replace-instead-of-merge surprise.

The pattern that worked — and the one we’d reach for again — is almost embarrassingly simple: small scripts, stable keys, idempotent writes, dry-runs first, one endpoint you trust. That combination turns a bulk migration into what it should feel like: a series of small, reversible, boring steps. None of them heroic. All of them safe.

DISCOVERCase StudiesPricingTerms & ConditionPrivacy Policy
SERVICESCustomizationHostingConsultationProfessional ServicesHire a Developer
ECOSYSTEMHackathonKnowledgeAffiliate
PRODUCTFAB StudioFAB CRMAnalyticsPage Pilot
CONNECTWhatsApp CommunityDiscord CommunityBlogsLinkedInTwitterFacebookYouTubeInstagram
COMPANYAbout UsCareerInvestorSupportContact Us
Copyright 2026 ©. All right reserved.