Strict Validation
Every WireKit component with a prop enum (<x-wirekit::, <x-wirekit::, <x-wirekit::, …) routes invalid values through a single decision point: the StrictnessGate. The gate decides whether to throw, log, and which value to render when a developer typo lands in production code.
The decision depends on the runtime context. That is intentional — a typo in a Pest test should fail fast and visibly; the same typo on a live HTTP request should not 500 the whole page over a cosmetic glitch. This page documents the full matrix so you can decide which mode fits each environment.
The decision matrix
Two configuration knobs interact with the runtime context:
| Knob | Default | What it does |
|---|---|---|
wirekit.validation.strict |
null (auto-detect from APP_DEBUG) |
When null, the gate is strict if APP_DEBUG=true, lenient otherwise. Set explicitly to true or false to override the auto-detect. |
wirekit.validation.throw_on_invalid |
null (context: console throws, HTTP degrades) |
Three states, not two. null defers to the runtime context. true forces the throw even in an HTTP request — useful for a staging environment that should fail loudly. false forces degradation even in console and Pest, which switches off the fail-fast the row below promises. |
The resulting behavior:
| Runtime context | strict=true (or APP_DEBUG=true) |
strict=false (or APP_DEBUG=false) |
|---|---|---|
| HTTP request | log ERROR + render fallback |
log WARNING + render fallback |
CLI / Pest / artisan tinker |
throw InvalidArgumentException |
log WARNING + render fallback |
throw_on_invalid=true (any context) |
throw InvalidArgumentException |
log WARNING + render fallback |
throw_on_invalid=false (any context) |
log ERROR + render fallback |
log WARNING + render fallback |
The gate constructs an Invalid. When the value came from a Blade template,
Laravel's view engine wraps it in an Illuminate\ before it reaches your
code — the message survives the wrap, so that is the class a toThrow assertion names. The
worked example below does.
throw_ decides HOW the gate fails, not WHETHER it engages. Prop validation only
throws while the gate is strict — under strict=false an invalid value logs a WARNING and
renders the fallback whatever throw_ says. The knob is unconditional for one
neighboring case: an unknown icon name reads it directly, so
throw_ throws there in an HTTP request too.
Fallback semantics: when the gate renders a fallback value, it picks the first allowed enum entry. For <x-wirekit:: the fallback is surface="filled". Each component can override the fallback per prop; the override surfaces in the same log line.
Why the split exists
A naive "throw on any typo" policy looks safe in isolation but creates a worse production failure mode: one cosmetic prop typo (a developer wrote surface="outlne" instead of surface="outline") takes down the entire Blade view with a 500 error. The page that should have rendered with a slightly-wrong button now renders nothing at all.
The matrix above resolves the trade-off:
- In a Pest test, the throw surfaces the typo at the assertion that should have caught it. No silent log line; no rendered fallback the test then accidentally validates.
- In
php artisan tinker, the throw matches what a developer would expect from a debugger — you typed the wrong thing, you get a stack trace. - In a live HTTP dev request (
APP_DEBUG=true), the page renders with the fallback so you can see the rest of the layout, and thelocal.ERROR: WireKit [...]line tells you exactly what went wrong. - In production, the log line drops to
WARNING(less noisy) but the fallback still keeps the page alive.
Spotting silent typos in logs
Two options:
Option 1 — let wirekit:doctor scan the log for you. The doctor's environment-tier checks include an optional silent-typo log scan that walks storage/ for the WireKit [...] ERROR / WARNING lines emitted by StrictnessGate. A WARN with example lines surfaces every typo that fired inside the scan window:
# 1. The environment-tier checks include the log scan.
php artisan wirekit:doctor --tier=environment
The scan SAFE-DEGRADES at every failure mode — missing log file, custom log channel (Slack / Papertrail / Sentry), log-level filtering below WARNING — it surfaces an INFO line explaining the skip and never blocks the doctor's exit code. Disable entirely by setting wirekit. to false (or WIREKIT_ in .env) when your app doesn't write file-based logs.
The window is the last 24 hours by default, judged by each line's timestamp. Older WireKit lines are counted in the message but do not turn the check yellow, so a typo you fixed drops out once its last line is older than the window. Set wirekit. (or WIREKIT_ in .env) to widen or narrow it; 0 reads the whole file.
Option 2 — grep the log directly. Lenient mode logs at WARNING level; strict-HTTP logs at ERROR. Either way the lines look the same:
# 1. From your project root, scan today's log for any WireKit fallback events.
grep 'WireKit \[' storage/logs/laravel.log
# 2. Narrow to one component if you suspect a specific surface.
grep 'WireKit \[button\]' storage/logs/laravel.log
# 3. Tail the log live while you click through the app to catch typos as they fire.
tail -f storage/logs/laravel.log | grep --line-buffered 'WireKit \['
A typical line looks like:
[2026-05-28 06:54:53] local.ERROR: WireKit [button]: Invalid surface "outlne". Allowed: filled, outline, soft, ghost, link. Did you mean: outline? Falling back to "filled".
The Falling back to "..." suffix is the source of truth for what the page actually rendered.
Each rejection is logged once per request for its component, prop and value, so a value that renders in a loop leaves one line rather than one per row. The value is quoted as a single line: a control character such as a line break appears as �, and a value longer than 64 characters is cut and ends in …. A prop bound to data your application did not write, such as :intent="$ticket->priority", cannot start a log line of its own that way.
Opting into strict mode for staging
Staging environments typically run with APP_DEBUG=false to mirror production, which puts the gate into lenient mode. If you would rather fail loudly on a typo before it reaches end users, flip the two knobs explicitly in your staging .env:
# 1. Force the gate into strict mode regardless of APP_DEBUG.
WIREKIT_STRICT_VALIDATION=true
# 2. Force a throw on every invalid value — no fallback, full stack trace.
WIREKIT_THROW_ON_INVALID=true
Add the corresponding config wiring to config/:
<?php
use Pushery\WireKit\Support\EnvValue;
// 1. The 'validation' block lives at the top level of the wirekit config.
return [
// …
'validation' => [
// 2. null = auto-detect from APP_DEBUG. true/false = explicit override.
'strict' => EnvValue::get('WIREKIT_STRICT_VALIDATION'),
// 3. null = defer to the context (console throws, HTTP degrades). true = throw
// even in HTTP, so an invalid prop 500s the request. false = never throw,
// INCLUDING in Pest — leave this one unset unless you mean that.
'throw_on_invalid' => EnvValue::get('WIREKIT_THROW_ON_INVALID'),
],
// …
];
EnvValue::get() reads a variable the way env() does, with one difference: a line with nothing after the =, such as WIREKIT_, counts as unset. The setting keeps its default, exactly as if the line were not there, instead of arriving as an empty string.
Both settings are read as switches. on, yes and 1 mean true and off, no and 0 mean false, in upper or lower case, as true and false do. A word that means neither leaves the setting unset, so the default decides.
Be careful with throw_ in production — every invalid prop value will 500 the request that triggered it. The setting is useful in CI's smoke-test job and dangerous in front of real visitors. You must leave it false in production unless you have a deliberate fail-loudly policy.
Testing strict mode in Pest
CLI context already triggers the throw branch when strict mode is active. A typical regression test reads:
<?php
// 1. Pest test asserting an invalid prop value throws under strict mode.
use Illuminate\Support\Facades\Blade;
use Illuminate\View\ViewException;
it('rejects unknown surface values on <x-wirekit::button>', function () {
// 2. Force strict mode explicitly so the test is independent of APP_DEBUG.
config()->set('wirekit.validation.strict', true);
// 3. The render call throws because 'outlne' is not in the surface enum. Blade wraps
// the gate's InvalidArgumentException in a ViewException, and the message survives
// the wrap — so assert on the wrapper class and match on the message.
expect(fn () => Blade::render('<x-wirekit::button surface="outlne">x</x-wirekit::button>'))
->toThrow(ViewException::class, 'Invalid surface "outlne"');
});
The value you assert on must be one the component really rejects. surface="outlined"
looks like the obvious typo and is not one — button declares it as an alias for outline, so
it renders happily and the assertion above fails with "nothing was thrown". Pick something
genuinely outside the enum; the dropped-letter outlne is.
The exception message carries a Levenshtein-ranked Did-you-mean hint — Did you mean: outline? — so the failing assertion already points at the likely correct value.
How intent and surface interact under the gate
Components like <x-wirekit:: and <x-wirekit:: carry two orthogonal enums (intent + surface). The gate runs each prop independently — an invalid intent falls back to primary, an invalid surface falls back to filled, and a value that lands outside both enums logs two separate lines. See Variants & Intents for the full prop-value catalog.
Asking the gate instead of reading its log
The gate's warnings are written for a developer reading a log. Two of the questions behind them are also available as plain predicates you can assert on in your own test suite — useful when you want a build to fail rather than a line to appear.
Both return an answer and nothing else: no logging, and no app.debug gate. That matters,
because the warnings themselves return early outside debug mode, so in production the
signal does not exist at all and no test can reach it.
use Pushery\WireKit\Support\StrictnessGate;
// Attribute names that are neither declared props nor legitimate HTML passthrough.
StrictnessGate::unknownPropNames($attributes, $declaredProps); // => ['varaint']
// Scope directives this component will DISCARD, because it sets its own on the
// same element and HTML keeps the first of two identical attributes.
StrictnessGate::discardedScopeDirectives('dropdown', ['x-data' => '{ open: false }']);
// => ['x-data']
What passes as HTML: every global attribute of the HTML standard (id, class, popover, the
microdata attributes and the rest), the attributes a form control, link, image or table cell
takes, aria-*, data-*, the framework prefixes (wire:, x-, @, :), and the
event-handler attributes spelled the way HTML spells them, in lowercase (onclick, oninput).
A few props begin with the same two letters, so an on name in any other case is read as a
prop: onLable on toggle-button is reported as a typo of onLabel. A lowercase name one
letter away from a declared prop is reported too, such as onse beside reveal's once.
discarded reads the component's own Blade template and asks whether it
sets the directive unconditionally — a component that only sets x-data inside a
condition does not always discard yours, and answering otherwise would report a collision
that does not happen. That distinction is why asking WireKit is better than re-deriving the
answer from the templates yourself.
A discarded x-data is not a degradation, it is a disconnection. Your scope never comes
into existence, so everything written against it — an x-init beside it, a @click inside
it — silently resolves against the component's own data. Wrap the component in your own
element, or use the API the component already exposes.
See Also
- Variants & Intents — canonical prop-value reference
- Customization — overriding component defaults via config
- CLI Reference —
wirekit:doctorand the wider Artisan surface