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
makekey 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
modelValuedirectly — 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)
- Compile your widget to a standalone
dist/plugin.js. Markvueas external so the host's copy is used. - The plugin's
index.jsregisters the widget at import time:
import GradientPickerWidget from './components/GradientPickerWidget.vue';
window.__tagixoWidgets = window.__tagixoWidgets ?? {};
window.__tagixoWidgets['gradient-picker'] = GradientPickerWidget;
- 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');
}
- 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
-
makekey 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 unexpectednullvalues - 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
- Props System — built-in Prop catalog and
baseConfig()reference - Custom PropTypes — composing multiple Props without a custom Vue widget
- Module System and Catalog — where PropTypes attach to modules
- Sub-elements and States — how Props interact with hover and mobile states
- Tagixo Plugin — bundling custom Props and Vue components as a distributable Composer package
- Storage Model and Data Shape — how module JSON is persisted and queried