Integrates Trix content with Laravel







Logo Rich Text Laravel

Total Downloads License

Integrates the Trix Editor with Laravel. Inspired by the Action Text gem from Rails.


You can install the package via composer:

composer require tonysm/rich-text-laravel

Then, you may install it running:

php artisan richtext:install

This will install the package with the recommended model structure. However, you may choose to not want any of the database opinions and only use the custom cast. To achieve that, you may pass the --no-model flag:

php artisan richtext:install --no-model

This will only make sure you have the latest version of Trix installed locally. It will also create a resources/js/libs/trix.js file where Trix is initialized. You may add the following line to your main JS file, usually at resources/js/app.js:

import './libs/trix.js';

You may also want to copy the CSS snippets the install command will tell you about. Those are needed to render the new rich-text-attachment tag properly.

After this is done, you may want to install Trix and compile your assets:

npm install && npm run dev

Now, you have the <trix-editor> custom element available for you. Check out the Trix usage documentation to know more about it.


We're going to extract attachments before saving the rich text field (which uses Trix) in the database and minimize the content for storage. We replace the attachments with rich-text-attachment tags. If the attachment for a model attachable, we store a sgid which should globally identify that model. When storing images directly (say, for a simple image uploading where you don't have a model for representing that image on your application), we'll fill the rich-text-attachment with all the attachment's properties needded to render that image again. Storing a minimized (canonical) version of the rich text content means we don't store the inner contents of the attachment tags, only the metadata needded to render it again when needed.

There are two ways of using the package:

  1. With the recommended database structure where all rich text content will be stored outside of the model that has rich text content (recommended); and
  2. Only using the AsRichTextContent trait to cast a rich text content field on any model, on any table you want.

Below, we cover each usage way. It's recommended that you at least read the Trix documentation at some point to get an overview of the client-side of it.

The RichText Model

The recommended way is to keep the rich text content outside of the model itself. This will keep the models lean when you're manipulating them, and you can (eagerly or lazily) load the rich text fields only where you need the rich text content.

Here's how you would have two rich text fields on a Post model, say you need one for the body of the content and another one for internal notes you may have:

use Tonysm\RichTextLaravel\Models\Traits\HasRichText;

