Migrating TinyMCE from v5.9 to v8.3 in a Laravel 8 → 11 Upgrade

Introduction

When migrating a Laravel blog application from Laravel 8 to Laravel 11, of the most challenging frontend tasks is upgrading TinyMCE — the rich text editor used in the admin panel. This isn't a simple version bump. You're dealing with two simultaneous breaking changes:

  1. Build system: webpack.mix.js (Laravel Mix) → Vite 5 (Laravel's new default)
  2. Editor: TinyMCE 5.9.2 → TinyMCE 8.3.1 (three major versions, with significant plugin removals and API changes)

This article walks through a real-world migration, with actual code from a production Laravel blog, covering every decision point and the reasoning behind the chosen approach.


1. The Starting Point: Laravel 8 + Mix + TinyMCE 5.9.2

Build System: webpack.mix.js

In the Laravel 8 setup, TinyMCE was installed as an npm dependency and bundled through Laravel Mix (a webpack wrapper):

// webpack.mix.js
const mix = require('laravel-mix');
const theme = process.env.BLOG_THEME;

mix.js('resources/js/app.js', 'public/js')
    .js('resources/js/admin.js', 'public/js')
    .js('resources/js/close.js', 'public/js')
    .sass(`resources/sass/admin/admin.scss`, 'public/css')
    .sass(`resources/sass/${theme}/app.scss`, 'public/css');

if (mix.inProduction()){
    mix.sass(`resources/sass/fontawesome.scss`, 'public/css');
}

The admin.js entry point pulled in TinyMCE along with other admin scripts:

// resources/js/admin.js (Laravel 8)
import bsCustomFileInput from 'bs-custom-file-input';

bsCustomFileInput.init();
require('./scripts/tinymce');
require('./scripts/sidebar');
require('./scripts/proper-url');
require('./scripts/hidden-form');
require('./scripts/post-date');

TinyMCE 5 Configuration

The editor configuration lived in a dedicated script file:

// resources/js/scripts/tinymce.js (Laravel 8 / TinyMCE 5.9.2)
import tinymce from 'tinymce/tinymce.min.js';

tinymce.baseURL = '/js/tinymce';

const editorConfig = {
    path_absolute: '/auth/',
    selector: 'textarea#content',

    plugins: [
        "advlist autolink lists link image charmap print preview hr anchor pagebreak",
        "searchreplace wordcount visualblocks visualchars code fullscreen",
        "insertdatetime media nonbreaking save table directionality",
        "emoticons template paste textpattern"
    ],
    toolbar: "insertfile undo redo | styleselect | bold italic | alignleft aligncenter " +
             "alignright alignjustify | bullist numlist outdent indent | link image media",
    fullscreen_native: true,
    visualblocks_default_state: false,
    end_container_on_empty_block: true,
    block_unsupported_drop: true,
    relative_urls: false,
    height: "250",
    templates: [
        {
            title: "HTML",
            description: "A HTML HLJS Code Block",
            content: "<h4>HTML</h4><pre><code class='html hljs xml'>HTML</code></pre>",
        },
        {
            title: "CSS",
            description: "A CSS HLJS Code Block",
            content: "<h4>CSS</h4><pre><code class='css hljs'>.css { value: 1px; }</code></pre>",
        },
        {
            title: "JS",
            description: "A JS HLJS Code Block",
            content: "<h4>Javascript</h4><pre><code class='javascript hljs'>" +
                     "function test() { console.log('test'); }</code></pre>",
        },
        {
            title: "PHP",
            description: "A PHP HLJS Code Block",
            content: "<h4>PHP</h4><pre><code class='php hljs'>a = 1;</code></pre>",
        },
    ],

    file_picker_callback: function(callback, value, meta){
        var x = window.innerWidth || document.documentElement.clientWidth ||
                document.getElementsByTagName('body')[0].clientWidth;
        var y = window.innerHeight || document.documentElement.clientHeight ||
                document.getElementsByTagName('body')[0].clientHeight;

        var cmsURL = editorConfig.path_absolute +
                     'laravel-filemanager?editor=' + meta.fieldname;
        if (meta.filetype == 'image') {
            cmsURL = cmsURL + "&type=Images";
        } else {
            cmsURL = cmsURL + "&type=Files";
        }

        tinymce.activeEditor.windowManager.openUrl({
            url: cmsURL,
            title: 'Filemanager',
            width: x * 0.8,
            height: y * 0.8,
            resizable: "yes",
            close_previous: "no",
            (api, message) => {
                callback(message.content);
            }
        });
    },

    style_formats: [
        { title: 'Headers', items: [
          { title: 'h1', block: 'h1' },
          { title: 'h3', block: 'h3' },
          { title: 'h4', block: 'h4' },
          { title: 'h5', block: 'h5' },
          { title: 'h6', block: 'h6' }
        ]},
        { title: 'Blocks', items: [
          { title: 'p', block: 'p' },
          { title: 'div', block: 'div' },
          { title: 'pre', block: 'pre' }
        ]},
        { title: 'Containers', items: [
          { title: 'section', block: 'section', wrapper: true, merge_siblings: false },
          { title: 'blockquote', block: 'blockquote', wrapper: true },
          { title: 'aside', block: 'aside', wrapper: true },
          { title: 'figure', block: 'figure', wrapper: true }
        ]}
    ]
};

tinymce.init(editorConfig);

Asset Deployment (TinyMCE 5)

A critical detail: TinyMCE 5's JS was imported and bundled via webpack, but the editor's runtime assets (skins, icons, themes, plugins) had to be manually placed in public/js/tinymce/. The directory looked like this:

public/js/tinymce/
├── icons/
│   └── default/
├── plugins/
│   ├── advlist/
│   ├── autolink/
│   ├── bbcode/
│   ├── charmap/
│   ├── code/
│   ├── colorpicker/
│   ├── contextmenu/
│   ├── paste/
│   ├── print/
│   ├── template/
│   ├── textcolor/
│   ├── textpattern/
│   ... (40+ plugin directories)
├── skins/
│   ├── content/
│   └── ui/
└── themes/
    ├── mobile/
    └── silver/

The line tinymce.baseURL = '/js/tinymce' told TinyMCE where to find these assets at runtime.


2. Why You Can't Just Upgrade In-Place

Three factors make an incremental upgrade impractical:

Build System Mismatch

Laravel 11 uses Vite 5 by default. Vite uses native ES modules, while Laravel Mix used CommonJS via webpack. This means:

  • require() calls don't work — you need import statements
  • Vite's output goes to public/build/ instead of public/js/ and public/css/
  • Asset references use @vite() Blade directive instead of mix() helper

TinyMCE's Module Loading Problem

TinyMCE's architecture relies on dynamically loading plugins, skins, and icons relative to a base URL. When you try to bundle TinyMCE's core through Vite's ES module system, these dynamic loads break — Vite tree-shakes and code-splits in ways that conflict with TinyMCE's internal module resolver.

Massive Plugin API Changes

TinyMCE 8 dropped or merged many v5 plugins. Using old plugin names causes initialization failures:

TinyMCE 5 Plugin TinyMCE 8 Status
print Removed (use browser print)
hr Removed (merged into core)
paste Removed (merged into core)
template Removed
textpattern Removed (merged into core)
textcolor Removed (merged into core)
colorpicker Removed (merged into core)
contextmenu Removed (merged into core)
imagetools Removed
noneditable Removed (merged into core)
toc Removed
legacyoutput Removed
tabfocus Removed (merged into core)
fullpage Removed
bbcode Removed
spellchecker Removed (use browser spellcheck)
advlist Still available
autolink Still available
code Still available
codesample Still available (enhanced with Prism.js)
image Still available
link Still available
lists Still available
table Still available

Migrating TinyMCE from v5.9 to v8.3 - Part 2