Skip to content
Go back

I Switched WYSIWYG Editors and All My Images Disappeared

My app switched from the Froala rich text editor to TipTap (via Flux UI’s <flux:editor>). New content worked perfectly. Old content didn’t — images disappeared, and the HTML was full of proprietary markup that TipTap couldn’t render.

This is a common problem when migrating between WYSIWYG editors. The editor changes, but the data stays in the old format. Here’s how I fixed it — and the migration bug that nearly left half the records untouched.

Table of contents

Open Table of contents

The problem: proprietary HTML markup

Every WYSIWYG editor stores content slightly differently. Froala wrapped images in custom spans with proprietary CSS classes:

<!-- What Froala stored in the database -->
<p>
  <span class="fr-img-caption fr-fic fr-dib" style="width: 629px;">
    <span class="fr-img-wrap">
      <img src="...image.png" style="width: 629px;" class="fr-fic fr-dib">
      <span class="fr-inner">
        <a href="..." id="isPasted">Image caption</a>
      </span>
    </span>
  </span>
</p>

With Froala’s CSS loaded, this rendered correctly. Without it, the fr-img-caption, fr-fic, fr-dib, and fr-img-wrap classes meant nothing — the images either disappeared or rendered broken. TipTap also requires images at root level (not nested inside <p> tags), so the entire structure was wrong.

What the HTML needed to look like for TipTap:

<!-- What TipTap expects -->
<img src="...image.png" style="width: 629px;"
     class="max-w-full h-auto rounded-lg shadow-sm">

Images extracted from their wrapper spans, Froala classes stripped, id="isPasted" attributes removed, and Tailwind utility classes added.

First attempt: a runtime sanitizer

The initial approach was an HtmlSanitizer PHP class that cleaned the HTML every time it was loaded. The sanitizer was called in the Livewire component’s mount() method:

// Runtime approach: sanitise on every page load
$this->description = HtmlSanitizer::sanitizeForTipTap($this->idea->description);

This worked — but only in the one place it was called. The description was also rendered in at least two other locations (a Blade template using {!! $idea->description !!} and another Livewire component), all showing unsanitised Froala markup.

I asked:

rather than use this HtmlSanitizer.php can we just make the changes directly in the database so that this isn’t required at all?

The answer was clear: clean the data at the source. A one-time database migration that transforms every record means every render location gets clean HTML automatically. No runtime overhead, no risk of missing a render path.

The right approach: a data migration

Instead of sanitising on every read, a Laravel migration cleaned every record once:

public function up(): void
{
    DB::table('ideas')
        ->where('description', 'like', '%fr-%')
        ->orWhere('description', 'like', '%id="isPasted"%')
        ->chunkById(100, function ($ideas) {
            foreach ($ideas as $idea) {
                $cleaned = $this->sanitize($idea->description);
                DB::table('ideas')
                    ->where('id', $idea->id)
                    ->update(['description' => $cleaned]);
            }
        });
}

public function down(): void
{
    // Intentionally irreversible — original Froala markup is lost
}

The sanitize() method handled each piece of Froala-specific markup:

  1. Extract images from wrapper spans — unwrap fr-img-caption / fr-img-wrap structures
  2. Move images out of <p> tags — TipTap needs images at root level
  3. Strip Froala classes — remove fr-fic, fr-dib, fr-img-caption, etc.
  4. Remove id="isPasted" — a Froala artifact from paste operations
  5. Remove data-fr-* attributesdata-fr-linked, data-fr-image-pasted, etc.
  6. Clean up empty attributes — stripping all classes can leave empty class="" behind
  7. Add Tailwind classes to imagesmax-w-full h-auto rounded-lg shadow-sm

After running the migration, the HtmlSanitizer class and its tests were deleted entirely. The Livewire component reverted to a simple direct read:

// After: data is already clean, no sanitiser needed
$this->description = $this->idea->description;

The bug that silently skipped half the records

The migration ran. No errors. It reported success. But when I tested, some ideas still had broken images. A closer look revealed it had only cleaned 482 out of 894 matching records — silently skipping 412.

The original code used chunk():

// BROKEN: chunk() uses OFFSET/LIMIT pagination
DB::table('ideas')
    ->where('description', 'like', '%fr-%')
    ->chunk(100, function ($ideas) {
        // ... update each idea
    });

Here’s what happened: chunk() uses SQL OFFSET and LIMIT to paginate through results. Batch 1 gets rows 1-100. Batch 2 gets rows 101-200. But when batch 1 updates its rows, some no longer match the WHERE clause (because the fr- patterns have been removed). So when batch 2 runs, the database only has ~800 matching rows instead of 894 — but the offset is still at 100. Rows shift position, and entire chunks get skipped.

The fix was one method name change:

// FIXED: chunkById() uses WHERE id > lastId — safe during updates
DB::table('ideas')
    ->where('description', 'like', '%fr-%')
    ->chunkById(100, function ($ideas) {
        // ... update each idea
    });

chunkById() uses cursor-based pagination — WHERE id > 100 for the second batch, regardless of how many rows still match the original WHERE clause. It doesn’t care if rows have been modified. Every record gets processed.

MethodPaginationSafe during updates?
chunk()OFFSET / LIMITNo — rows shift when updated rows leave the result set
chunkById()WHERE id > lastIdYes — cursor position is independent of result set changes

This is a well-known Laravel gotcha, but it’s the kind of thing you only discover when your migration silently half-finishes with no errors.

Deploying safely

I asked:

should we commit and push, then do the database migration, or do all at once? and why?

The answer: all at once. The migration and the code that removes the sanitiser should ship together. If you deploy the code without the migration, the sanitiser is gone but the data is still dirty. If you run the migration without the code, the sanitiser is still running unnecessarily.

But before deploying, I took a production database backup. This migration is irreversible — the down() method is a no-op because the original Froala markup is intentionally discarded. If anything goes wrong, the backup is the only way back.

if in future we can’t rollback like this (ie the migration down method is a no-op) please flag this to me clearly

For more on database backups and irreversible migrations, see How Laravel Migrations Work.

Testing thoroughly

Before deploying, I tested 20 ideas across multiple teams — some with images, some without, some with multiple images, some from 2024, some from 2025. Every edge case I could think of. After deploying to production, I re-tested the same 20 ideas using production URLs.

The thorough testing paid off. During local testing I caught several edge cases the initial sanitiser missed: bare <img> tags with Froala classes (not inside caption wrappers), data-fr-* attributes the XPath query didn’t match, and empty class="" attributes left behind after stripping.

Key takeaway

Switching WYSIWYG editors is a data migration problem, not a rendering problem. The instinct is to sanitise on read — transform the HTML every time it’s displayed. But that’s fragile (you’ll miss render paths) and wasteful (you’re cleaning the same data on every page load). Clean it once in the database and delete the sanitiser.

And if your migration modifies rows that match its own WHERE clause, use chunkById() instead of chunk(). The alternative is a migration that reports success while silently skipping half your records.


Back to top ↑