Props & PropTypes

Custom Props

Build a new atomic widget with its own Vue component, configurable via fluent setter methods.

Custom Props

A Prop is the smallest unit in Tagixo's design system: it pairs a PHP value object with a Vue widget and knows how to emit CSS. Props are composed into PropTypes, which are composed into module schema() arrays.

This guide covers when to build a custom Prop, how to scaffold and wire it, and how to ship a Vue widget alongside it.


When to build a custom Prop

Situation Recommended path
You need a colour picker, toggle, slider, text field, or any other control already listed in the built-in widget table Use a built-in widget identifier in widget() — no new class needed
You have a domain concept (e.g. "gradient stop list") that maps poorly to every built-in widget Build a custom Prop
You want to reuse an existing Prop but change its label, default, or CSS output Subclass it
You need a completely new editing UI (Vue component) Build a custom Prop and register a custom Vue widget

The cost of a custom Prop is low (one PHP class, optionally one Vue SFC). The hidden cost is serialization stability: once a Prop's make key is stored in a page's JSON blob, renaming it requires a migration.


Scaffold

php artisan make:tagixo-prop GradientPickerProp

Options:

Flag Default Notes
--path app/Tagixo/Props Output directory
--namespace App\Tagixo\Props PHP namespace
--package (none) Set when shipping the Prop inside a Composer package

The command creates a single PHP class that extends Ccast\Tagixo\Core\Props\AbstractProp.


PHP class anatomy

Minimum viable class

namespace App\Tagixo\Props;

use Ccast\Tagixo\Core\Props\AbstractProp;

class GradientPickerProp extends AbstractProp
{
    public function widget(): string
    {
        // Return a built-in widget identifier or the name you will register
        // under window.__tagixoWidgets on the Vue side.
        return 'gradient-picker';
    }

    public function widgetConfig(): array
    {
        return array_merge($this->baseConfig(), [
            // Keys here arrive as Vue props on your widget component.
            // Every value must be JSON-serializable.
            'maxStops' => $this->maxStops,
        ]);
    }
}

widget() — built-in identifiers

If you do not need a custom Vue component, return one of these identifiers:

Identifier Value shape Notes
text string Single-line text input
textarea string Multi-line text input
number int|float Numeric input
toggle bool Boolean toggle
select string Dropdown; options passed via widgetConfig() key options
color string Hex/RGBA colour string
slider int|float Slider; min, max, step in widgetConfig()
icon string Icon picker (returns icon identifier)
media int|null Media library image ID
font-picker string Font family picker
four-ways array {top, right, bottom, left}
box-shadow array Box-shadow definition object
editor string Rich-text (Tiptap) HTML
repeater array List of sub-objects; fields key required in widgetConfig()
model-select int|null Eloquent model record picker
css-declarations array Raw CSS {property, value} pairs

widgetConfig()

Always start with $this->baseConfig(), which injects the make, label, default, helpText, and conditional visibility keys that the panel infrastructure reads. Merge your own keys on top.

Every value in the returned array must be JSON-serializable. Closures, resource handles, and Eloquent models will throw at render time.


Inherited fluent API

AbstractProp exposes a full fluent builder:

Method Signature Description
make static make(string $key) Creates the Prop and sets its storage key
setLabel setLabel(string|Closure $label) Panel label; supports closures for dynamic labels
default default(mixed $value) Default value used when no payload key is found
helpText helpText(string $text) Tooltip shown next to the control
variablePicker variablePicker(string $type) Shows a variable picker button (reads global variables)
showWhen showWhen(string $key, mixed $value) Conditionally show this Prop when another Prop equals $value
hideWhen hideWhen(string $key, mixed $value) Inverse of showWhen

All setters return $this, so they are chainable:

GradientPickerProp::make('hero_gradient')
    ->setLabel('Hero gradient')
    ->default(['#ffffff', '#000000'])
    ->helpText('Applied behind the headline text.');

Adding custom fluent setters

