ozdemirburak/iris
PHP color library for manipulation and conversion between Hex, Hexa, RGB, RGBA, HSL, HSLA, HSV, CMYK, and OKLCH. Requires PHP 8.1+ and supports named colors, alpha channels, and conversion methods.
Iris is a plain-PHP value-object library — no service provider, no facade, no config/iris.php. You composer require ozdemirburak/iris (PHP 8.1+ platform check matters) and start instantiating classes. Anything integration-shaped is yours to build.
First three places to read, in order:
Hex, Hexa, Rgb, Rgba, Hsl, Hsla, Hsv, Cmyk, Oklch.src/Color/Factory.php — the dispatch logic behind Factory::init($string). This is what you'll lean on for user/DB-supplied values, and it tells you exactly which strings are accepted (including CSS named colors like fuchsia).src/BaseColor.php — shared surface: gradient(), mix(), clone(), back(), getColorModelName(). If a method exists in more than one format class, it's probably declared here.Your first real use case in a Laravel app is almost always brand-color derivation: one hex from the design team, and you need hover states, a subtle background tint, and a readable text-on-brand color.
use OzdemirBurak\Iris\Color\Hex;
$brand = new Hex('#2563eb');
$brand->darken(10); // hover
$brand->darken(20); // active
$brand->tint(85); // card/tint background
$brand->isDark() ? '#ffffff' : '#111111'; // naive text-on-brand
That single snippet replaces a hand-maintained Tailwind config block or a dozen hard-coded CSS variables.
Wrap it in a palette service and cache the result. Conversion + manipulation is cheap, but you don't want to re-derive tokens on every request. Colors are deterministic, so results are perfectly cacheable.
namespace App\Support;
use Illuminate\Support\Facades\Cache;
use OzdemirBurak\Iris\Color\Factory;
use OzdemirBurak\Iris\Color\Hex;
final class Palette
{
public function make(string $brand): array
{
$hex = Factory::init($brand)->toHex();
$key = 'palette:'.strtolower((string) $hex);
return Cache::rememberForever($key, fn () => [
'brand' => (string) $hex,
'hover' => (string) $hex->darken(10),
'active' => (string) $hex->darken(20),
'subtle' => (string) $hex->tint(85),
'border' => (string) $hex->tint(60),
'on_brand' => $hex->isDark() ? '#ffffff' : '#111111',
'scale' => array_map('strval', $hex->gradient([$hex->tint(90), $hex], 9)),
]);
}
}
Bind it as a singleton, feed it config('branding.primary'), and emit CSS variables from a single Blade partial or View composer. That's the highest-leverage integration point in a Laravel codebase.
Factory is your boundary object. Any color arriving from a request, an admin panel, an imported CSV, or a JSON API should go through Factory::init(), never through a guessed concrete class. You don't know whether the user pasted #fff, rgba(...), or oklch(...).
Validation rule — wrap Factory and treat any throw or null as invalid:
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail("The {$attribute} must be a color string.");
return;
}
try {
if (Factory::init($value) === null) {
$fail("The {$attribute} is not a recognizable color.");
}
} catch (\Throwable) {
$fail("The {$attribute} is not a recognizable color.");
}
}
Eloquent cast — store the canonical hex string, hand back an object. Note the set() normalisation to lowercase hex; you want one canonical column representation, not five.
public function get($model, $key, $value, $attributes)
{
return $value ? Factory::init($value) : null;
}
public function set($model, $key, $value, $attributes)
{
return [$key => $value === null ? null : strtolower((string) $value->toHex())];
}
Because the returned object has only __toString(), it will serialize badly — json_encode() on it produces an object of public properties, not "#2563eb". Always convert explicitly in API resources: 'color' => (string) $model->color->toHex().
Contrast that actually passes audit. isDark()/isLight() are convenient heuristics, not WCAG. If accessibility is in scope, compute relative luminance yourself from toRgb()->values():
private function luminance(Hex $hex): float
{
[$r, $g, $b] = array_map(fn ($c) => $c / 255, $hex->toRgb()->values());
$f = fn ($c) => $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
return 0.2126 * $f($r) + 0.7152 * $f($g) + 0.0722 * $f($b);
}
Charts and data viz. gradient() is the cleanest way to get an N-step series palette, and it accepts pivots:
$colors = array_map(
'strval',
(new Hex('#2563eb'))->gradient([new Hex('#22c55e'), new Hex('#f59e0b')], $seriesCount)
);
Deterministic avatars / tags. Hash a user id into a hue, build an Hsl, convert to Hex. Stable forever, no storage needed.
$hue = crc32((string) $user->id) % 360;
$avatar = (new Hsl("{$hue},65%,45%"))->toHex();
Compositing. When you must flatten a translucent overlay onto a known surface, use background() rather than dropping alpha silently:
$flat = $rgba->background($surface->toRgb())->toHex(); // 'a7add1'
__toString() is format-specific, and comparisons must be normalised. Hex renders #ff00ff; Hsl renders hsl(300,100%,50%); Rgb renders rgb(255,0,255). Never compare or store these strings raw. Canonicalise through ->toHex() and lowercase it, or your "same color, different class" bug will be invisible until it isn't.
values() returns mixed types. Hsl::values() gives [300, '100%', '50%'] — strings with % on the ends. Don't do arithmetic on them. Use the named accessors (hue(), saturation(), lightness()) or valuesInUnitInterval() when you need 0–1 floats for math. Hex::values() gives hex fragments (['ff','00','ff']); Rgb::values() gives ints.
toHex() discards alpha. Rgba/Hsla → toHex() gives you the opaque color, not a composite. If the intent was "what does the user actually see", call background() explicitly. toHexa() is the one that preserves both RGB and alpha.
Hexa alpha is lossy on the way in. #ff00ff4c yields alpha() === 0.3, not 0.298. If exact transparency matters (design tokens, exported assets), persist the 8-digit hex string and only parse when you need to compute.
OKLCH silently ignores alpha and relative-color syntax. oklch(59.69% 0.156 49.77 / .5) will parse and drop the .5, and oklch(from green l c h) is unsupported. If your input pipeline accepts user CSS, validate that OKLCH strings have no / before trusting them.
Round-tripping drifts by a unit. hex → hsl → hex is rounded at each step; the result can differ by ±1 per channel. In tests, assert channel proximity, not string equality. Same reason: don't use color round-trips as a checksum.
Manipulation methods are immutable. lighten(), mix(), spin(), fade() all return new instances; the receiver is untouched. Chain or reassign — silently dropping the return value is the most common "why didn't it change" bug with this library. Use clone() when you want to branch one base color into several variants.
Percent arguments clamp, they don't wrap. lighten(20) adds 20 percentage points of HSL lightness; repeated calls march to #ffffff and stop. spin() wraps hue, fade() clamps at 0/1. Also note lighten() and brighten() are not synonyms — lighten() adjusts HSL lightness, brighten() scales the RGB channels, so they diverge visibly on saturated colors. Pick one convention and hold it across your design system.
Manipulation return types change. fade()/fadeIn()/fadeOut() hand back an alpha-capable type (Hsla/Rgba), so downstream code typed against Hsl will bite you. gradient() returns an array matching the starting type — a Hex gradient yields Hex objects, which is why you array_map('strval', ...).
Factory is a guesser, not a parser contract. Wrap it in try/catch and null-check; the failure mode isn't guaranteed to be a throw across versions. Also trim input and reject empty strings before calling it.
Zero dependents, 124 stars — treat it as a leaf dependency. It's MIT, actively released, and does one thing well, but don't let OzdemirBurak\Iris\Color\* types leak into your domain signatures. Take string $hex at your service boundaries and keep the library inside App\Support\Color so swapping it later is a one-file change.
How can I help you explore Laravel packages today?