Skip to main content
Copy for LLM

Strict Validation

Every WireKit component with a prop enum (<x-wirekit::button intent="primary">, <x-wirekit::alert intent="info">, <x-wirekit::hero size="lg">, …) 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 InvalidArgumentException. When the value came from a Blade template, Laravel's view engine wraps it in an Illuminate\View\ViewException 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_on_invalid 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_on_invalid says. The knob is unconditional for one neighboring case: an unknown icon name reads it directly, so throw_on_invalid=true 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::button surface="weird-typo"> 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 the local.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/logs/laravel*.log 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.doctor.scan_logs to false (or WIREKIT_DOCTOR_SCAN_LOGS=false 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.doctor.scan_logs_window_hours (or WIREKIT_DOCTOR_SCAN_LOGS_WINDOW_HOURS 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/wirekit.php:

<?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_THROW_ON_INVALID=, 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_on_invalid=true 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::button> and <x-wirekit::badge> 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.

discardedScopeDirectives() 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

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store