---
title: Authoring Custom Blade Components
visibility: guest
---

# 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

```blade
{{-- 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:

```blade
<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:

```bash
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

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

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

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

```php
@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`

```php
@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

```php
@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

```php
@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

```php
@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

```php
@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

```php
@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

```php
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:

```php
[
    '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:

```bash
# 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

```php
// 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

```php
// 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

```php
// 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:

```php
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

- [ComponentRegistry — Programmatic Discovery](component-registry.md) —
  reading the catalog at runtime.
- [Authoring Custom Alpine Plugins](authoring-custom-alpine-plugins.md) —
  defensive-cleanup pattern for plugins that drive your components.
- [CLI Reference](../cli-reference.md) — full command surface.
