Skip to content

Rich Text

The RichText field renders a WYSIWYG editor with a configurable toolbar. It submits an HTML string, or null when the editor is empty. The PHP field adds a string rule automatically, so optional editors should also call nullable().

Basic Usage

Example
php
use InertiaUI\Forms\Fields\RichText;

RichText::make('body')->nullable();

This renders an editor with the default toolbar: bold, italic, underline, strike, an H2 heading, lists, links, blockquotes, inline code, undo, and redo.

Toolbar Presets

Simple

A minimal toolbar with just bold, italic, and link:

Example
php
RichText::make('bio')->simple();

Full

The full preset adds the larger editor controls, including image embeds, task lists, and text alignment:

php
RichText::make('content')->full();

Custom

Pass your own toolbar configuration:

Example
php
RichText::make('notes')
    ->toolbar('bold italic | bullet ordered ~ link undo redo');

You may also pass an array. Each nested array becomes a visual button group:

php
RichText::make('notes')->toolbar([
    ['bold', 'italic'],
    ['bulletList', 'orderedList'],
    ['link'],
]);

Flat arrays are accepted too:

php
RichText::make('notes')->toolbar([
    'bold', 'italic', '|', 'bulletList', 'orderedList', '|', 'link',
]);

Use | as a visual separator between button groups and ~ as a flexible spacer. The string grammar also accepts these aliases: bullet for bulletList, ordered for orderedList, quote for blockquote, hr for horizontalRule, and h1, h2, or h3 for heading.

RichText::normalizeToolbar() exposes the same normalization when application code needs the canonical flat toolbar array without building a field instance.

h1, h2, and h3 all normalize to the single heading toolbar control. That control toggles an H2 heading; it is not a heading-level picker.

Available Toolbar Items

ItemDescription
boldBold text
italicItalic text
underlineUnderlined text
strikeStrikethrough text
headingToggle an H2 heading
highlightHighlighted text
subscriptSubscript text
superscriptSuperscript text
bulletListUnordered list
orderedListNumbered list
taskListCheckbox list
linkHyperlink
imageImage embed
blockquoteBlock quote
codeInline code
codeBlockCode block
horizontalRuleHorizontal divider
alignLeftLeft alignment
alignCenterCenter alignment
alignRightRight alignment
undoUndo
redoRedo

The link toolbar item opens a native modal dialog for inserting, editing, and removing links. Configure the generated link policy from PHP:

php
RichText::make('body')
    ->simple()
    ->linkTargetBlank()
    ->linkNoopener()
    ->linkNoreferrer()
    ->linkAllowedProtocols(['https', 'mailto'])
    ->linkDefaultProtocol('https');

By default, links open in the same tab, noopener is enabled for links that open in a new tab, noreferrer is disabled, auto-linking and link-on-paste are enabled, the default protocol is http, and allowed protocols are http, https, mailto, and tel.

Use autoLink(false) to stop converting typed URLs and linkOnPaste(false) to stop converting a pasted URL. These switches do not disable the link dialog. linkAllowedProtocols() normalizes and deduplicates protocol names. Removing the current default protocol makes the first allowed protocol the new default. linkDefaultProtocol() must select a protocol that is already allowed.

Use allowLinkTargetOverride(false) when the field configuration should fully control whether links open in a new tab. With target override allowed, authors may toggle "Open in new tab" per link.

php
RichText::make('body')
    ->linkTargetBlank()
    ->allowLinkTargetOverride(false);

Use linkRel() for additional rel tokens such as nofollow, sponsored, or ugc:

php
RichText::make('body')
    ->linkRel(['nofollow', 'ugc']);

Protocol options are validated when the form is built. Unsafe or malformed protocols such as javascript: and data: are rejected, and the editor also rejects links whose protocol is not allowed. This policy applies to links created or normalized by the editor; it is not a replacement for server-side HTML sanitization when accepting untrusted HTML.

Height

Set minimum and/or maximum heights for the editor area in pixels:

php
RichText::make('content')
    ->minHeight(200)
    ->maxHeight(600);

Image Embeds

The image toolbar item accepts image URLs by default. Add imageUploads() when authors should also be able to pick an image file from their device:

php
use InertiaUI\Forms\Fields\RichText;
use InertiaUI\Forms\FileUploads\UploadConfig;

RichText::make('body')
    ->full()
    ->imageUploads(fn (UploadConfig $uploads) => $uploads
        ->maxSize(2048)
        ->maxDimensions(width: 2400, height: 1600));