Declare a protected property, write a setter that returns $this, and emit the property's value inside widgetConfig().

class GradientPickerProp extends AbstractProp
{
    protected int $maxStops = 5;
    protected bool $allowTransparency = true;
    protected string $direction = 'to right';
    protected array $presets = [];

    public function maxStops(int $max): static
    {
        $this->maxStops = $max;
        return $this;
    }

    public function disableTransparency(): static
    {
        $this->allowTransparency = false;
        return $this;
    }

    public function direction(string $direction): static
    {
        $this->direction = $direction;
        return $this;
    }

    public function presets(array $presets): static
    {
        $this->presets = $presets;
        return $this;
    }

    public function widget(): string
    {
        return 'gradient-picker';
    }

    public function widgetConfig(): array
    {
        return array_merge($this->baseConfig(), [
            'maxStops'          => $this->maxStops,
            'allowTransparency' => $this->allowTransparency,
            'direction'         => $this->direction,
            'presets'           => $this->presets,
        ]);
    }
}

Usage at the call site:

GradientPickerProp::make('bg_gradient')
    ->setLabel(__('Background gradient'))
    ->maxStops(8)
    ->disableTransparency()
    ->direction('135deg')
    ->presets([
        ['name' => 'Sunset', 'stops' => [
            ['color' => '#f97316', 'position' => 0],
            ['color' => '#ec4899', 'position' => 100],
        ]],
    ])
    ->default([
        ['color' => '#6366f1', 'position' => 0],
        ['color' => '#a855f7', 'position' => 100],
    ]);

Optional toCss() override

public function toCss(mixed $value): string

Override this on the Prop itself only when the value maps unambiguously to a CSS declaration without needing context from sibling Props. Most Props leave this returning '' and let the parent PropType's toCss(array $values) handle everything.

// Only implement when the Prop IS the entire CSS output
public function toCss(mixed $value): string
{
    if (empty($value) || count($value) < 2) {
        return '';
    }

    $stops = implode(', ', array_map(
        fn($s) => "{$s['color']} {$s['position']}%",
        $value
    ));

    return "background: linear-gradient(to right, {$stops});";
}

Value storage

The key passed to make() is where this Prop's value lives inside the module's props JSON blob:

{
  "type": "hero-section",
  "props": {
    "bg_gradient": [
      { "color": "#6366f1", "position": 0 },
      { "color": "#a855f7", "position": 100 }
    ]
  }
}

Serialization rules:

  • Do not rename the key after it has been used in production pages — rename the PHP identifier, keep the make key identical, or write a data migration.
  • The value shape set in default() must match what the Vue widget emits. Shape mismatches silently fall back to the default.
  • Arrays are JSON arrays; associative arrays are JSON objects. Pick one shape and keep it stable.

Using a custom Prop in a module

Inside a PropType's schema() method, instantiate the Prop like any other:

use App\Tagixo\Props\GradientPickerProp;

public function schema(): array
{
    return [
        GradientPickerProp::make('gradient')
            ->setLabel(__('Gradient'))
            ->maxStops(6)
            ->default([
                ['color' => '#3b82f6', 'position' => 0],
                ['color' => '#8b5cf6', 'position' => 100],
            ]),
    ];
}

Read the value in the PropType's toCss():

public function toCss(array $values): string
{
    $stops = $values['gradient'] ?? [];

    if (count($stops) < 2) {
        return '';
    }

    $css = implode(', ', array_map(
        fn($s) => "{$s['color']} {$s['position']}%",
        $stops
    ));

    return "background: linear-gradient(135deg, {$css});";
}

Vue widget contract

A custom Vue widget is a standard Vue 3 SFC that must implement:

<script setup>
const props = defineProps({
  // The stored value — shape matches what ->default() was given
  modelValue: {
    type: Array,
    default: () => [],
  },
  // All keys from widgetConfig() arrive as flat props automatically
  maxStops:           { type: Number,  default: 5 },
  allowTransparency:  { type: Boolean, default: true },
  direction:          { type: String,  default: 'to right' },
  presets:            { type: Array,   default: () => [] },
  // Standard fields from baseConfig() — always present
  label:    { type: String, default: '' },
  helpText: { type: String, default: '' },
});

