Skip to main content
WireKit
Copy for LLM

Authoring Custom Blade Components

WireKit components are plain Blade anonymous components — no special build step, no class hierarchy to inherit from. You write your own custom components alongside WireKit's, mix-and-match in your views, and the WireKit tooling (wirekit:show, wirekit:export-json, wirekit:doctor) introspects them through the same machinery it uses for the shipped catalog.

This page documents the @props([...]) syntax shapes WireKit's toolchain understands. Following the patterns here ensures your custom components show up correctly in wirekit:show, in AI-tool autocomplete, and pass the cleanup-hygiene + parser drift audits.

The Minimum Viable Component

{{-- resources/views/components/my-button.blade.php --}}
@props([
    'variant' => 'primary',
    'size' => 'md',
])

<button {{ $attributes->class([
    'rounded px-3 py-1.5',
    'bg-[var(--color-wk-accent)]' => $variant === 'primary',
    'bg-[var(--color-wk-bg-muted)]' => $variant === 'neutral',
    'text-sm' => $size === 'sm',
    'text-base' => $size === 'md',
]) }}>
    {{ $slot }}
</button>

Use it from any Blade view:

<x-my-button>Save</x-my-button>
<x-my-button variant="neutral" size="sm">Cancel</x-my-button>

WireKit's tooling immediately recognizes the props:

php artisan tinker --execute='
use Pushery\WireKit\Support\PropsParser;
dd(PropsParser::parseBlade(resource_path("views/components/my-button.blade.php")));
'

…returns the two prop entries with their defaults.

@props Shape Reference

The parser handles the following expressions inside an @props([...]) block. If your default expression doesn't fit one of these shapes, the parser may still handle it — but these are the tested-and-guaranteed cases.

1 · Bare literals

@props([
    'count' => 0,
    'enabled' => true,
    'tag' => 'div',
    'maxRows' => null,
])

Primitive types — integers, booleans, strings, null. Always works.

2 · config(...) defaults with commas in arguments

@props([
    'variant' => config('myapp.components.button.variant', 'primary'),
])

The parser recognizes commas inside the config(...) argument list as PART OF the default expression, not as prop boundaries. This was a historical bug class in the old regex parser; the current tokenizer- based parser handles it correctly.

3 · Trailing inline // comment

@props([
    'trend' => null, // 'up' | 'down' | 'neutral' | null
])

The comment is captured as a separate comment field on the prop record (NOT as part of the default value). wirekit:show <name> prints the comment in gray alongside the prop name, making the allowed-value enumeration discoverable without opening the source file.

4 · match(...) expressions

@props([
    'classes' => match (1) {
        1 => 'text-4xl',
        2 => 'text-3xl',
        default => 'text-base',
    },
])

Useful for deriving the default from another prop at render time.

5 · Array-literal defaults

@props([
    'items' => [1, 2, 3],
    'config' => ['enabled' => true, 'limit' => 10],
])

Both flat and nested arrays parse cleanly. Use heredoc syntax (below) for very long inline arrays — readability beats brevity.

6 · Mixed single- and double-quoted keys

@props([
    'foo' => 'one',
    "bar" => "two",
])

Both quote styles are valid PHP. The parser strips them uniformly when extracting the prop name.

7 · Multi-line default expressions

@props([
    'classes' => implode(' ', [
        'flex',
        'items-center',
        'gap-2',
    ]),
])

The parser tracks bracket / paren nesting via PHP's tokenizer, so multi-line expressions don't trip it up. Indent for readability.

8 · Heredoc / nowdoc defaults

@props([
    'template' => <<<'TEMPLATE'
<div>
  Hello, world.
</div>
TEMPLATE,
])

Useful for long inline strings that contain special characters (backticks, double quotes, </script> sequences).

What the Parser Returns

use Pushery\WireKit\Support\PropsParser;

$props = PropsParser::parseBlade('resources/views/components/my-button.blade.php');

Each entry in the returned list has the same shape:

