te7a-houdini/laravel-trix
A Laravel package that integrates the Trix rich text editor and manages file attachments. It stores uploaded files, generates attachment URLs, validates uploads, and provides configurable storage and model setup for adding Trix fields to Eloquent models quickly.
Install with composer require te7a-houdini/laravel-trix. On Laravel 12 you want ^3.1; if you are still on 10/11, check composer.json constraints before assuming the latest tag works — 3.1.0's headline is Laravel 12 support, not backward compatibility.
Publish and run the package's migrations. This is a hard prerequisite: the package stores rich text and attachments in its own polymorphic tables (trix_rich_texts, trix_attachments) rather than in a column on your model. Run php artisan vendor:publish and select the package's provider, or use its tagged publishes (...-config, ...-migrations, ...-assets) if available in your version. Then php artisan migrate.
If you use the default public disk, run php artisan storage:link — without it, uploaded attachment previews inside the editor will 404 even though the upload succeeded.
Look first at three things: config/trix.php (disk, directory, validation rules, any model/route overrides), the package service provider (it registers the <x-trix-input> component and the attachment upload route), and the TrixAttachment model (it owns file deletion on cleanup).
Wire it into a model: add the HasTrixRichText trait and make the field name mass-assignable. In the form use the component:
<x-trix-input id="body" name="body" :value="old('body', $post->body ?? '')" />
The id matters — it must match the hidden input that Trix attaches to, and it must be unique per page.
First use case: a Post with a body rich-text field. Create a post through a normal controller ($request->validate(['body' => ['nullable','string']]); $post->update($request->only('body'))), confirm a row lands in trix_rich_texts keyed by the field name, then read it back through the trait's relation (e.g. $post->trixRichTexts->firstWhere('field', 'body')?->content — verify the exact accessor against the README for your version) and render with {!! !!}. Upload an image in the editor and confirm the file lands on the configured disk before doing anything else.
Wrap the read path once. Don't scatter relation lookups across Blade files. Add a small accessor on the model (bodyHtml(): string) that pulls the rich text row and returns the sanitized HTML. This gives you one place to change when the relation name shifts between minor versions, and one place to add caching or a fallback.
Eager load. If any index page renders rich text for N models, eager load the rich text relation in the controller (->with('trixRichTexts') or whatever the trait exposes) or you get an N+1 on every listing.
Store the Trix HTML as-is, validate conservatively. Trix submits HTML, not markdown. Validate nullable|string|max:... and let the package's sanitization handle the write. Don't try to strip_tags() before saving — you'll destroy attachment <figure> markup and lose images.
Keep a plain-text mirror for previews. For excerpts, search, or meta descriptions, derive at read time with Str::limit(strip_tags($html), 160) (or persist a body_text column if you need to query it). Searching HTML with LIKE is a common and painful mistake here.
Treat attachments as a lifecycle, not a one-off. The flow is: editor uploads → file on disk + trix_attachments row → on save the attachment is associated with the model → on model delete the file is cleaned up. Two things follow:
storage/app/public/...). If the association isn't made, the observer has nothing to clean.Per-environment disk config. public locally, S3 (or similar) in production. Set it via config/env rather than editing the component, and re-run php artisan config:cache as part of deploy.
Multi-tenant / per-user directories. Override the directory at runtime (config(['trix.directory' => "tenants/{$tenant->id}/trix"])) inside a middleware or service that runs before the upload route, rather than publishing and editing the package config per tenant.
Livewire and Alpine. The Trix editor is DOM-managed, so binding it directly to wire:model will fight the component. Use wire:ignore on the wrapper and mirror the value explicitly:
<div wire:ignore x-data x-on:trix-change="$wire.set('body', $event.target.value)">
<x-trix-input id="body" name="body" :value="$body" />
</div>
The same pattern applies for plain Alpine state (x-on:trix-change="body = $event.target.value") when you need live character counts or previews.
Customize via published views, not by editing vendor. Publish the component view when you need extra classes, a placeholder, toolbar tweaks, or data-* hooks for JS. Keep the change minimal so upgrades stay cheap.
Testing. Storage::fake('public') plus a feature test that posts a file to the package's attachment route and asserts a trix_attachments row and a stored file — this is the fastest way to catch a broken route registration or a disk misconfiguration before it hits a browser. Then assert your model save produces the expected sanitized HTML in trix_rich_texts.
Form Requests / actions. Because the payload is just a string field, this package composes cleanly with form objects and action classes — pass the sanitized HTML string into your action and let it persist normally. No special handling needed.
php artisan config:clear (and re-cache on deploy). Similarly, if you override the package config at runtime in a middleware, verify the upload route actually runs after that middleware.web-middleware route for uploads. In an API-only or custom-middleware app, uploads fail silently-ish (the editor just shows an error state). Check php artisan route:list for the trix routes, and confirm the endpoint is protected — by default it may be reachable by any visitor, which means anyone can write files to your disk. Lock it down with auth middleware or a signed route, and tighten the default attachment validation rules while you're there.upload_max_filesize/post_max_size and your web server's body limit against the configured validation rule.<x-trix-input> components with the same id on one page produce one working editor and one dead one. Generate unique ids (body-{{ $post->id }}, Str::uuid()).old() is not automatic. On a validation failure the editor reverts to empty unless you pass :value="old('body', ...)". This is the most common "we lost the user's content" bug report.DB::table()->update(), imports, seeders, or content restored from a backup bypass it entirely. If content can enter by any other route, sanitize on render too, and never {!! !!} untrusted HTML. Also round-trip a document with an image and a caption after upgrading — a sanitizer tightening can strip data-trix-* attributes and break attachment rendering without throwing an error.<figure>, <figcaption>, data-trix-attachment (base64 JSON), and content-type attributes. If your editor "saves but loses images on reload," this is the culprit — compare the submitted HTML to what's in the database.trix_rich_texts / trix_attachments rows unless the relationship is configured to cascade. Add a deleting hook or a DB-level cascade; otherwise you accumulate rows and orphaned files indefinitely.POST to the trix attachment route (status and response body) → storage/app/public/<configured-directory> for the file → trix_attachments table for the row → trix_rich_texts for the sanitized HTML → php artisan route:list | grep trix if nothing reaches the server at all.vendor/, which will be silently reverted on the next composer update.How can I help you explore Laravel packages today?