bagistoplus/couleur
Couleur is a lightweight color utility for Laravel and PHP, offering helpers to define, convert, and format colors for use in views, CSS, and dynamic theming. A small, dependency-free addition for projects that need consistent color handling.
Install and confirm the floor first.
composer require bagistoplus/couleur
There is no service provider, facade, config file, or migration to publish. If composer require succeeded, the package is installed. The only install-time risk is the PHP 8.2 floor — verify it against your lowest-supported runtime before committing:
composer why-not php 8.1 # run in your app root after requiring
If any part of your fleet runs PHP 8.1, this is a hard blocker, not a nuisance.
Where to look first (before you write a line of code).
This is a 0-star, single-maintainer package with zero dependents. There is no Stack Overflow corpus, no issue history, and no published semver guarantee to lean on. Spend your first hour here:
vendor/bagistoplus/couleur/src/ — read the entire tree. It is a color library; it should be small enough to read end-to-end in under an hour.parse, fromHex, fromRgb, etc.). The names used below are indicative; verify against src/ before copying them.Your first use case: normalize and validate an admin color input.
Do not start with theme compilation. Start where the blast radius is one form field. The lowest-risk, highest-signal integration is a Laravel validation rule that normalizes a user-supplied hex value and rejects it if it fails your contrast policy:
// app/Rules/ValidBrandColor.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use App\Support\Color; // your adapter, not the vendor class
class ValidBrandColor implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$color = Color::parse((string) $value);
} catch (\Throwable) {
$fail('The :attribute must be a valid CSS color.')->translate();
return;
}
if (Color::contrast($color, Color::white()) < 4.5) {
$fail('The :attribute must reach 4.5:1 contrast against white text.')
->translate();
}
}
}
That one rule is the whole pitch: color correctness enforced at save time instead of in design QA. If it works, expand; if the API fights you here, you have lost an afternoon, not a sprint.
Pattern 1 — the adapter is mandatory, not optional.
Wrap the vendor behind an application-owned port from the first commit. Vendor value objects must never appear in domain models, API resources, Blade templates, or event payloads. With a 0-star dependency this is not style — it is the difference between a one-afternoon swap and a rewrite.
// app/Support/Color.php
namespace App\Support;
use Bagistoplus\Couleur\Color as VendorColor; // exact FQCN: verify
final class Color
{
private function __construct(public readonly VendorColor $inner) {}
public static function parse(string $value): self
{
return new self(VendorColor::parse($value));
}
public function toHex(): string { return $this->inner->toHex(); }
public function toCss(): string { return $this->inner->toCss(); }
public function lighten(float $amount): self
{
return new self($this->inner->lighten($amount));
}
public function darken(float $amount): self
{
return new self($this->inner->darken($amount));
}
public function adjustOpacity(float $alpha): self
{
return new self($this->inner->adjustOpacity($alpha));
}
public static function contrast(self $a, self $b): float
{
return $a->inner->contrastRatio($b->inner);
}
}
Keep the surface tiny. Every method you expose is a method you must re-implement if you fork or swap. Do not expose ->inner.
Pattern 2 — compile tokens at write time, never per request.
The real payoff is white-label theming: a tenant supplies one brand hex, and you derive the full token set. Do this when the tenant is saved or when the brand color changes, persist the result as JSON or a CSS artifact, and serve the artifact. Do not run color math in a request path, a Blade component, or an API resource.
// app/Theming/ThemeCompiler.php
final class ThemeCompiler
{
public function compile(string $brandHex): array
{
$brand = Color::parse($brandHex);
return [
'brand' => $brand->toHex(),
'brand-hover' => $brand->darken(8)->toHex(),
'brand-active' => $brand->darken(14)->toHex(),
'brand-muted' => $brand->lighten(40)->toHex(),
'on-brand' => $this->onColor($brand),
'focus-ring' => $brand->adjustOpacity(0.4)->toCss(),
];
}
private function onColor(Color $bg): string
{
// Derive from measured contrast, never from a luminance-threshold guess.
return Color::contrast($bg, Color::white()) >= 4.5 ? '#ffffff' : '#111111';
}
}
Wire it to a model observer or a TenantSaved listener, write the artifact to the cache or a theme_tokens column, and invalidate on change. That is the whole pipeline.
Pattern 3 — emit CSS custom properties once, provide a fallback.
When you generate theme CSS, emit modern syntax with an sRGB fallback. The compatibility boundary is the browser, not PHP — oklch() and color-mix() support is broad but not universal, and display-p3 needs an sRGB fallback:
$css = ":root{\n";
$css .= " --brand: {$tokens['brand']};\n";
if ($l4 = $tokens['brand-oklch'] ?? null) {
$css .= " --brand-l4: {$l4};\n"; // consumer opts in via @supports
}
$css .= "}\n";
If the package can emit oklch() but not an automatic sRGB fallback, generate both yourself and gate the modern declaration behind @supports. Confirm what it actually emits — do not assume.
Pattern 4 — encode contrast as a policy object, not scattered if statements.
Contrast requirements differ per surface (body text 4.5:1, large text 3:1, UI borders 3:1, decorative none). Define the policy once and reuse it in the validation rule, the theme compiler, and any admin warning banner:
enum ContrastTarget: string
{
case Body = 'body';
case LargeText = 'large';
case UiComponent = 'ui';
public function minimum(): float
{
return match ($this) {
self::Body => 4.5,
self::LargeText, self::UiComponent => 3.0,
};
}
}
Note that WCAG 2.x ratios and APCA can produce different verdicts. If your accessibility commitment is APCA, verify before building on this — most small libraries implement WCAG 2.x only, and this package's docs do not promise otherwise.
Pattern 5 — the surfaces without a browser are where this earns its keep.
Email, PDF invoices, OG/social images, Slack and webhook card payloads, server-generated chart series, and server-rendered theme CSS variables cannot rely on color-mix() in the client. Those are the surfaces where a PHP color engine is not optional, and where you should pilot. A branded transactional email layout that derives its accent bar from a tenant's hex — computed at send time, inline styles only — is the single highest-value use case.
Pattern 6 — test with golden files against browser output.
Your own test suite is the only safety net given the dependency's maturity. Build fixtures of ~20 real brand colors (achromatic gray, near-gamut-edge saturated, translucent, malformed, 3-digit hex, 8-digit hex with alpha) and assert:
parse → serialize → parse is lossless.If gamut mapping diverges from browser behavior, stop. Brand-color drift is a defect that surfaces as a customer complaint, not as an exception.
Gamut mapping is the thing that will bite you. Out-of-sRGB OKLCH/Lab values need proper chroma reduction (CSS Color 4 §13 local-MINDE). Naive clipping to 0–255 produces visibly wrong colors versus what the browser renders. Read the source and find out which one it does. This is the go/no-go question for the whole package, and it cannot be answered from the README.
HSL lighten/darken is a lie the eye rejects. If you are replacing hand-rolled lighten() helpers, expect visual diffs — the old helpers produced uneven perceptual ramps and the new output will not match pixel-for-pixel. Screenshot-diff generated themes before cutover, per tenant, behind a flag. Plan for design sign-off; this is not a silent refactor.
Pin the exact version. Never ^1.0. Never *.
"bagistoplus/couleur": "0.3.1"
No documented semver policy, no dependents to signal-test releases, one maintainer. Treat every upgrade as a reviewed change with the conformance harness run against it. Consider a private mirror with your own tests acting as the compatibility contract, so upstream abandonment or a hostile release cannot take you down.
The Lab/LCH white point is a silent correctness bug. D50 and D65 produce different numbers and neither errors. If the package's Lab conversions assume a different white point than the CSS spec or your design tooling, everything shifts subtly. Check the source; if chromatic adaptation is not exposed and you need it, you have a problem.
none keywords, percentage alpha, and angle units. Modern CSS color syntax allows none channels, rgb(0 0 0 / 50%), and hue as deg/rad/turn. If the parser is a regex over classic syntax, admins pasting modern CSS values will get confusing rejections. Test the parser against real CSS Color 4 examples before trusting it in a validation rule — and always wrap parse() in try/catch, treating a parse failure as a validation error, never as an uncaught exception.
Achromatic colors have no meaningful hue. Grays round-trip through HSL/OKLCH with an arbitrary hue channel. If you serialize to hsl() and then parse back, you can get color drift on grays specifically. Add an explicit achromatic fixture to your tests — it is the case that silently breaks.
Never compute color in Blade. No @php $x = Color::parse(...) @endphp in a template. Pass prepared strings into the view. Vendor objects in templates are the leak that makes the dependency unswappable, and per-render math is exactly the performance problem you avoided by compiling tokens.
Cache invalidation is the whole job. Your token artifact is derived state. When a tenant's brand color changes, the cache key must change with it — key it on the color value itself (theme:{tenant_id}:{sha1($hex)}) plus a schema version, or you will ship stale themes and chase a bug that only appears for one customer.
Contrast helpers are WCAG 2.x unless proven otherwise. The docs say "check contrast for accessible pairings" and stop there. Do not assume APCA, and do not assume the ratio accounts for alpha compositing against an unknown background — translucent colors need you to composite onto the actual surface first.
Keep the blast radius small by construction. Everything above — the adapter, the compiler, the validation rule, the golden files — exists so that if this package is abandoned, forked, or turns out to be subtly wrong, you delete one composer require line and reimplement ~200 lines behind an interface that never changed. Given 0 stars, 0 dependents, and one maintainer, that isolation is not defensive over-engineering; it is the price of admission.
How can I help you explore Laravel packages today?