RichText image uploads use the same temporary upload pipeline as the File field. Configure custom routes, chunking, direct-to-storage uploads, upload validators, and token validation in File Uploads. Common image validation helpers such as accept(), maxSize(), minSize(), dimensions(), minDimensions(), and maxDimensions() run during the upload request. The upload config always includes Laravel's image rule; accept() narrows the allowed image MIME types and must not turn this into a general file upload.

The generated companion field is <name>_images, for example body_images. Use getImageUploadFieldName() on the field or RichTextUploads::imageUploadFieldName('body') when application code needs that name explicitly.

storeWithForm() is not supported for RichText images. RichText still submits an HTML string for the field itself. Uploaded images are tracked in a companion token list, so your controller may resolve the temporary uploads and rewrite the HTML before storing it.

Image Resizing

Images inserted by URL or uploaded through imageUploads() may be resized in the editor with corner handles. The editor stores the user's resized display size as numeric width and height attributes on the <img> tag, so the submitted value stays regular HTML. RichTextUploads and RichTextContent preserve those attributes when they rewrite temporary upload markers or stored-image references.

Storage and rendering callbacks receive the existing image attributes through the RichTextImage builder. Use RichTextImage::width(), height(), or dimensions() when your application wants to normalize or override the user's resized display size while storing or rendering an image. These fluent setters write the rendered <img width height> attributes. They are not getters and do not describe the source image's intrinsic pixel dimensions. Use upload validation helpers such as UploadConfig::dimensions() and maxDimensions() when you need to validate the actual uploaded file.

Store Uploaded Images

Use the validated Form in your controller or form request handler. The richText() accessor applies the matching field's imageUploads() policy, verifies the HTML markers against the companion tokens, and deletes temporary uploads after a successful rewrite:

php
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use InertiaUI\Forms\RichText\RichTextImage;

$html = $form->richText('body')
    ->storeImagesUsing(function (SubmittedUpload $upload, RichTextImage $image): RichTextImage {
        $storedImage = app(ContentImageStore::class)->store($upload->getUploadedFile());

        return $image
            ->src($storedImage->url())
            ->identifier($storedImage->uuid(), [
                'id' => $storedImage->id(),
            ]);
    })
    ->toHtml();

The callback may mutate the provided RichTextImage builder without returning it. It may also return that builder, a new RichTextImage instance, or null. Returning arrays is intentionally unsupported so the replacement shape stays typed and readable.

RichTextImage may set src(), alt(), title(), width(), height(), or both dimensions together. attribute() and attributes() set other syntactically valid image attributes; pass null or false to remove one. identifier() writes a durable application-owned identifier plus optional metadata for deferred rendering, and toAttributes() returns the final attribute map.

Image Upload Validation

The Form accessor validates each image against the matching field's MIME type, size, and dimension policy before storage. Missing, unauthorized, hidden, non-RichText, and image-upload-disabled fields throw a RuntimeException.

Direct processing outside a Form must provide its policy explicitly:

php
use InertiaUI\Forms\FileUploads\UploadConfig;
use InertiaUI\Forms\RichText\RichTextUploads;

$html = RichTextUploads::from($request, 'body')
    ->validateUsing(UploadConfig::images()
        ->maxSize(2048)
        ->maxDimensions(width: 2400, height: 1600))
    ->storeImagesInMediaLibrary($post, 'content-images')
    ->toHtml();

You may also use forField($field) to apply a RichText field's policy in a non-Form workflow. RichTextUploads::from() does not discover a policy from the ambient request.

Invalid MIME types, sizes, and dimensions throw a ValidationException before storage.

withoutImageValidation() accepts any file behind a valid upload token. Use it only when your own pipeline validates the files.

Spatie Media Library

Use storeImagesInMediaLibrary() to store uploaded images on a model that implements Spatie Media Library's HasMedia contract:

php
$post->update([
    'body' => $form->richText('body')
        ->storeImagesInMediaLibrary($post, 'content-images')
        ->toHtml(),
]);

The helper stores each image in the given collection, replaces its temporary src with the Media Library URL, and records a signed image reference for later rendering. Pass the optional disk argument to override the collection's configured disk.

The model must already be saved. Create workflows should persist the model before moving its RichText images:

php
use Illuminate\Support\Arr;

$post = Post::create(
    Arr::except($form->validated(files: false), 'body'),
);

$post->body = $form->richText('body')
    ->storeImagesInMediaLibrary($post, 'content-images')
    ->toHtml();

$post->save();

Removing Unused Images