const emit = defineEmits(['update:modelValue']);
</script>

Rules:

  • Never mutate modelValue directly — always copy, modify, and emit the new value. Direct mutation bypasses Vue's reactivity system and breaks the builder's undo/redo stack.
  • Keep the emitted value JSON-serializable. Dates, Maps, Sets, and class instances will break serialization.
  • The component must work in isolation — no assumptions about its parent's DOM.

Registering the Vue component

App-local (single consumer)

Add the widget registration to your app's builder JS entry point, loaded before the builder mounts:

// resources/js/tagixo-extensions.js
import GradientPickerWidget from './components/GradientPickerWidget.vue';

window.__tagixoWidgets = window.__tagixoWidgets ?? {};
window.__tagixoWidgets['gradient-picker'] = GradientPickerWidget;

Include this file in your Vite build and ensure it loads before the builder initializes.

Plugin-distributed (Composer package)

  1. Compile your widget to a standalone dist/plugin.js. Mark vue as external so the host's copy is used.
  2. The plugin's index.js registers the widget at import time:
import GradientPickerWidget from './components/GradientPickerWidget.vue';

window.__tagixoWidgets = window.__tagixoWidgets ?? {};
window.__tagixoWidgets['gradient-picker'] = GradientPickerWidget;
  1. The service provider declares the publishable path:
public function boot(): void
{
    $this->publishes([
        __DIR__ . '/../dist' => public_path('vendor/acme/tagixo-gradient'),
    ], 'acme-tagixo-gradient-assets');
}
  1. After php artisan vendor:publish, the consumer's layout includes the script before the builder initializes.

Never ship raw .vue source in a Composer package — consumers will not have your build toolchain. Always commit the compiled dist/ to the package repository.


Complete example: GradientPickerProp

PHP class

<?php

namespace App\Tagixo\Props;

use Ccast\Tagixo\Core\Props\AbstractProp;

class GradientPickerProp extends AbstractProp
{
    protected int $maxStops = 5;
    protected bool $allowTransparency = true;
    protected string $direction = 'to right';
    protected array $presets = [];

    public function maxStops(int $max): static
    {
        $this->maxStops = $max;
        return $this;
    }

    public function disableTransparency(): static
    {
        $this->allowTransparency = false;
        return $this;
    }

    public function direction(string $direction): static
    {
        $this->direction = $direction;
        return $this;
    }

    public function presets(array $presets): static
    {
        $this->presets = $presets;
        return $this;
    }

    public function widget(): string
    {
        return 'gradient-picker';
    }

    public function widgetConfig(): array
    {
        return array_merge($this->baseConfig(), [
            'maxStops'          => $this->maxStops,
            'allowTransparency' => $this->allowTransparency,
            'direction'         => $this->direction,
            'presets'           => $this->presets,
        ]);
    }
}

Vue SFC (resources/js/components/GradientPickerWidget.vue)

<template>
  <div class="gradient-picker">
    <div class="gradient-preview" :style="previewStyle" />

    <div class="gradient-stops">
      <div v-for="(stop, i) in modelValue" :key="i" class="gradient-stop">
        <input
          type="color"
          :value="stop.color"
          @input="updateColor(i, $event.target.value)"
        />
        <input
          type="range"
          min="0"
          max="100"
          :value="stop.position"
          @input="updatePosition(i, Number($event.target.value))"
        />
        <button
          v-if="modelValue.length > 2"
          type="button"
          @click="removeStop(i)"
        >
          ×
        </button>
      </div>
    </div>

    <button
      v-if="modelValue.length < maxStops"
      type="button"
      @click="addStop"
    >
      + Add stop
    </button>

    <div v-if="presets.length" class="gradient-presets">
      <button
        v-for="preset in presets"
        :key="preset.name"
        type="button"
        @click="applyPreset(preset)"
      >
        {{ preset.name }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  modelValue:         { type: Array,   default: () => [] },
  maxStops:           { type: Number,  default: 5 },
  allowTransparency:  { type: Boolean, default: true },
  direction:          { type: String,  default: 'to right' },
  presets:            { type: Array,   default: () => [] },
  label:              { type: String,  default: '' },
  helpText:           { type: String,  default: '' },
});