[
    'name' => 'variant',
    'default' => "'primary'",                           // Raw source text.
    'default_normalized' => "'primary'",                // Whitespace-collapsed.
    'type_hint' => null,                                // Reserved for future @phpdoc augmentation.
    'comment' => null,                                  // Or "'up' | 'down'" etc.
]

The same data drives wirekit:show <name>, wirekit:show <name> --as=json, and wirekit:export-json.

CLI Tooling for Custom Components

Once your component is in resources/views/components/, every WireKit CLI command sees it:

# Programmatic introspection — same JSON shape as shipped components
php artisan wirekit:show my-button --as=json

# Lint a developer Blade file against your component's prop set
php artisan wirekit:show my-button --validate-against=resources/views/pages/checkout.blade.php

Note: wirekit:list and the registry-based commands only list components that are in Pushery\WireKit\ComponentRegistry. Custom components in resources/views/components/ are visible to wirekit:show (which file-walks) but not to wirekit:list (which reads the registry). If you want your custom component to show up in the registry, register it via your service provider — but the typical pattern is "custom components stay local, only WireKit's catalog lives in the registry".

Common Pitfalls

Comma INSIDE a function-call argument list — historical bug class

// PARSES CORRECTLY in v2.0.0+. The old regex parser broke here.
'variant' => config('myapp.x.y', 'fallback'),

If you see wirekit:show <name> printing a truncated config(...) expression OR a phantom "next prop" you didn't write, you're either on an old WireKit version OR there's a parser regression — file a bug with the prop source.

Trailing inline comment leaking into the default — historical bug class

// PARSES CORRECTLY. The comment is captured as a separate field.
'trend' => null, // 'up' | 'down' | 'neutral'

Same as above — historical bug, fixed in v2.0.0.

Variable / expression as the array key

// Don't do this. PHP allows it but `@props` doesn't.
@props([
    $dynamicKey => 'value',   // ← parser can't extract a string name
])

@props array keys MUST be string literals. The parser silently skips non-string-literal keys.

Asserting strict-mode warnings in tests

When app.debug is true (or you run in the console), WireKit emits a warning-level log for an unknown prop key passed to a component — a Did-you-mean signal that a typo'd prop (<x-wirekit::button variantt="…">) was silently dropped into the attribute bag. It is logged through the strictness gate and never throws.

To assert that warning in a test you need it to (a) NOT land in storage/logs/laravel*.log — otherwise the wirekit:verify silent-typo log-scan would later report it as a finding — and (b) stay type-clean under static analysis (Larastan). Two common reaches don't fit:

  • Log::fake() — Laravel has no such method (unlike Event::fake() / Queue::fake()); calling it errors with undefined method …::fake().
  • Log::shouldReceive(…) — a Mockery facade expectation, which Larastan flags at higher levels on the dynamically-typed return.

Swap the logger for a tiny array recorder instead — it replaces the channel entirely (nothing reaches the file handler) and uses only typed PSR-3 methods, so it is both log-scan-safe and Larastan-clean:

use Illuminate\Support\Facades\Log;
use Pushery\WireKit\WireKit;
use Psr\Log\AbstractLogger;

it('warns on an unknown prop key in debug mode', function () {
    // 1. Strict-mode warnings only emit when app.debug is on.
    config()->set('app.debug', true);

    // 2. Replace the logger with an array recorder — no disk write, no Mockery.
    $records = [];
    Log::swap(new class($records) extends AbstractLogger {
        public function __construct(private array &$records) {}

        public function log($level, string|\Stringable $message, array $context = []): void
        {
            $this->records[] = [(string) $level, (string) $message];
        }
    });

    // 3. Drive the unknown-prop path. WireKit's components call this helper
    //    internally; in a focused unit test you can call it directly with a key
    //    that isn't a declared prop (stable across releases, unlike a specific
    //    component's prop list).
    WireKit::warnUnknownProps('button', ['variantt' => 'ghost'], ['variant', 'surface']);

    // 4. Assert the warning fired — captured in memory, never written to disk.
    expect($records)->toHaveCount(1)
        ->and($records[0][0])->toBe('warning')
        ->and($records[0][1])->toContain('Unknown prop "variantt"');
});

See Also