Images removed from the editor remain in the collection by default. Pass deleteUnused: true to keep an exclusively owned collection in sync with the stored HTML:

php
$post->body = $form->richText('body')
    ->storeImagesInMediaLibrary(
        $post,
        'content-images',
        deleteUnused: true,
    )
    ->toHtml();

$post->save();

Cleanup runs only after every new image is stored and the HTML is rewritten. An empty body clears the collection. Only valid signed references for the same collection retain media.

Each image keeps its client original filename. A successful rewrite deletes its staged upload. A failed rewrite removes newly created Media records and keeps the staged upload available for retry. The scheduled upload cleanup command remains the safety net for abandoned files.

Deferred Image Rendering

The default storage helper writes final image URLs into the HTML. Use keepTokenized() instead when URLs should be resolved while rendering. Common examples include private media, queued responsive images, and tenant-specific URLs.

php
$post->body = $form->richText('body')
    ->keepTokenized()
    ->storeImagesInMediaLibrary($post, 'content-images')
    ->toHtml();

Resolve each stored reference before sending the HTML to the browser:

php
use InertiaUI\Forms\RichText\RichTextContent;
use InertiaUI\Forms\RichText\RichTextImage;
use InertiaUI\Forms\RichText\RichTextStoredImage;

$html = RichTextContent::from($post->body)
    ->replaceImagesUsing(function (RichTextStoredImage $stored, RichTextImage $image): void {
        $image->src(route('content-images.show', $stored->identifier()));
    })
    ->toHtml();

The RichTextStoredImage passed to the callback exposes identifier(), all metadata(), a nested meta($key, $default) lookup, and the original image attributes(). The RichTextImage builder is mutable, so the callback does not need to return it. Returning a different RichTextImage replaces the builder; returning null without changing it preserves the original attributes. RichTextContent::toHtml() returns null when its input was null.

Rendered HTML removes data-inertia-forms-image attributes by default, including unresolved and malformed references. Ordinary image attributes remain. Preserve valid references in HTML that will be loaded back into the editor:

php
$editorHtml = RichTextContent::from($post->body)
    ->preserveStoredImageReferences()
    ->toHtml();

Replacement callbacks may preserve a reference, remove it with attribute(RichTextImage::STORED_ATTRIBUTE, null), or replace it with identifier().

Rendering Media Library Images

Public Media

A render callback may return a Media Library Media model directly. Inertia Forms maps it onto the image builder:

php
use Spatie\MediaLibrary\MediaCollections\Models\Media;

$media = $post->getMedia('content-images')->keyBy('uuid');

$html = RichTextContent::from($post->body)
    ->replaceImagesUsing(function (RichTextStoredImage $stored) use ($media): ?Media {
        return $media->get($stored->identifier());
    })
    ->toHtml();

The lookup should already be scoped to the model and collection being rendered. Applications with tenant or viewer rules must authorize the resolved Media item before returning it.

Use fromMediaLibrary() explicitly to select a conversion or add responsive image attributes:

php
$media = $post->getMedia('content-images')->keyBy('uuid');

$html = RichTextContent::from($post->body)
    ->replaceImagesUsing(function (
        RichTextStoredImage $stored,
        RichTextImage $image,
    ) use ($media): void {
        $storedMedia = $media->get($stored->identifier());

        if (! $storedMedia) {
            return;
        }

        $image
            ->fromMediaLibrary($storedMedia, 'web')
            ->attribute('sizes', '(min-width: 768px) 720px, 100vw')
            ->attribute('loading', 'lazy');
    })
    ->toHtml();

fromMediaLibrary() writes the full URL and available srcset, then records the Media Library identifier. Existing editor dimensions are preserved. Images without editor dimensions receive the largest responsive variant's intrinsic size. The Media name becomes alt text only when the original image has no alt attribute.

Private Media

fromMediaLibrary() is a public-media shorthand. It writes the Media Library model's durable URL into the image. Private media needs a fresh authorized URL each time the content is rendered:

php
$media = $post->getMedia('content-images')->keyBy('uuid');

$html = RichTextContent::from($post->body)
    ->requireSignedImages()
    ->replaceImagesUsing(function (
        RichTextStoredImage $stored,
        RichTextImage $image,
    ) use ($media): void {
        $storedMedia = $media->get($stored->identifier());

        if (! $storedMedia) {
            return;
        }

        $image
            ->src($storedMedia->getTemporaryUrl(now()->addMinutes(5)))
            ->attribute('srcset', null);
    })
    ->toHtml();