class Post extends Model
    use HasRichText;

    protected $guarded = [];

    protected $richTextFields = [

This trait will create dynamic relationships on the Post model, one for each field. These relationships will be called: richText{FieldName} and you may define the fields using underscore, so if you had a internal_notes field, that would have a richTextInternalNotes relationship added on the model.

For a better DX, the trait will also add a custom cast for the body and notes fields on the Post model to forward setting/getting operations to the relationship, since these fields will NOT be stored in the posts table. This means that you can use the Post model like this:

$post = Post::create(['body' => $body, 'notes' => $notes]);

And you can interact with the rich text fields just like you would with any regular field on the Post model:


Again, there's no body or notes fields on the Post model, these virtual fields will forward interactions to the relationship of that field. This means that when you interact with these fields, you're actually interacting with an instance of the RichText model. That model will have a body field that holds the rich text content. This field is then casted to an instance of the Content class. Calls to the RichText model will be forwarded to the body field on the RichText model, which is an instance of the Content class. This means that instead of:


Where the first "body" is the virtual field which will be an instance of the RichText model and the second "body" is the rich text content field on that model, which is an instance of the Content class, you can do:


Similarly to the Content class, the RichText model will implement the __toString magic method and render the HTML content (for the end user) by casting it to a string, which in blade can be done like this:

{!! $post->body !!}

Note: since the HTML output is NOT escaped, make sure you sanitize it before rendering. You can use something like the mews/purifier package, see the sanitization section for more about this.

The HasRichText trait will also add an scope which you can use to eager load the rich text fields (remember, each field will have its own relationship), which you can use like so:

// Loads all rich text fields (1 query for each field, since each has its own relationship)

// Loads only a specific field:

// Loads some specific fields (but not all):
Post::withRichText(['body', 'notes'])->get();

The database structure for this example would be something like this:

    id (primary key)
    created_at (timestamp)
    updated_at (timestamp)

    id (primary key)
    field (string)
    body (long text)
    record_type (string)
    record_id (unsigned big int)
    created_at (timestamp)
    updated_at (timestamp)
💡 If you use UUIDs, you may modify the migration that creates the rich_texts table to use uuidMorphs instead of morphs. However, that means all your model with Rich Text content must also use UUIDs.

We store a back-reference to the field name in the rich_texts table because a model may have multiple rich text fields, so that is used in the dynamic relationship the HasRichText creates for you. There's also a unique constraint on this table, which prevents having multiple entries for the same model/field pair.

Rendering the rich text content back to the Trix editor is a bit differently than rendering for the end users, so you may do that using the toTrixHtml method on the field, like so:

<input id="post_body" value="{!! $post->body->toTrixHtml() !!}" type="hidden" />
<trix-editor input="post_body" class="trix-content"></trix-editor>

Next, go to the attachments section to read more about attachables.

The AsRichTextContent Trait

In case you don't want to use the recommended structure (either because you have strong opinions here or you want to rule your own database structure), you may skip the entire recommended database structure and use the AsRichTextContent custom cast on your rich text content field. For instance, if you're storing the body field on the posts table, you may do it like so:

use Tonysm\RichTextLaravel\Casts\AsRichTextContent;

class Post extends Model
    protected $casts = [
        'body' => AsRichTextContent::class,

Then the custom cast will parse the HTML content and minify it for storage. Essentially, it will convert this content submitted by Trix which has only an image attachment:

    'content' => <<<HTML
    <h1>Hello World</h1>
    <figure data-trix-attachment='{
        "url": "",
        "width": 300,
        "height": 150,
        "contentType": "image/jpeg",
        "caption": "Something cool",
        <img src="" width="300" height="150" />
            Something cool

To this minified version:

<h1>Hello World</h1>
<rich-text-attachment content-type="image/jpeg" filename="blue.png" filesize="1168" height="300" href="" url="" width="300" caption="testing this caption" presentation="gallery"></rich-text-attachment>

And when it renders it again, it will re-render the remote image again inside the rich-text-attachment tag. You can render the content for viewing by simply echoing out the output, something like this:

{!! $post->content !!}

Note: since the HTML output is NOT escaped, make sure you sanitize it before rendering. You can use something like the mews/purifier package, see the sanitization section for more about this.

When feeding the Trix editor again, you need to do it differently:

<input id="post_body" value="{!! $post->body->toTrixHtml() !!}" type="hidden" />
<trix-editor input="post_body" class="trix-content"></trix-editor>

Rendering for the editor is a bit different, so it has to be like that.

Image Upload

The default image attachment implementation that ships with Trix won't work out of the box with Laravel. It's up to you to implement the image uploading and update the attachment accordingly after that with the image URL. Here's a suggested implementation using Stimulus, but you can do it on any front-end framework of your choice. We won't cover how to setup Stimulus on your project here, check their docs or, if you are already using the Turbo Laravel package, you can see how it installs Stimulus there.

First, we need to create the Stimulus controller, let's call it trix_controller.js:

import { Controller } from "stimulus";

export default class extends Controller {
    // ...

Then, we can listen to the trix-attachment-add event that the Trix editor dispatched whenever a new attachment is added, like so:


Now, let's implement the upload method in the trix_controller.js we just created:

import { Controller } from "stimulus";

export default class extends Controller {
    upload(event) {
        if (! event?.attachment?.file) {


    _uploadFile(attachment) {
        const form = new FormData();
        form.append('attachment', attachment.file);'/attachments', form, {
            onUploadProgress: (progressEvent) => {
                attachment.setUploadProgress(progressEvent.loaded / * 100);
        }).then(resp => {

This will send a POST request to /attachments with the attachment field, which should be a file Blob. The expected response should contain an image_url field. Here's what that route in Laravel could look like:

Route::middleware(['auth:sanctum', 'verified'])->post('attachments', function () {
        'attachment' => ['required', 'file'],

    $path = request()->file('attachment')->store('trix-attachments', 'uploads');

    return [
        'image_url' => Storage::disk('uploads')->url($path),

In this example, the image will be stored in the uploads disk inside the trix-attachments/ folder, and the URL to that file will be returned in the image_url property. That image will be stored in the Trix content as a remote image. This is only a simplified version of doing image uploads. Another way would be to use something like the Media Library package from Spatie and customizing the Media model and make it an attachable too. This way, the Media model would have its own SGID and you would set that attribute in the attachment as well, like so:


This would allow more advanced things like retrieving all attachments of the Media model in the Rich Text content and saving the embedded Media attachments as a relationship on the model that has rich text content, as we did with mentions in the attachments section. This way, you have a reference of which images are being used on rich text codes (can be useful if you want to prune the images later).

Content Attachments

With Trix we can have content Attachments. In order to cover this, let's build a users mentions feature on top of Trix. There's a good Rails Conf talk building out this entire feature but with Rails. The JavaScript portion is the same, so we're recreating that portion here.

To turn the User model into an Attachable, you must implement the AttachableContract and use the Attachable trait on the User model. Besides that, you must also implement a richTextRender(array $options): string where you tell the package how to render that model inside Trix:

use Tonysm\RichTextLaravel\Attachables\AttachableContract;
use Tonysm\RichTextLaravel\Attachables\Attachable;

class User extends Model implements AttachableContract
    use Attachable;

    public function richTextRender(array $options = []): string
        return view('users._mention', [
            'user' => $this,

The $options array passed to the richTextRender is there in case you're rendering multiple models inside a gallery, so you would get a in_gallery boolean field (optional) in that case, which is not the case for this user mentions example, so we can ignore it.

Then inside that users._mention Blade template you have full control over the HTML for this attachable field. You may want to show the user's avatar and their name in a span tag inside the attachment, so the users._mention view would look like this:

<span class="flex items-center space-x-1">
    <img src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" class="inline-block object-cover w-4 h-4 rounded-full" />
    <span>{{ $user->name }}</span>

Now, to the dropdown and to trigger opening the dropdown whenever users type the @ symbol inside the Trix editor, you may use something like Zurb's Tribute, or you could build your own dropbown and listen to keydown events on the editor watching when users type an @ symbol to open the dropdown. Your choice. Let's first create a new Stimulus controller for mentions called mentions_controller.js:

import { Controller } from "stimulus";

export default class extends Controller {
    // ...

Next, we're going to import Tribute and initiate it when the controller connects to the DOM element it's attached to - and also detach it when the controller disconnects, as well as which method it will use to look for users (the fetchUsers). We need to attach Tribute to the element so it knows where to add the event listeners which trigger the mentions dropdown. We also need to override what Tribute does when an option is picked, that's why we're adding our own implementation of the range.pasteHtml method on the instance (see the code below). We also need to :

import { Controller } from "stimulus";
import Tribute from 'tributejs';
import Trix from 'trix';


export default class extends Controller {
    connect() {

    disconnect() {

    initializeTribute() {
        this.tribute = new Tribute({
            allowSpaces: true,
            lookup: 'name',
            values: this.fetchUsers,

        this.tribute.range.pasteHtml = this._pasteHtml.bind(this);

    fetchUsers(text, callback) {
            .then(resp => callback(
            .catch(error => callback([]))

    _pasteHtml(html, startPosition, endPosition) {
        // We need to remove everything the user has typed
        // while searching for a user. We'll later inject
        // the mention into Trix as a content attachment.

        let range = this.editor.getSelectedRange();
        let position = range[0];
        let length = endPosition - startPosition;

        this.editor.setSelectedRange([position - length, position])

Now we need to attach the mentions controller to the Trix editor, just like we did with the image upload example:

    data-controller="trix mentions"

The GET /mentions?search= route could look something like this:

Route::middleware(['auth:sanctum', 'verified'])->get('mentions', function () {
    return auth()->user()->currentTeam->allUsers()
        ->when(request('search'), fn ($users, $search) => (
            $users->filter(fn (User $user) => str_starts_with(strtolower($user->name), strtolower($search)))
        ->map(fn (User $user) => [
            'sgid' => $user->richTextSgid(),
            'name' => $user->name,
            'content' => $user->richTextRender(),

You see we're returning the sgid, which is a method from the Attachable trait. It basically generates a unique global identifier for this model inside your application. More on that in the SGDI section. It also returns the user's name, which will be used by Tribute to show the options, and the content, which is the rich text render that we're going to insert into the Trix.Attachment. However, if you try to run this code yet, this should not work as you'd expect. After choosing an option, the stuff that you wrote while looking for the option, something like @Ton, should be gone but no attachment was placed instead. That's because we haven't implemented this part yet.

When you choose an option in Tribute, you need to listen to the tribute-replaced event and call a method inside the mentions_controller.js, let's hook it:

    data-controller="trix mentions"

Next, let's implement that method inside out mentions controller. The event that we get there should contain the user object (the one with the sgid, name, and content attributes we returned from the Controller) inside the detail.item.original path. We can take that and create an instance of the Trix.Attachment passing the sgid and content attributes to it, then inserting that attachment into the editor:

import { Controller } from "stimulus";
import Tribute from 'tributejs';
import Trix from 'trix';


export default class extends Controller {
    // ...

    tributeReplaced(e) {
        let mention = e.detail.item.original;
        let attachment = new Trix.Attachment({
            sgid: mention.sgid,
            content: mention.content,

        this.editor.insertString(" ");

    get editor() {
        return this.element.editor;

Now we're done. The example here is using user mentions, but you could really attach anything into the Trix document. And you have full control over how that document is rendered. When that document is submitted to your backend to be stored, the package will then minimize any content attachments, similar to what was done in the image upload example. But this time, the sgid will be used to identify the User attachable that was mentioned and the users._mention Blade template will be rendered again later whenever you're displaying that document. This is useful because you can tweak how user mentions look like inside your app without having to worry about the documents at-rest in the database.

You can later retrieve all attachments from that rich text content. See The Content Object section for more.

The Content Object

You may want to retrieve all the attachables in that rich text content at a later point and do something fancy with it, say actually storing the User's mentions associated with the Post model, for example. Or you can fetch all the links inside that rich text content and do something with it.

Getting Attachments

You may retrieve all the attachments of a rich content field using the attachments() method both in the RichText model instance or the Content instance:


This will return a collection of all the attachments, anything that is an attachable, really, so images and users, for instance - if you want only attachments of a specific attachable you can use the filter method on the collection, like so:

// Getting only attachments of users inside the rich text content.
    ->filter(fn (Attachment $attachment) => $attachment->attachable instanceof User)

Getting Links

To extract links from the rich text content you may call the links() method, like so:


Getting Attachment Galleries

Trix has a concept of galleries, you may want to retrieve all the galleries:


This should return a collection of all the image gallery DOMElements.

Getting Gallery Attachments

You may also want to get only the attachments inside of image galleries. You can achieve that like this:


Which should return a collection with all the attachments of the images inside galleries (all of them). You can then retrieve just the RemoteImage attachable instances like so:

    ->map(fn (Attachment $attachment) => $attachment->attachable)

Plain Text Rendering

Trix content can be converted to anything. This essentially means HTML > something. The package ships with a HTML > Plain Text implementation, so you can convert any Trix content to plain text by calling the toPlainText() method on it:


As an example, this rich text content:

<h1>Very Important Message<h1>
<p>This is an important message, with the following items:</p>
    <li>first item</li>
    <li>second item</li>
<p>And here's an image:</p>
<rich-text-attachment content-type="image/jpeg" filename="blue.png" filesize="1168" height="300" href="" url="" width="300" caption="The caption of the image" presentation="gallery"></rich-text-attachment>
<p>With a famous quote</p>
<blockquote>Lorem Ipsum Dolor - Lorense Ipsus</blockquote>

Will be converted to:

Very Important Message

This is an important message, with the following items:

    1. first item
    1. second item

And here's an image:

[The caption of the image]

With a famous quote

“Lorem Ipsum Dolor - Lorense Ipsus”


If you're attaching models, you can implement the richTextAsPlainText(?string $caption = null): string method on it, where you should return the plain text representation of that attachable. If the method is not implemented on the attachable and no caption is stored in the Trix attachment, that attachment won't be present in the Plain Text version of the content.


Since we're output unescaped HTML, you need to sanitize to avoid any security issues. One suggestion is to to use the mews/purifier package, before any final render (with the exception of rendering inside the value attribute of the input field that feeds Trix). That would look like this:

{!! clean($post->body) !!}

You need to add some customizations to the config file created when you install the mews/purifier package, like so:

return [
    // ...
    'settings' => [
        // ...
        'default' => [
            // ...
            'HTML.Allowed' => 'rich-text-attachment[sgid|content-type|url|href|filename|filesize|height|width|previewable|presentation|caption|data-trix-attachment|data-trix-attributes],div,b,strong,i,em,u,a[href|title|data-turbo-frame],ul,ol,li,p[style],br,span[style],img[width|height|alt|src],del,h1,blockquote,figure[data-trix-attributes|data-trix-attachment],figcaption,*[class]',
        // ...
        'custom_definition' => [
            // ...
            'elements' => [
                // ...
                ['rich-text-attachment', 'Block', 'Flow', 'Common'],
        // ...
        'custom_attributes' => [
            // ...
            ['rich-text-attachment', 'sgid', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'content-type', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'url', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'href', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'filename', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'filesize', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'height', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'width', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'previewable', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'presentation', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'caption', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'data-trix-attachment', new HTMLPurifier_AttrDef_Text],
            ['rich-text-attachment', 'data-trix-attributes', new HTMLPurifier_AttrDef_Text],
            ['figure', 'data-trix-attachment', new HTMLPurifier_AttrDef_Text],
            ['figure', 'data-trix-attributes', new HTMLPurifier_AttrDef_Text],
        // ...
        'custom_elements' => [
            // ...
            ['rich-text-attachment', 'Block', 'Flow', 'Common'],

Attention: I'm not an expert in HTML content sanitization, so take this with an extra grain of salt and, please, consult someone more with more security experience on this if you can.


When storing references of custom attachments, the package uses another package called GlobalID Laravel. We store a Signed Global ID, which means users cannot simply change the sgids at-rest. They would need to generate another valid signature using the APP_KEY, which is secret.

In case you want to rotate your key, you would need to loop-through all the rich text content, take all attachables with an sgid attribute, assign a new value to it with the new signature using the new secret, and store the content with that new value.


composer test


Please see CHANGELOG for more information on what has changed recently.


Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.



The MIT License (MIT). Please see License File for more information.