schmeits/filament-character-counter
Easy drop-in Filament character counter for TextField, Textarea and RichEditor fields in Filament v4 and v5. Add a characterLimit or maxLength to display a live character count, optionally shown inside the control. Translation strings are publishable, and v3.2 is supported via 1.x.
1. Match the version to your stack before anything else. The version matrix is the single most important install decision:
| Filament | Package | PHP |
|---|---|---|
| 3.2 | ^1.0 |
> 8.1 |
| 4.x | ^5.0 |
> 8.2 |
| 5.x | ^5.0 |
> 8.2 |
composer require schmeits/filament-character-counter:"^5.0"
Pin the major explicitly — a bare composer require on a Filament 3.2 app will happily drag in a 5.x that requires filament/forms 4+/5+ and leave you resolving conflicts for an afternoon. If you're on 3.2, you also lose RichEditor support (added in 1.3.0) and showInsideControl on Textarea (fixed in 1.3.3).
2. Publish translations only if you need to override the copy. The package ships translations out of the box; publishing is for rewording:
php artisan vendor:publish --tag="filament-character-counter-translations"
php artisan optimize:clear
Don't publish "just in case." Published lang files go stale silently (see Gotchas — character_seperator is no longer read as of v5.2.0).
3. Look at one existing field first. The fastest way to understand the package is to swap a single import and add one method call:
use Schmeits\FilamentCharacterCounter\Forms\Components\TextInput;
TextInput::make('title')
->characterLimit(60), // shows "42 / 60 characters"
That's the whole API surface for 90% of use. Everything else — maxLength(), showInsideControl(), showCharacterCounter() — is a modifier on top.
4. Pick your first real use case: SEO metadata. This package's natural home is a "SEO" tab on a resource, where editors need to see length against Google's truncation points:
TextInput::make('meta_title')
->characterLimit(60)
->maxLength(70),
Textarea::make('meta_description')
->characterLimit(155)
->maxLength(170)
->rows(3),
Notice both methods used together — that pattern is explained in Implementation Patterns and is the correct default.
maxLength() alone for hard limits, characterLimit() for guidanceThe two methods have distinct semantics, and the distinction has changed across releases:
maxLength(N) — hard enforcement (HTML maxlength + Filament's validation) and renders the counter. No need to add characterLimit() alongside it.characterLimit(N) — soft limit since v5.0.1: no maxlength, no validation. As of v5.2.0 an overflow is rendered in words, e.g. (12 too many), bolded and announced to screen readers.So the correct daily-use patterns are:
// "You must not exceed this" — API field, slug, external ID
TextInput::make('slug')->maxLength(100),
// "Aim for this, but don't break the page" — copy fields
Textarea::make('excerpt')->characterLimit(155)->maxLength(200),
The second pattern is what you want for marketing copy: the soft counter nudges ("12 too many"), the hard ceiling prevents a pathological paste from being stored. Don't rely on characterLimit() for data integrity — it does not stop the value reaching your database.
The package's components are Filament components, so importing them shadows the originals. In a form with twelve fields where three need counters, be explicit:
use Filament\Forms\Components\TextInput;
use Schmeits\FilamentCharacterCounter\Forms\Components\TextInput as CounterTextInput;
use Schmeits\FilamentCharacterCounter\Forms\Components\Textarea as CounterTextarea;
Blanket-swapping every import in a large resource makes it hard for the next developer to know which fields are counter-aware, and quietly couples unrelated fields to this dependency (which matters — see the maintenance note in Gotchas).
Limits are business rules that drift. Centralise them rather than sprinkling magic numbers:
enum FieldLimit: int
{
case SeoTitle = 60;
case SeoDescription = 155;
case Excerpt = 200;
}
CounterTextInput::make('meta_title')
->characterLimit(FieldLimit::SeoTitle->value)
->maxLength(FieldLimit::SeoTitle->value + 10),
This keeps the validation rule and the counter reading from one source, which is the only way to avoid the classic "the counter said 60 but the validator says 65" bug.
Closure + GetThe counter is dynamic-config aware, so you can gate it on sibling state:
use Filament\Forms\Get;
CounterTextarea::make('bio')
->characterLimit(255)
->showCharacterCounter(fn (Get $get) => $get('is_public')),
Practical uses: hide the counter on fields whose limit only applies to certain post types, on read-only contexts, or when a field is disabled anyway. Note the deprecation — use shouldShowCharacterCounter() if you need to read the resolved value; getShowCharacterCounter() is deprecated as of v5.2.0.
showInsideControl() for dense layoutsThe counter renders as a helper below the field by default. Inside a Grid with columns(3) that produces a ragged, tall form. showInsideControl(true) puts it in the input's suffix area:
CounterTextInput::make('sku')
->showInsideControl()
->maxLength(20),
Use it on narrow grid columns and on RichEditor (v5.0.0 added proper spacing for the RichEditor counter, and v5.2.0 added .fi-fo-rich-editor-wrapper). Leave it off for textareas where the text can run to the full width — an inside counter fights the scrollbar.
use Schmeits\FilamentCharacterCounter\Forms\Components\RichEditor;
RichEditor::make('content')
->characterLimit(2000)
->maxLength(2500),
The RichEditor counter reads the DOM text content of .fi-fo-rich-editor-content — i.e. visible text, not markup. That is the right behaviour for authors, but it means the browser count and the stored value diverge by the size of the HTML. Never validate the raw column with max:2000. Either strip tags before counting:
use Illuminate\Validation\Rule;
->rules([
fn () => Rule::when(
true,
fn ($attribute, $value, $fail) => mb_strlen(strip_tags($value)) > 2000
? $fail('Content is too long.')
: null,
),
])
…or store a denormalised content_length column on save via a model observer if you need to report on it in the admin. The first is simpler; the second is worth it if you expect to query on length later.
Override the label copy per panel or per tenant if your editors are non-technical:
// lang/vendor/filament-character-counter/en/character-counter.php (post-publish)
return [
'character_label' => 'characters',
'characters_over' => ':count too many',
];
characters_over is the v5.2.0 addition and is the one you'll actually want to tune — "too many" is blunter than most product copy.
characterLimit() will not protect your databaseThis bit people twice. v5.0.0 made characterLimit() call maxLength() under the hood; v5.0.1 reverted that ("now a soft limit again", issue #36). If you read a tutorial or a copy of the README from before v5.0.1, your fields are unenforced. Always confirm with a deliberate paste of 500 characters into a deployed form.
Through v5.1.0 the package rendered a copied Filament RichEditor view, which switched off file attachments, mentions, floating toolbars, link protocols and resizable images — no error, they just vanished. v5.2.0 fixed it by letting Filament render the editor and placing the counter around it. If any RichEditor in your app mysteriously lost its attachment button, this is why. Upgrade to ≥5.2.0 before you go debugging Filament's own config.
The counter now counts per code point in the browser, matching Laravel's mb_strlen-based max: rule. On older versions an emoji counted as two in the browser and one in validation, producing an off-by-N mismatch that's miserable to reproduce. Same release fixed autofill, dictation, paste, $set() and form resets — pre-5.2.0 the counter only followed key presses. Test with a paste, not just typing.
->live() and reactive updatesCounter stutter with ->live() was fixed back in 1.2.1, and v5.2.0 replaced the RichEditor's 500 ms polling loop (and its per-editor Livewire hook leak) with a commit hook. If you're on a version that polls, watch long editing sessions for memory growth — a leaked hook per editor mounts up in an SPA panel.
character_seperator (note the spelling) was used in older versions and is no longer read as of v5.2.0. A stale published lang file that only overrides the separator will look like it's silently being ignored — because it is. After upgrading, diff your published file against the package's current source and delete keys you don't need to override.
Earlier versions copied rich-editor and textarea Blade views and had to work around async Alpine loading in SPA mode (1.3.2/1.3.3). These were real bugs with real fixes; if counters appear for a moment then vanish on wire:navigate, upgrade first. Debugging path: Vue/DevTools → check for x-data on the wrapper → console for Alpine expression errors → confirm the rendered markup contains fi-input-charcounter classes.
Since v5.2.0 the stylesheet is plain CSS using Filament's colour variables and works in dark mode with no compilation. The exceeded-state class is fi-input-charcounter-exceeded (previously warning) — update any custom CSS that targeted the old name. Because it's plain CSS with no build step, theming is a one-line override in your panel's custom stylesheet rather than a Vite dependency.
HasCharacterLimit is the trait that carries characterLimit(), maxLength() wiring and showCharacterCounter()/shouldShowCharacterCounter(). If you have a custom field extending Filament's TextInput (a currency input, a masked phone field), you don't need to swap it for the package's component — just use the trait:
use Schmeits\FilamentCharacterCounter\Forms\Concerns\HasCharacterLimit;
class CurrencyInput extends \Filament\Forms\Components\TextInput
{
use HasCharacterLimit;
}
This is the cleanest integration point and the reason not to treat the components as an all-or-nothing replacement.
Since v5.2.0 the package requires only filament/forms and filament/support rather than the whole filament/filament panel package — a real improvement, but also a sign that if you're pinned to an older Filament 4.x lockfile, Composer may resolve a different package version than you expect. Check composer why schmeits/filament-character-counter after upgrading.
With ~34 stars, zero dependents and a single maintainer, treat this as a small, forkable utility rather than core infrastructure. The mitigation is cheap: the surface is three components and a trait, the views are Blade you can vendor with vendor:publish, and the licence is MIT. If it ever stalls, replacing characterLimit() with a ->hint(fn ($state) => strlen($state).'/60') plus a maxLength() is a half-day of work — worth knowing before you spread it across fifty fields.
The package has unit tests for all three component types (composer test), so you can assert on rendered output in your own suite. For Livewire form tests, assert the maxlength attribute exists on hard-limited fields and that characterLimit-only fields are absent — that's the cheapest regression test for the soft-vs-hard confusion above.
How can I help you explore Laravel packages today?