The application must authorize the media for the current model, tenant, and viewer before emitting the URL. Removing srcset prevents public responsive URLs from remaining beside the private src.

The signed reference contains the application identifier and optional metadata, not the temporary upload token. Authorize that identifier in the replaceImagesUsing() callback before rendering a URL.

Stored Image Reference Versions

The data-inertia-forms-* namespace is package-owned. RichTextUploads removes client-submitted package attributes before storage callbacks run.

RichTextImage::identifier() writes a signed version 2 reference using the application key. Verification also checks app.previous_keys, so key rotation keeps existing content readable. keepTokenized() preserves only verified existing references or references issued by the storage callback.

A signature proves that the package issued a reference, not that the current user owns its image. Continue to authorize identifiers in the render callback.

Require a valid signature while rendering with requireSignedImages():

php
$html = RichTextContent::from($post->body)
    ->requireSignedImages()
    ->replaceImagesUsing(fn (RichTextStoredImage $stored, RichTextImage $image): RichTextImage => $image
        ->src(route('content-images.show', $stored->identifier())))
    ->toHtml();

Versions before 1.4 wrote unsigned version 1 references. Version 1 references remain readable from existing stored HTML. They are read-only legacy data: RichTextUploads never trusts an unsigned version 1 reference submitted by the browser. Signed version 1 references remain valid, and all newly issued references use signed version 2.

Continue to sanitize untrusted HTML before storing or rendering it. Image upload validation does not sanitize the RichText HTML. Pre-processing sanitizers should allow src, alt, title, width, height, and data-inertia-forms-upload. Tokenized content also needs data-inertia-forms-image until RichTextContent renders it.

Word and Character Count

Word Count

php
RichText::make('content')->showWordCount();

Character Count

php
RichText::make('content')->showCharacterCount();

Max Length

Set a character limit for the editor text:

php
RichText::make('content')
    ->maxLength(5000)
    ->showCharacterCount();

maxLength() is serialized to the editor and configures Tiptap's CharacterCount extension. It limits the visible text the editor counts, and showCharacterCount() makes that count visible to the user.

It does not add a Laravel validation rule by itself. RichText submits an HTML string, or null for an empty editor, so Laravel string length rules measure the submitted markup as well as the visible text. Add your own server-side rules for the submitted value, sanitize untrusted HTML before storing or rendering it, and use a custom rule or FormRequest hook if the server must enforce a visible-text limit:

php
RichText::make('content')
    ->maxLength(5000)
    ->showCharacterCount()
    ->rules(['nullable', 'string']);
php
$request->validate([
    'content' => ['nullable', 'string'],
]);

$html = $request->string('content')->toString();

// Sanitize the HTML with your application's approved sanitizer before storing.
// Count text extracted from sanitized HTML when enforcing a visible-text limit.

Article Editor Configuration

Use placeholder() to set the empty-editor prompt:

php
RichText::make('body')
    ->label('Article Body')
    ->placeholder('Start writing...')
    ->full()
    ->linkAllowedProtocols(['https', 'mailto'])
    ->linkDefaultProtocol('https')
    ->linkTargetBlank()
    ->linkNoopener()
    ->minHeight(300)
    ->maxHeight(800)
    ->showWordCount()
    ->showCharacterCount()
    ->maxLength(10000)
    ->required();

RichText-specific methods above compose with shared field APIs. Use Form Class, Model Binding, Validation, Conditional Visibility, Authorization, and Styling for labels, help, defaults, rules, visibility, authorization, layout, and classes. RichText image uploads use the same runtime configuration described in File Uploads.

Using the Component Directly

You may render RichText from each stack's richtext subpath with local HTML state, or pass serialized field props during manual form rendering.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  RichText,
  type RichTextLinkOptions,
  type RichTextToolbar,
} from '@inertiaui/form-vue/richtext'

const body = ref<string | null>('<p>Draft body</p>')
const locked = ref(false)
const hasError = ref(false)

const toolbar: RichTextToolbar = [
  'bold',
  'italic',
  '|',
  'bulletList',
  'orderedList',
  '|',
  'link',
  'image',
  '|',
  'undo',
  'redo',
]

const linkOptions: RichTextLinkOptions = {
  targetBlank: true,
  noopener: true,
  noreferrer: true,
  rel: ['nofollow'],
  allowedProtocols: ['https', 'mailto'],
  defaultProtocol: 'https',
  autoLink: true,
  linkOnPaste: true,
  allowTargetOverride: false,
}

