optimistdigital/nova-color-field
A Laravel Nova color picker field that adds an easy-to-use color input to your resources, letting you choose, preview, and save colors in Nova forms with simple configuration.
0. Clear the compatibility gate first. This is the only step that can actually stop you. Check the field's declared Nova constraint against your installed Nova before you write any code:
composer show laravel/nova | grep versions
composer show outl1ne/nova-color-field --all | grep -A2 requires
Nova 4 moved from Vue 2 to Vue 3, and third-party field assets built for Nova 3 will not render on Nova 4/5. If the constraint doesn't cover your install, stop and use the fallback (Text::make('Color')->rules('hex_color')) instead of debugging a blank form.
1. Install and confirm the package name. The project moved from optimistdigital/* to outl1ne/*. Install one, not both:
composer require outl1ne/nova-color-field
Then confirm the PHP namespace the installed version actually ships (Outl1ne\NovaColorField\Color on current releases, OptimistDigital\NovaColorField\Color on old ones) by reading vendor/outl1ne/nova-color-field/src/ — do not trust a blog post or a half-migrated fork.
2. Lock the storage format before the migration ships. That means two decisions:
// 7 chars for #RRGGBB, 9 if you allow alpha (#RRGGBBAA)
$table->string('color', 7)->nullable()->after('name');
3. Add the field to a resource.
use Outl1ne\NovaColorField\Color;
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Name'),
Color::make('Color')
->rules('nullable', 'hex_color')
->default('#3B82F6'),
];
}
4. First use case. Put it on one low-traffic resource where color is genuinely a label, not a brand decision — a Tag, Status, or Category. Index and detail views render a swatch automatically, so you get the visual payoff immediately: open the index, confirm the swatch renders, open a record, edit the color, save, reload. Ten minutes, and you've validated the field end-to-end.
Treat the stored value as a string contract, not a UI concern. Everything downstream (API responses, Blade views, exports, mail templates) consumes the raw hex. Write the contract down once — #RRGGBB, uppercase, nullable — and enforce it centrally rather than per-resource.
// app/Support/Color.php
final class Color
{
public const HEX = '/^#[0-9A-F]{6}$/';
public const DEFAULT = '#3B82F6';
}
Always pair the field with a server-side rule. The picker guarantees a valid hex from the form. It guarantees nothing about seeders, Artisan imports, API writes, or a second form on the same column. Nova's built-in hex_color rule is the cheap default, but verify its regex on your Laravel version — it accepts short-form #RGB on some releases, which will silently violate a 7-char contract. Use an explicit regex when you need exactness:
Color::make('Color')
->rules('nullable', 'regex:' . Color::HEX)
->help('Hex only, e.g. #3B82F6'),
Pick the picker variant for the job. The field wraps vue-color, so you can choose the interaction model per field rather than accepting the default:
Color::make('Accent') // default picker
Color::make('Accent')->picker('sketch');
Color::make('Brand')->picker('swatches'); // constrained palette, see below
For internal tooling, swatches is usually the right answer for anything a non-technical operator touches: it turns "free color selection" into "pick one of our six approved colors" without you writing a policy.
Constrain the palette instead of writing an approval workflow. If the product decision is "ops may only use approved status colors," express it as data:
Color::make('Status Color')
->picker('swatches')
->withProps(['palette' => ['#22C55E', '#F59E0B', '#EF4444', '#6B7280']])
->rules('nullable', Rule::in(['#22C55E', '#F59E0B', '#EF4444', '#6B7280']));
The palette prop steers the UI; the in: rule is what actually enforces it. Ship both or ship neither.
Normalize with fillUsing / resolveUsing rather than trusting the client. A picker will happily submit lowercase hex, or an empty string on clear. Decide once:
Color::make('Color')
->fillUsing(function ($request, $model, $attribute) {
$value = $request->input($attribute);
$model->{$attribute} = filled($value) ? strtoupper($value) : null;
});
Storing '' instead of null is a real annoyance — a nullable column with empty-string rows breaks whereNull() queries and ?? fallbacks in Blade.
Pair the swatch with a readable label. The index swatch is a small colored chip with no text context. For anything with more than a handful of rows, put the color next to a text column:
Color::make('Color', 'color')->hideFromIndex(),
Text::make('Color')
->asHtml()
->displayUsing(fn ($value, $resource) =>
'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;'
. 'border:1px solid rgba(0,0,0,.15);background:' . e($resource->color ?? '#fff')
. '"></span> ' . e($resource->color ?? '—'))
->onlyOnIndex(),
Use Nova's conditional plumbing for "custom color" flows. A very common day-to-day pattern is a preset enum plus an override picker:
Select::make('Color Mode')->options(['preset' => 'Preset', 'custom' => 'Custom']),
Select::make('Preset')->options(TagPalette::MAP)->hideWhen(fn ($request) => $request->color_mode !== 'preset'),
Color::make('Color')->hideWhen(fn ($request) => $request->color_mode !== 'custom')
->rules('required_if:color_mode,custom', 'regex:' . Color::HEX),
Replacing an existing hex text field is a read-compatible swap — both store strings. But audit the data before you tighten validation, or you'll ship a rule that rejects rows users can still see in the index:
SELECT id, color FROM tags
WHERE color IS NOT NULL AND color NOT REGEXP '^#[0-9A-Fa-f]{6}$';
Backfill, then deploy the rule. Not the other way around.
Keep it out of your API layer's type system unless you need it. A plain string column with a documented format is fine. If you find yourself branching on brightness, put the logic in a small value object or accessor rather than sprinkling hexdec() calls through controllers.
The rename is the most likely cause of a bad afternoon. optimistdigital/nova-color-field and outl1ne/nova-color-field can both end up in composer.lock via transitive or stale requirements, producing two copies of the field class and a "class not found" or "duplicate field" error that only manifests when Nova boots. Check with:
composer show | grep -i color-field # expect exactly one line
If both appear, composer remove the old one and purge any published assets left in public/vendor/.
Pin the exact version, not ^. Third-party Nova fields are the most volatile dependency in a typical admin panel, because they ship compiled Vue assets tied to a Nova major. "outl1ne/nova-color-field": "1.2.3" costs you nothing and makes Nova upgrades a deliberate, testable event instead of a surprise.
Nova upgrades are your only recurring cost — budget it. Roughly an hour per Nova major to check: does the form render, does the swatch render in index/detail, does dark mode look right, does the field still save. Put the field on a checklist next to your other third-party Nova fields; do not rely on CI to catch a broken Vue asset.
Column truncation is silent on MySQL in non-strict mode. An 8-character #RRGGBBAA value written to varchar(7) becomes #RRGGBB on some configurations and errors on others. That's a data-loss bug found by a user, not a test. Lock the column length to the format you actually allow, and decide on alpha before the migration ships — retrofitting alpha later means a second migration plus a data audit.
Client-side validation is not validation. The picker prevents typos in the form. It does not protect against your seeder, your importer, an API endpoint, or a tinker paste. Every column touched by this field needs a rule on the write path that isn't the picker.
Empty-string vs. null. Clearing the picker can submit '' rather than null, depending on the variant. Normalize in fillUsing (as above) and make the column nullable. This bites hardest in whereNull('color') filters and in $model->color ?? '#fff' fallbacks.
White and black swatches disappear. A #FFFFFF chip on Nova's light index background, or #000000 on dark mode, is invisible without a border. Add a hairline border to any custom swatch rendering you do; the field's own swatch is generally fine but worth eyeballing in both themes during QA.
displayUsing / resolveUsing still apply and are the escape hatch. The field is a normal Nova Field, so if the default display isn't what you want (e.g., you want the hex code spelled out, or a color name resolved from a palette map), override the display rather than forking the component. Forking the Vue asset is the last resort — it converts a near-zero maintenance item into a permanent one.
Testing: validate the contract, not the component. There's little value in asserting the field's JSON structure. Assert the rules:
$this->assertTrue(Validator::make(['c' => '#3B82F6'], ['c' => 'regex:' . Color::HEX])->passes());
$this->assertFalse(Validator::make(['c' => '#XYZ'], ['c' => 'regex:' . Color::HEX])->passes());
$this->assertFalse(Validator::make(['c' => '#3B82F6AA'], ['c' => 'regex:' . Color::HEX])->passes());
Know your exit path, because it's genuinely cheap. If the package is abandoned or breaks on a Nova major, the fallback is roughly:
Text::make('Color')
->rules('nullable', 'regex:' . Color::HEX)
->help('Hex, e.g. #3B82F6'),
Ten lines, half a day including QA, no data migration because you stored a string. That triviality is the whole reason this package is a safe "yes" for internal admin tooling — and the reason you should not stretch it into palette governance, contrast enforcement, gradients, or multi-color attributes, which are separate problems with separate tools.
Where it stops. This field captures one hex string and shows a swatch. It does not check contrast ratios, enforce accessibility, version brand colors, gate who may edit them, or support gradients. If a stakeholder asks for any of that, it's a new line item, not a config flag.
How can I help you explore Laravel packages today?