emilianotisato/nova-tinymce
Nova TinyMCE adds a full-featured TinyMCE WYSIWYG editor field to Laravel Nova, enabling rich text editing with configurable toolbars, plugins, and media support for managing HTML content directly in Nova resources.
Install the field and publish its assets, then swap an existing Textarea on a single resource. That is the whole first-day loop:
composer require emilianotisato/nova-tinymce
php artisan vendor:publish --provider="Emilianotisato\NovaTinyMCE\FieldServiceProvider"
php artisan nova:publish
use Emilianotisato\NovaTinyMCE\NovaTinyMCE;
use Laravel\Nova\Fields\Textarea; // remove
public function fields(NovaRequest $request)
{
return [
NovaTinyMCE::make('Body')
->rules('required', 'string', 'max:65535'),
];
}
Look here first, in this order:
vendor/emilianotisato/nova-tinymce/src/ — the PHP field class is small; read it end to end to see which methods map to TinyMCE options and what withFiles() actually registers.vendor/emilianotisato/nova-tinymce/resources/js/field.js (or dist/js/) — the Vue component, to understand how the value is bound and where the upload handler lives.config/nova-tinymce.php (after publishing) — global editor defaults.First use case: a Post or Page resource with a body longText column. Replace the textarea, edit one record, verify the HTML round-trips (save → reload → render on the frontend). Do that before touching image uploads — uploads are where the real configuration lives.
Wrap the field once, use it everywhere. The single highest-value pattern for a 116-star community package: a thin internal factory so a future swap touches one file.
// app/Nova/Fields/RichText.php
namespace App\Nova\Fields;
use Emilianotisato\NovaTinyMCE\NovaTinyMCE;
class RichText
{
public static function make(string $name, ?string $attribute = null)
{
return NovaTinyMCE::make($name, $attribute)
->options([
'height' => 500,
'menubar' => false,
'plugins' => 'lists link image table code fullscreen paste',
'toolbar' => 'undo redo | bold italic | bullist numlist | link image table | code fullscreen',
'valid_elements' => '*[*]', // tighten, don't loosen
])
->rules('nullable', 'string');
}
}
Then RichText::make('Body') on every resource keeps toolbars consistent and makes the migration to a different editor a one-file change.
Image uploads. Wire the upload disk explicitly rather than relying on the default:
RichText::make('Body')->withFiles('public') // or 's3'
Confirm against the README for your pinned version whether the argument is a disk name or a visibility flag — this has been the most-changed part of the API. Uploads should land on a configured Laravel disk so you can move to S3 later without code changes. Keep storage:link in your deploy script and never let the editor hold a path that outlives the deploy.
Sanitize on write, not on read. The editor's client-side filtering is a UI convenience, not a security boundary. Add a mutator or observer:
// Post model
public function setBodyAttribute($value): void
{
$this->attributes['body'] = clean($value); // HTMLPurifier / mews/purifier
}
Render with {!! $post->body !!} only after that allowlist pass. Write a feature test that saves a <script> payload through Nova and asserts it's stripped — that test is your regression guard against a future config change.
Separate the storage column from the presentation. Keep body as raw HTML for the editor, and derive a stripped excerpt and a plain-text search_text for Nova index columns and full-text search. Don't try to render rich HTML in an index cell.
Text::make('Excerpt', fn () => Str::limit(strip_tags($this->body), 80))->onlyOnIndex(),
RichText::make('Body')->onlyOnForms()->hideFromIndex(),
Detail views. For a read-only resource detail page, don't instantiate the editor — use Text::make('Body', fn () => view('nova.partials.html', ['html' => $this->body]))->asHtml() or render a sanitized fragment. Editors are wasted weight on non-editable screens.
Versioning and drafts. Because the field is "just" an HTML string, drafts fit naturally: keep body and published_body columns, or a PostRevision model capturing the HTML on save. This is how you get editorial safety out of a field that offers none.
Per-role toolbars. Branch the options() call on $request->user()->hasRole(...) so marketing gets source and code while a support user gets bold/italic/link only. Same field class, different NovaTinyMCE::make() config.
Migration from Trix/Textarea. Existing content is structurally compatible but not byte-compatible. Run a one-time normalization over existing rows (sanitize, collapse stray <div>/<span style> into the allowed tag set) rather than assuming TinyMCE will round-trip it cleanly.
nova:publish and vendor:publish write to public/vendor. If your image build is read-only or you build before the package is baked in, the editor renders as an empty box with no obvious PHP error. Publish at build time and assert the directory exists.options() array is passed to tinymce.init() mostly verbatim. A typo (plugins vs plugin, a trailing comma in a comma-joined string) fails silently or throws inside the editor, not in PHP. Test the toolbar in the browser after every config change.->rules() still matters. It's an HTML string and can be large; use longText/mediumText, not varchar(255), and add a max: rule that matches the column so MySQL truncation never surprises an author mid-save.hideFromIndex() on rich-text fields. Shipping the editor config to the index page works but is wasteful and can bloat Nova response payloads on large tables./nova-vendor/... upload requests → php artisan nova:publish → check the disk config and storage:link → confirm auth middleware on the upload route rejects unauthenticated POSTs (test it with curl)."emilianotisato/nova-tinymce": "1.x.y") and upgrade Nova and this package in the same branch. Pre-approve the fork option in writing; the codebase is a field class plus a Vue component, so a fork is genuinely cheap.How can I help you explore Laravel packages today?