const editorParts = {
  toolbar: 'border-b border-zinc-200',
  toolbarButton: 'data-[active=true]:text-blue-600',
  editor: 'min-h-48',
  footer: 'text-xs',
  dialog: 'max-w-lg',
}
</script>

<template>
  <RichText
    v-model="body"
    id="article-body"
    name="body"
    label="Article body"
    help="Draft the public article body."
    placeholder="Start writing..."
    :toolbar="toolbar"
    :min-height="300"
    :max-height="800"
    show-word-count
    show-character-count
    :max-length="10000"
    :link-options="linkOptions"
    required
    precognitive
    :disabled="locked"
    :invalid="hasError"
    :error="hasError ? 'Article body is required.' : null"
    badge="Draft"
    badge-class="bg-zinc-100 text-zinc-700"
    label-trailing="Required"
    label-trailing-class="text-zinc-500"
    tooltip="Shown on the article page."
    help-position="below"
    layout="stacked"
    control-position="end"
    label-class="font-medium"
    help-class="text-zinc-500"
    error-class="text-red-600"
    wrapper-class="max-w-3xl"
    control-class="bg-white"
    class="font-medium"
    :part-classes="editorParts"
  />
</template>
tsx
import { useState } from 'react'
import {
    RichText,
    type RichTextLinkOptions,
    type RichTextToolbar,
} from '@inertiaui/form-react/richtext'

const toolbar: RichTextToolbar = [
    'bold',
    'italic',
    '|',
    'bulletList',
    'orderedList',
    '|',
    'link',
    'image',
    '|',
    'undo',
    'redo',
]

const linkOptions: RichTextLinkOptions = {
    targetBlank: true,
    noopener: true,
    noreferrer: true,
    rel: ['nofollow'],
    allowedProtocols: ['https', 'mailto'],
    defaultProtocol: 'https',
    autoLink: true,
    linkOnPaste: true,
    allowTargetOverride: false,
}

const editorParts = {
    toolbar: 'border-b border-zinc-200',
    toolbarButton: 'data-[active=true]:text-blue-600',
    editor: 'min-h-48',
    footer: 'text-xs',
    dialog: 'max-w-lg',
}

export default function ArticleEditor() {
    const [body, setBody] = useState<string | null>('<p>Draft body</p>')
    const [locked] = useState(false)
    const [hasError] = useState(false)

    return (
        <RichText
            value={body}
            onValueChange={setBody}
            id="article-body"
            name="body"
            label="Article body"
            help="Draft the public article body."
            placeholder="Start writing..."
            toolbar={toolbar}
            minHeight={300}
            maxHeight={800}
            showWordCount
            showCharacterCount
            maxLength={10000}
            linkOptions={linkOptions}
            required
            precognitive
            disabled={locked}
            invalid={hasError}
            error={hasError ? 'Article body is required.' : null}
            badge="Draft"
            badgeClass="bg-zinc-100 text-zinc-700"
            labelTrailing="Required"
            labelTrailingClass="text-zinc-500"
            tooltip="Shown on the article page."
            helpPosition="below"
            layout="stacked"
            controlPosition="end"
            labelClass="font-medium"
            helpClass="text-zinc-500"
            errorClass="text-red-600"
            wrapperClass="max-w-3xl"
            controlClass="bg-white"
            class="font-medium"
            partClasses={editorParts}
        />
    )
}

RichText is a composite editor and does not forward arbitrary native attributes to an inner native element. See Native Attributes And Events. Image-upload runtime configuration uses File Uploads.

Editor Support Components

The richtext subpath also exports EditorShell, EditorToolbar, and EditorCountFooter with their prop types. These low-level presenters let custom RichText integrations reuse the package shell, toolbar semantics, count footer, native attributes, and styling hooks without pulling Tiptap into the root or components entry points.

ComponentPublic options
EditorShellname writes data-editor-name; focused, invalid, and disabled select semantic states; classValue and controlClass extend shell classes. Vue provides toolbar, default, and footer slots. React renders children. Both forward native wrapper attributes.
EditorToolbaritems is the normalized control/separator/spacer order; buttons supplies each control's label, state, and action; disabled disables every control; dataAttributePrefix changes generated test/data attribute names; classValue and buttonClass extend toolbar styling. React additionally receives an icons map; Vue toolbar buttons carry their icon components. Native wrapper attributes are forwarded.
EditorCountFooterfooter.show controls rendering; wordLabel, characterLabel, and characterLimitLabel provide already-formatted display text; classValue extends footer styling. Both implementations forward native wrapper attributes.

For broader presenter customization, copy the shipped RichText presenter and see Customizing Presenters.