In our previous Rich Text post, we implemented file attachments using the Rich Text Laravel package, but we've only touched the surface of Trix functionality. In this post, we'll dive a little deeper into Trix's attachments functionality by implementing a User mentions feature.
Let's get started!
We'll pick up from where we previously left off. We have a basic Trix input and integrated its file-uploading attachments feature. In case you didn't follow along the previous post but want to hit the ground running for this one, I've prepared a branch in the same repository called rich-text-laravel-intro
, you may clone it, checkout the branch, and follow along from there:
git clone git@github.com:tonysm/laravel-bootcamp-blade-chirper.git chirpercd chirper/git checkout rich-text-laravel-introcp .env.example .envcomposer installphp artisan key:generatephp artisan migratenpm install && npm run buildphp artisan serve
That should get the application up and running.
In our Chirper app, users will be able to mention any existing User, including themselves. This will store a reference to the mentioned user model in the Chirp's content
rich text attribute, and we'll have full control over the rendered HTML of mentions on the server side!
We'll use Tribute.js to detect when the user types the @
symbol inside the Trix editor to show them a list of options based on what they've typed after the symbol.
Let's install Tribute:
npm install --save-dev tributejs
Then, add the import statement to the app.js
file:
import "./libs/trix"; import "./bootstrap"; +import "tributejs/dist/tribute.css";+import Tribute from "tributejs";+window.Tribute = Tribute; import Alpine from "alpinejs"; window.Alpine = Alpine; Alpine.start();
This will ensure Tribute is registered globally, but it doesn't do anything with our Trix component. Let's update the <x-trix-input />
Blade component to use Tribute:
-@props(['id', 'name', 'value' => '', 'toolbar' => '', 'acceptFiles' => false])+@props(['id', 'name', 'value' => '', 'toolbar' => '', 'acceptFiles' => false, 'acceptMentions' => false]) <input
type="hidden" name="{{ $name }}" id="{{ $id }}_input" value="{{ $value }}" /> <trix-toolbar @class([ "[&_.trix-button]:bg-white [&_.trix-button.trix-active]:bg-gray-300", "[&>.trix-button-row>.trix-button-group:not(.trix-button-group--text-tools)]:hidden [&_.trix-button--icon-strike]:hidden" => $toolbar === 'mini', ]) id="{{ $id }}_toolbar" ></trix-toolbar> <trix-editor id="{{ $id }}" toolbar="{{ $id }}_toolbar" input="{{ $id }}_input" x-data="{+ init() {+ @if ($acceptMentions)+ const tribute = new Tribute({+ allowSpaces: true,+ lookup: 'name',+ values: this.fetchMentions.bind(this),+ })+ tribute.attach(this.$el)+ tribute.range.pasteHtml = this.pasteHtmlOnTribute.bind(this)+ @endif+ },+ fetchMentions(text, callback) {+ fetch(`/mentions?search=${text}`)+ .then(resp => resp.json())+ .then(users => callback(users))+ .catch(() => callback([]))+ },+ pasteHtmlOnTribute(html, startPosition, endPosition) {+ let range = this.$el.editor.getSelectedRange()+ let position = range[0]+ let length = endPosition - startPosition+ this.$el.editor.setSelectedRange([position - length, position])+ this.$el.editor.deleteInDirection('backward')+ },+ addMention({ detail: { item: { original }}}) {+ this.$el.editor.insertAttachment(new Trix.Attachment(original))+ this.$el.editor.insertString(' ')+ }, uploadAttachment(event) { if (! event.attachment?.file) return const form = new FormData()
form.append('attachment', event.attachment.file) fetch('/attachments', { method: 'POST', body: form, headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': document.head.querySelector('meta[name=csrf-token]').content, } }).then(resp => resp.json()).then((data) => { event.attachment.setAttributes({ url: data.attachment_url, href: data.attachment_url, }) }).catch(() => event.attachment.remove()) } }" @if ($acceptFiles) x-on:trix-attachment-add="uploadAttachment" @else x-on:trix-file-accept.prevent x-on:trix-attachment-add="$event.attachment.file && $event.attachment.remove()" @endif+ @if ($acceptMentions)+ x-on:tribute-replaced="addMention"+ @endif {{ $attributes->merge(['class' => 'trix-content border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:!text-white focus:ring-1 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }} ></trix-editor>
In these changes, we're adding a new :accept-mentions
boolean attribute, defaulting to false
. Next, we're adding an init()
function to our Alpine component, which Alpine will execute automatically when the element enters the DOM. In this function, we'll initialize the Tribute object and attach it to the <trix-editor>
element.
When instantiating the Tribute object, we're telling it which function it should use for the load options, which will be the fetchMentions
function. This function will receive the text the user is typing after the @
symbol and a callback we can execute by passing a new set of options. It's up to us to do the fetch request and load the options. We're making a request to GET /mentions
passing the text as a search parameter.
Additionally, we need to override the pasteHtml
method of Tribute. That's because, by default, Tribute would try to replace the HTML in the [contenteditable]
element that Trix creates. But that's not how Trix works. The [contenteditable]
element is a UI + input for Trix's document model. When we're typing there, the signals are sent to Trix's document model, and that generates the output we see inside the [contenteditable]
. We shouldn't update the [contenteditable]
element directly.
Instead, our pasteHtmlOnTribute
function will use the data provided to update the Trix document and remove everything the user has typed. Ideally, this would also be where we'd create the attachment, but at this point, we don't have access to the option object the user chose from the Tribute dropdown.
However, Tribute will think this worked, so it will dispatch the tribute-replaced
event, which gives us access to the option's original object that we returned from our endpoint. We can then use this object to feed a custom Trix attachment with:
editor.insertAttachment(new Trix.Attachment(original));editor.insertString(" ");
That's it for the client-side part. Next, we need to turn our User model into an attachable entity. To do that, let's make it implement the AttachableContract
. We must implement a handful of methods, but to make things easier, the package ships with an Attachable
trait we can use, which provides some reasonable defaults. With that trait, our work is minimal:
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Tonysm\RichTextLaravel\Attachables\AttachableContract; use Tonysm\RichTextLaravel\Models\Traits\HasRichText; -class User extends Authenticatable+class User extends Authenticatable implements AttachableContract { use HasFactory, Notifiable; use HasRichText;+ use User\Mentioned;
/** * The dynamic attributes for the Trix editor. * * @var array<int|string, string> */ protected $richTextAttributes = [ 'bio', ]; /** * The attributes that are mass assignable. * * @var array<int, string> */ protected $fillable = [ 'name', 'email', 'password', 'bio', ]; /** * The attributes that should be hidden for serialization. * * @var array<int, string> */ protected $hidden = [ 'password', 'remember_token', ]; /** * Get the attributes that should be cast. * * @return array<string, string> */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; } public function chirps(): HasMany { return $this->hasMany(Chirp::class); } }
Now, let's create that trait in app/Models/User/Mentioned.php
with the following contents:
<?php namespace App\Models\User; use Tonysm\RichTextLaravel\Attachables\Attachable; trait Mentioned{ use Attachable; const ATTACHABLE_CONTENT_TYPE = 'application/vnd.chirper.user-mention+html'; public function richTextAsPlainText(): string { return e($this->name); } public function richTextRender(array $options = []): string { return trim(view('rich-texts.partials.mention', [ 'user' => $this, ])->render()); } public function richTextContentType(): string { return static::ATTACHABLE_CONTENT_TYPE; }}
Notice we're using a custom content-type for user mentions. This is recommended for custom attachments, although it is not that relevant for model-baked attachments, since we'll use Signed Global IDs (aka. SGID) to figure out which model it was referencing.
The richTextRender
method is the most important one here. This generates the HTML that will be rendered inside the Trix editor when we're attaching users. We need to create that Blade view at resources/views/rich-texts/partials/mention.blade.php
(the location is arbitrary):
<span class="relative whitespace-nowrap !pl-5"> <span class="absolute left-0 inset-y-0 h-5 w-5 rounded-full bg-indigo-500 shadow-sm"></span> <span class="whitespace-nowrap [trix-editor_&]:pl-1">{{ $user->name }}</span></span>
The closing </span>
tag looks like it may be a bit off, however, that's intentional. If you add a new line, the HTML will generate a
entity, which will mess up the spacing. Just keep it there for now.
Now, let's create the MentionsController
:
php artisan make:controller MentionsController
And register the route in routes/web.php
:
<?php use App\Http\Controllers\AttachmentController; use App\Http\Controllers\ChirpController;+use App\Http\Controllers\MentionsController; use App\Http\Controllers\ProfileController; use Illuminate\Support\Facades\Route;
Route::get('/', function () { return view('welcome'); }); Route::get('/dashboard', function () { return view('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); }); Route::resource('chirps', ChirpController::class) ->only(['index', 'store', 'edit', 'update', 'destroy']) ->middleware(['auth', 'verified']); Route::post('attachments', AttachmentController::class) ->name('attachments.store') ->middleware(['auth', 'verified']); +Route::get('mentions', MentionsController::class)+ ->name('mentions.index')+ ->middleware(['auth', 'verified']); require __DIR__.'/auth.php';
Since this controller will only have a single action, we can make it invokable:
<?php namespace App\Http\Controllers; use App\Models\User;use Illuminate\Http\Request; class MentionsController extends Controller{ public function __invoke(Request $request) { return User::query() ->when($request->input('search'), fn ($query, $search) => ( $query->where('name', 'like', "%{$search}%") )) ->orderBy('name') ->take(20) ->get() ->map(fn (User $user) => [ 'sgid' => $user->richTextSgid(), 'name' => $user->name, 'content' => $user->richTextRender(), 'contentType' => $user->richTextContentType(), ]); }}
Perhaps the most interesting part to discuss here is the last map()
call. We'll return a few properties for each mention:
sgid
stands for Signed Global ID; it's a cryptographically signed string that uniquely identifies a model in our application (more on that later)name
property is what Tribute is configured to look up (what is typed after the @
symbol)content
contains the HTML that will be rendered inside TrixcontentType
will be used by Trix as wellIn summary, the sgid
, content
, and contentType
are the ones required for the Trix.Attachment
object. The name is only for Tribute.
The sgid
is provided by the globalid-laravel package (another port of a Rails gem called globalid). The basic idea is that we'll generate a URL for a model, something like:
chirper://user/42
Where "chirper" is the app name, "user" is the Custom Polymorphic Type alias of the model, and "42" is the ID. As is, this URL would be a regular Global ID (GID), but we can create a Signed Global ID (SGID) instead, which will cryptographically sign the URL with a derived key based on your app's secret key. What's important here is that users cannot change this themselves. The sgid
must be signed by our backend. If you want to know more about Globalid Laravel, I have a video about it; this package can be useful in other contexts as well.
So, Rich Text Laravel will store this identifier in the minimized version of the HTML in the database. In other words, the HTML *content* of attachments is not stored in the database. We only store some attributes in a minimized tag called <rich-text-attachment>
. When the document is rendered again, it will maximize it to the full HTML. The final render will be slightly different whether we're rendering to feed the Trix editor or rendering for the final output. That's why we must call the toTrixHtml()
when feeding Trix with the stored value of our bio
and Chirp message!
This allows us to evolve the HTML of attachments as our application styles change. If we stored the final HTML in the database, it would be tricky to make design changes to that.
Let's enable the mentions feature in the Chirp forms by passing the :accept-mentions=true
to the <x-trix-input />
Blade component in the create and edit Chirps forms:
-<x-trix-input :accept-files="true" ... /> +<x-trix-input :accept-mentions="true" :accept-files="true" ... />
Now, rebuild the assets one more time:
npm run build
If you open the editor and type the @
sign, it should work!
You may change the styles of this dropdown to look however you want it to look, it's just a few lines of CSS (here's the default one for reference).
When you select the option, you should see the HTML from the mention.blade.php
appearing inside the Editor:
Then, once you save the Chirp, you should see it in the final HTML rendered like that:
Now, we should be able to extract the attachment from the Chirp. Let's Tinker with it:
php artisan tinker
Then type:
App\Models\Chirp::latest()->first()->content->attachments()
You should see something like this:
= Illuminate\Support\Collection {#6069 all: [ Tonysm\RichTextLaravel\Attachment {#6110 +node: DOMElement {#6081 ... }, +attachable: App\Models\User {#6136 id: 1, name: "Tony Messias", email: "tonysm@hey.com", email_verified_at: null, created_at: "2025-01-26 23:58:09", updated_at: "2025-01-26 23:58:09", }, }, ], }
Again, if you're only interested in the attachables, you may use the attachables()
method instead:
App\Models\Chirp::latest()->first()->content->attachables()
Which should give you an output like this:
= Illuminate\Support\Collection {#6225 all: [ App\Models\User {#6166 id: 1, name: "Tony Messias", email: "tonysm@hey.com", email_verified_at: null, created_at: "2025-01-26 23:58:09", updated_at: "2025-01-26 23:58:09", }, ], }
If you're curious, this is the minimized HTML for user attachments:
<div> Hey, <rich-text-attachment content-type="application/vnd.chirper.user-mention+html" sgid="[REDACTED]" ></rich-text-attachment> this works!</div>
Since we're only storing the sgid
identifier, when the user updates their name, or profile picture, or if we want to tweak the way mentions are displayed in our documents, we can do that! Try that for yourself, mention your own user, then update your name or change the fake image background color in the mention.blade.php
partial. You should see something like this:
While we could end the post here, let's take it an extra mile, and see how we can build a notification system for mentions!
Whenever the Chirp model is saved, we'll scan the content
rich text attribute for user mentions and create mentions records. Then, we can trigger email notifications for each one.
Before we start building it, we need to run a fake mail service. I use Takeout, so I can spin up a Mailpit instance in no time by running:
takeout enable mailpit
Then, select all the default values. After that, Mailpit should be running and we can access its web interface at localhost:8025.
Next, update the .env
to point to the local Mailpit SMTP port:
MAIL_MAILER=smtpMAIL_HOST=127.0.0.1MAIL_PORT=1025
Okay, so the app is ready to send email notifications. Let's create the Mention
model:
php artisan make:model -mf Mention
Here's the initial version of the Mention
model:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property-read Chirp $chirp * @property-read User $user */class Mention extends Model{ /** @use HasFactory<\Database\Factories\MentionFactory> */ use HasFactory; protected $guarded = []; public function chirp(): BelongsTo { return $this->belongsTo(Chirp::class); } public function user(): BelongsTo { return $this->belongsTo(User::class); }}
Update the create_mentions_table
to add the foreign keys and unique constraint:
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('mentions', function (Blueprint $table) { $table->id();+ $table->foreignId('chirp_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->timestamps(); + $table->unique(['chirp_id', 'user_id']); }); } public function down(): void { Schema::dropIfExists('mentions'); } };
Then, we can migrate the database:
php artisan migrate
Now, let's add a new behavior to the Chirp model. We'll add a new Mentions trait, so let's update the Chirp model to use it:
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Tonysm\RichTextLaravel\Models\Traits\HasRichText; class Chirp extends Model { use HasFactory; use HasRichText;+ use Chirp\Mentions; protected $richTextAttributes = [ 'content', ];
protected $fillable = [ 'content', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
And here's that app\Models\Chirp\Mentions.php
trait:
<?php namespace App\Models\Chirp; use App\Models\Chirp;use App\Models\Mention;use App\Models\User;use Illuminate\Database\Eloquent\Relations\HasMany;use Illuminate\Database\UniqueConstraintViolationException; trait Mentions{ public static function bootMentions(): void { static::saved(function (Chirp $chirp) { $chirp->syncMentions(); }); } public function mentions(): HasMany { return $this->hasMany(Mention::class); } protected function syncMentions(): void { $mentionedUsers = (clone $this->content)->attachables() ->filter(fn ($attachable) => $attachable instanceof User) ->values(); $this->mentions() ->when($mentionedUsers->isNotEmpty(), fn ($query) => ( $query->whereNotIn('user_id', $mentionedUsers->pluck('id')->all()) )) ->delete(); foreach ($mentionedUsers as $user) { try { $this->mentions()->create([ 'user_id' => $user->getKey(), ]); } catch (UniqueConstraintViolationException) { // No need to anything... } } }}
We're hooking into the saved event of the Chirp model and calling the syncMentions()
method, which will scan the content
rich text attribute looking for User attachables. Then, we'll delete any prior mentions this Chirp had that are not present in the new mentioned users list and attempt to create them in the database. We might get a UniqueConstraintViolationException
since the mention may already exist, but that's fine, we don't have to do anything in that case.
Now, we need to hook into the Mention model's created
event and dispatch a job to notify the mentioned user later:
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property-read Chirp $chirp * @property-read User $user */ class Mention extends Model { /** @use HasFactory<\Database\Factories\MentionFactory> */ use HasFactory;+ use Mention\NotifiesMentionedUsers; protected $guarded = [];
public function chirp(): BelongsTo { return $this->belongsTo(Chirp::class); } public function user(): BelongsTo { return $this->belongsTo(User::class); } }
The NotifiesMentionedUsers
trait is where we're going to hook into the model's created event to notify the mentioned user, but only if it's not the same user as the Chirp author (silly in this context, but in a real example, you'd want to do something like that):
<?php namespace App\Models\Mention; use App\Models\Mention;use App\Notifications\MentionedInChirp; trait NotifiesMentionedUsers{ public static function bootNotifiesMentionedUsers(): void { static::created(function (Mention $mention) { $mention->notifyMentionedUserLater(); }); } protected function notifyMentionedUserLater(): void { if ($this->user->is($this->chirp->user)) { return; } $this->user->notify(new MentionedInChirp($this->chirp)); }}
Now, we need to generate that notification:
php artisan make:notification MentionedInChirp
Then, update it to create the message:
<?php namespace App\Notifications; use App\Models\Chirp;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Notifications\Messages\MailMessage;use Illuminate\Notifications\Notification; class MentionedInChirp extends Notification implements ShouldQueue{ use Queueable; public function __construct( public Chirp $chirp, ) {} public function via(object $notifiable): array { return ['mail']; } public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject(__('You were mentioned in a Chirp')) ->line(__('You were mentioned in a chirp:')) ->line('"' . e($this->chirp->content?->toPlainText()) . '"') ->action(__('View Chirp'), url('/')) ->line(__('Thank you for using Chirper!')); }}
Notice that we're implementing the ShouldQueue
in the notification here, so Laravel will automatically send this to the queue. This way, we don't need to create a dedicated job just to send the notification.
If you mention a user, you should see the notification popping up in the Mailpit dashboard:
There you have it! I hope you can see how powerful Trix attachments are. While we highlighted how to embed Eloquent models, it's great to know that embedding doesn't necessarily need to be a database record—we can emded pretty much anything into a Trix document!
Hope you found this useful and see you next time!
We appreciate your interest.
We will get right back to you.