const emit = defineEmits(['update:modelValue']);

const previewStyle = computed(() => {
  const stops = props.modelValue
    .map(s => `${s.color} ${s.position}%`)
    .join(', ');
  return { background: `linear-gradient(${props.direction}, ${stops})` };
});

function updateColor(index, color) {
  emit('update:modelValue', props.modelValue.map((s, i) =>
    i === index ? { ...s, color } : s
  ));
}

function updatePosition(index, position) {
  emit('update:modelValue', props.modelValue.map((s, i) =>
    i === index ? { ...s, position } : s
  ));
}

function addStop() {
  emit('update:modelValue', [
    ...props.modelValue,
    { color: '#ffffff', position: 100 },
  ]);
}

function removeStop(index) {
  emit('update:modelValue', props.modelValue.filter((_, i) => i !== index));
}

function applyPreset(preset) {
  emit('update:modelValue', [...preset.stops]);
}
</script>

Owning PropType

<?php

namespace App\Tagixo\PropTypes;

use App\Tagixo\Props\GradientPickerProp;
use Ccast\Tagixo\Core\PropTypes\AbstractPropType;

class GradientBackgroundPropType extends AbstractPropType
{
    public function key(): string   { return 'gradient_bg'; }
    public function label(): string { return __('Gradient Background'); }
    public function tab(): string   { return 'design'; }
    public function hasCss(): bool  { return true; }

    public function schema(): array
    {
        return [
            GradientPickerProp::make('gradient')
                ->setLabel(__('Gradient'))
                ->maxStops(8)
                ->direction('135deg')
                ->presets([
                    ['name' => 'Ocean', 'stops' => [
                        ['color' => '#0ea5e9', 'position' => 0],
                        ['color' => '#6366f1', 'position' => 100],
                    ]],
                ])
                ->default([
                    ['color' => '#6366f1', 'position' => 0],
                    ['color' => '#a855f7', 'position' => 100],
                ]),
        ];
    }

    public function toCss(array $values): string
    {
        $stops = $values['gradient'] ?? [];

        if (count($stops) < 2) {
            return '';
        }

        $css = implode(', ', array_map(
            fn($s) => "{$s['color']} {$s['position']}%",
            $stops
        ));

        return "background: linear-gradient(135deg, {$css});";
    }
}

Service provider registration

use Ccast\Tagixo\Facades\Tagixo;
use App\Tagixo\PropTypes\GradientBackgroundPropType;

public function boot(): void
{
    Tagixo::registerPropType(new GradientBackgroundPropType());
}

GradientPickerProp needs no separate registration call — it travels with GradientBackgroundPropType through the schema and arrives in the bootstrap payload alongside it.


Serialization checklist

  • make key matches everywhere: PHP class, all call sites, and any existing JSON blobs
  • default() value has the exact same shape as what the Vue widget emits
  • widgetConfig() contains only JSON-serializable values
  • Vue widget never mutates modelValue — always emits a new value
  • Renaming a Prop key requires a data migration that rewrites it in all affected page JSON blobs

Testing checklist

  • Unit test: toCss() (or the parent PropType's) returns expected CSS for a known value
  • Unit test: toCss() returns '' for empty/null value
  • widgetConfig() is fully serializable: json_encode($prop->widgetConfig()) produces no unexpected null values
  • Browser: widget appears in the correct PropType section
  • Browser: editing in the panel updates the canvas live
  • Browser: page reload preserves the saved value
  • Browser: undo/redo steps correctly around the change

See also