marshmallow/nova-tinymce
A TinyMCE rich-text editor field for Laravel Nova, adding a WYSIWYG input to Nova forms so you can create and format content with familiar TinyMCE editing tools.
Install and publish. The package ships as a standard Nova field package, so the flow is Composer → publish assets/config → use the field class.
# Pin to >= 2.3.2: earlier 2.x releases register their JS assets under
# capitalised names, which breaks on hosts that lowercase URLs (see Gotchas).
composer require marshmallow-packages/nova-tinymce:^2.3.2
# Publish the package config/assets (confirm the provider/tag names from the README,
# since small packages often change them between releases)
php artisan vendor:publish --provider="Marshmallow\NovaTinyMce\NovaTinyMceServiceProvider"
php artisan storage:link # required if inline image uploads write to the public disk
Nova 4+ discovers fields via Composer; if you see a blank textarea or a "component not found" error, run php artisan nova:publish and php artisan optimize:clear.
Where to look first
README.md — the only reliable source for the exact namespace, config keys, and upload options for this version.config/nova-tinymce.php (after publishing) — disk, upload path, TinyMCE init options, API key.src/ — the field class (usually one file extending Laravel\Nova\Fields\Field) and the upload controller. Read both; with a 3-star package you should know exactly what code is running on your server. Also check the service provider's Nova::script(...) calls — the names it registers are the contract Nova's /nova-api/scripts/<name> route matches case-sensitively.resources/js/ — the Vue component wrapping TinyMCE; this is where default toolbar/plugin options live. Note that component names are a separate contract from the registered asset names and must stay in sync with dist/.First use case: a CMS-style body field. Add a longText (or mediumText) column, then use the field in a resource:
// database migration
$table->longText('content')->nullable();
// app/Nova/Post.php
use Marshmallow\NovaTinyMce\TinyMCE; // verify namespace in README/src
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Title')->rules('required', 'max:255'),
TinyMCE::make('Content')
->rules('required')
->hideFromIndex(),
];
}
That gives you a WYSIWYG body field where editors can paste/upload images directly into the content, with the HTML stored in posts.content. Render it on the front end with {!! $post->content !!} only after sanitizing (see Gotchas).
Configure the editor through field meta. The common pattern for TinyMCE wrappers is to pass an options array into the field and merge it with defaults in the Vue component. Expect something along these lines (check the README for exact method names):
TinyMCE::make('Content')
->options([
'height' => 500,
'menubar' => false,
'plugins' => 'link lists image code table',
'toolbar' => 'undo redo | bold italic | bullist numlist | link image | code',
'valid_elements' => '*[*]', // be deliberate here
]);
If the package doesn't expose a fluent options() method, use ->withMeta(['options' => [...]]) and confirm the Vue component reads that key.
Inline image uploads. The selling point of this package is the upload endpoint wired into TinyMCE's images_upload_handler. Practical pattern:
public disk → uploads/editor/.editor) over dumping everything into public/ so you can move to S3 later without touching content.Sanitize on write, not on read. Treat the editor output as untrusted HTML, because TinyMCE's client-side filtering is not a security boundary:
// app/Models/Post.php
protected static function booted(): void
{
static::saving(function (Post $post) {
$post->content = clean($post->content); // mews/purifier or HTMLPurifier
});
}
Alternatively use a fillUsing() callback on the Nova field, but a model mutator covers seeders, imports, and API writes too — which is usually what you want for a CMS field.
Display in Nova's index/detail views. Raw HTML in an index cell is unreadable and Nova will escape it. Two patterns:
// Index: short plain-text preview
TinyMCE::make('Content')
->hideFromIndex(),
Text::make('Content', fn () => Str::limit(strip_tags($this->content), 80))
->onlyOnIndex(),
// Detail: render the HTML
TinyMCE::make('Content')
->asHtml() // if the package forwards to Nova's Text behavior
->exceptOnForms(),
If asHtml() isn't supported, use ->displayUsing(fn ($value) => $value) with a custom detail component, or just show it as read-only text in detail and link out to the front end.
Pair with validation. Nova rules still apply because the field submits a normal string:
TinyMCE::make('Content')
->rules('required', 'string', 'max:65000')
->help('Images are uploaded to the editor disk.');
max matters: MySQL TEXT caps at 65,535 bytes, and HTML with base64 or long image markup blows past that quickly. Use LONGTEXT if editors paste a lot.
Multi-field and per-resource configuration. If you have body and summary editors with different toolbars, define small factory methods or a config array keyed by field name, then pass the relevant options per field. This keeps resource files readable and makes it obvious which editor has image upload enabled.
Integration with media libraries. For anything beyond a single editor, consider replacing the package's uploader with a Media Library package (Spatie Media Library, nova-media-hub). Pattern: keep this field for the editing UI, but override the upload handler/route to store through your media model and return the media URL. That gives you conversions, CDN URLs, and cleanup on delete — things this package will not do for you.
Upgrading to 2.3.2. The fix only renames three registered assets to lowercase; Vue component names and TinyMCE.php's $component are unchanged, so it's a drop-in upgrade. Bump the constraint, composer update, then hard-refresh the browser and check the network tab: the nova-api/scripts/... requests should return 200 and the names should be all-lowercase. If you have a forked service provider or your own Nova::script() registrations for this field, apply the same lowercase convention there.
TypeError: t.fill is not a function on save (fixed in 2.3.2). In 2.x below 2.3.2 the package registers its assets as Nova-TinyMCE / Nova-TinyMCE-tinymce. Nova serves /nova-api/scripts/<name> and matches the name case-sensitively, so any host that normalises URLs to lowercase (SEO middleware, proxies, some CDNs) 301s the request, the script 404s, the Vue component never registers, and form submission dies client-side with t.fill is not a function — no request is sent, so nothing appears in laravel.log. Symptom is "every save on this resource does nothing, including saves with no changes". Fix: upgrade to ^2.3.2; diagnose by looking at the console error and the network tab, not the server logs.tinymce.min.js in public/vendor and check the config option that switches the source. Verify in the network tab which URL is actually loaded; "editor doesn't appear" is almost always this or a CSP block.nova middleware / auth guard? If not, add middleware or replace the route. Unauthenticated image upload endpoints are a common oversight in small Nova packages.php artisan storage:link is mandatory for the public disk. For S3, set the disk visibility to public or generate temporary URLs — otherwise images upload fine but appear broken in the editor after refresh.array/json; the field expects and submits a string. If you need structured content, store it in a separate JSON column and keep the HTML body as a plain long-text column.->asHtml() output unexpectedly in detail views — verify in the browser, not just in the resource definition.content_css (or the package's equivalent config) to your compiled front-end CSS so editors see the same typography and spacing as visitors.composer.json (at minimum ^2.3.2), commit the published config, and audit the Vue component and controller before relying on it. Check the release date/commit history against your Nova major version — Nova field API changes between v4 and v5 are a frequent source of "field renders as a plain textarea" bugs. Also watch for host-level URL rewriting in your infrastructure: a package that works locally can fail entirely behind a middleware or proxy that changes URL casing.storage/logs/laravel.log. An empty log plus a broken form almost always means the field's JS never loaded — check the /nova-api/scripts/... responses first. After any config change, run php artisan config:clear — Laravel caches published package configs aggressively in production.options meta, and (c) subclass the field to override the component or upload handler. Avoid editing files in vendor/ — the next composer update will erase them.How can I help you explore Laravel packages today?