Tagixo Plugin
Once your custom modules, PropTypes, or Props are stable, packaging them as a Composer plugin lets you reuse them across projects and ship updates independently. Both ccast/tagixo-filament and ccast/tagixo-primix are themselves Tagixo plugins — their service providers are real-world reference implementations you can study directly.
This page covers the full lifecycle: deciding when a plugin makes sense, scaffolding the package, wiring the service provider, building custom Vue widgets, versioning and distribution, and testing in isolation. It closes with a complete worked example.
When to make a plugin package
Keeping extensions inside app/Tagixo/ is the right call for project-specific behaviour. Extract to a Composer plugin when:
- You need the same modules or PropTypes in two or more projects. The moment you copy a class between repos you have a maintenance problem; a plugin solves it.
- You want to sell or distribute extensions. A Composer package is the natural unit for a marketplace product.
- The extension has a different release cadence from the host app. Publishing to Packagist (or a private Satis repo) lets you tag independently and pin consumer constraints properly.
- You are building an SDK that sits on top of Tagixo and provides its own set of primitives — like
tagixo-primixdoes for the Primix admin panel.
You do not need a plugin for: a single app with a few custom modules, a quick prototype, or a feature that is inherently tenant-specific.
Plugin anatomy
A Tagixo plugin is a normal Composer package with three required pieces:
- A
composer.jsonwithautoload.psr-4mapping andextra.laravel.providersfor auto-discovery - A Laravel service provider that registers modules / PropTypes / Props with the core
- The actual extension classes (modules, PropTypes, props), optionally with companion Blade views and frontend assets
The minimum viable file tree looks like this:
acme/tagixo-charts/
├── composer.json
├── src/
│ ├── AcmeTagixoChartsServiceProvider.php
│ ├── ChartsPlugin.php (optional, recommended)
│ ├── Modules/
│ │ ├── BarChart.php
│ │ └── PieChart.php
│ ├── PropTypes/
│ │ └── ChartLegendPropType.php
│ └── Props/
│ └── ChartDataProp.php
├── resources/
│ └── views/
│ └── modules/
│ └── bar-chart.blade.php
└── dist/ (only if shipping custom Vue widgets)
└── widgets.js
Step-by-step: bootstrapping a plugin package
1. Initialize the package
mkdir tagixo-charts && cd tagixo-charts
composer init --name=acme/tagixo-charts --type=library
Edit composer.json to declare PSR-4, the service provider for Laravel auto-discovery, and the minimum Tagixo version:
{
"name": "acme/tagixo-charts",
"description": "Chart modules for the Tagixo Visual Builder",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"ccast/tagixo": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\TagixoCharts\\": "src/" }
},
"extra": {
"laravel": {
"providers": [
"Acme\\TagixoCharts\\AcmeTagixoChartsServiceProvider"
]
}
}
}
Version constraint: pin
ccast/tagixoto the minimum version where the APIs your plugin uses were introduced. Do not use*or>=1.0— Tagixo may have breaking changes in future major versions and consumers need predictable resolution.
2. Scaffold extensions into the plugin
The make:tagixo-* commands accept a --package= flag that reads the plugin's composer.json, resolves the PSR-4 root, and writes files into the correct directory. Run these commands from a host Laravel app that has ccast/tagixo installed:
# Generate a module directly into the plugin repo
php artisan make:tagixo-module BarChart \
--package=../tagixo-charts \
--context=page
# Generate a PropType into the plugin
php artisan make:tagixo-prop-type ChartLegendPropType \
--package=../tagixo-charts
# Generate a custom prop into the plugin
php artisan make:tagixo-prop ChartDataProp \
--package=../tagixo-charts
Each command prints a registration snippet to paste into your service provider:
Register in your package's ServiceProvider::boot():
\Ccast\Tagixo\Facades\Tagixo::registerModules([\Acme\TagixoCharts\Modules\BarChart::class]);
Without --package=, the generator writes into the host app under the paths set in config/tagixo.php (scaffolding.path / scaffolding.namespace). With --package=, it respects the plugin's own PSR-4 root regardless of host app config.
3. Wire the service provider
The plain service provider is sufficient for simple plugins. Register all extensions in boot(), not register():
<?php
namespace Acme\TagixoCharts;
use Ccast\Tagixo\Facades\Tagixo;
use Illuminate\Support\ServiceProvider;
class AcmeTagixoChartsServiceProvider extends ServiceProvider
{
public function register(): void
{
// Bind any plugin-specific services here. Keep this lean.
}
public function boot(): void
{
Tagixo::registerModules([
\Acme\TagixoCharts\Modules\BarChart::class,
\Acme\TagixoCharts\Modules\PieChart::class,
\Acme\TagixoCharts\Modules\LineChart::class,
]);
Tagixo::registerPropTypes([
\Acme\TagixoCharts\PropTypes\ChartLegendPropType::class,
\Acme\TagixoCharts\PropTypes\ChartAxisPropType::class,
]);
$this->loadViewsFrom(__DIR__.'/../resources/views', 'acme-tagixo-charts');
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/acme-tagixo-charts'),
], 'acme-tagixo-charts-views');
}
}
boot() vs register(): Tagixo's own service provider boots before yours. Registrations made in
register()risk running before Tagixo's registry is initialised. Always register extensions inboot().
First-class plugin API (recommended)
The service provider approach above works, but Tagixo ships a dedicated plugin contract that gives you a cleaner separation between the Composer package bootstrap and the Tagixo registration logic. This is the approach used by ccast/tagixo-filament and ccast/tagixo-primix.
Scaffold the plugin class
php artisan make:tagixo-plugin ChartsPlugin
Output: app/Tagixo/Plugins/ChartsPlugin.php. In package mode:
php artisan make:tagixo-plugin ChartsPlugin --package=../tagixo-charts
Implement TagixoPluginBase
Extend TagixoPluginBase instead of calling Tagixo::registerModules() from the service provider directly. The base class receives a resolved Tagixo instance in boot(), making the dependency explicit and testable:
<?php
namespace Acme\TagixoCharts;
use Ccast\Tagixo\Tagixo;
use Ccast\Tagixo\TagixoPluginBase;
class ChartsPlugin extends TagixoPluginBase
{
public function getId(): string
{
return 'acme/tagixo-charts';
}
public function boot(Tagixo $tagixo): void
{
$tagixo->registerModules([
\Acme\TagixoCharts\Modules\BarChart::class,
\Acme\TagixoCharts\Modules\PieChart::class,
]);
$tagixo->registerPropTypes([
\Acme\TagixoCharts\PropTypes\ChartLegendPropType::class,
]);
}
}
TagixoPluginBase provides a static make() factory and empty default implementations for register() and boot() — override only what you need.
Register the plugin class in the service provider
use Ccast\Tagixo\Facades\Tagixo;
public function boot(): void
{
Tagixo::plugin(\Acme\TagixoCharts\ChartsPlugin::make());
$this->loadViewsFrom(__DIR__.'/../resources/views', 'acme-tagixo-charts');
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/acme-tagixo-charts'),
], 'acme-tagixo-charts-views');
}
Tagixo calls register() then boot() on every registered plugin after all Laravel providers have booted, so you are guaranteed to run after the core is fully initialised.
HasPlugin — exposing a panel sub-plugin
If your Tagixo plugin also ships a Filament or Primix admin-panel integration (resources, pages, settings UI), implement HasPlugin and return your panel plugin object from getPlugin():
use Ccast\Tagixo\Contracts\HasPlugin;
use Ccast\Tagixo\Tagixo;
use Ccast\Tagixo\TagixoPluginBase;
class ChartsPlugin extends TagixoPluginBase implements HasPlugin
{
public function getId(): string { return 'acme/tagixo-charts'; }
public function boot(Tagixo $tagixo): void
{
$tagixo->registerModules([/* ... */]);
}
public function getPlugin(): object
{
return \Acme\TagixoCharts\FilamentChartsPlugin::make();
}
}
The active SDK (tagixo-filament or tagixo-primix) does an instanceof check on the returned object during its own boot phase and auto-registers it with the panel. Returning a Filament plugin on a Primix app (or vice versa) is safe — it results in a no-op. No consumer code is needed: the SDK handles it automatically as long as your Tagixo plugin is registered via Tagixo::plugin(…).
Frontend assets in a plugin
If your plugin ships custom Props with custom Vue widgets (i.e. UI controls that don't exist among the built-ins), you need to bundle those widgets and deliver them to consumer apps as pre-built JS. Tagixo core uses the same pattern — it ships a pre-built dist/builder.js that consumers publish to public/vendor/tagixo/.
When you need a dist bundle
| Scenario | Needs dist bundle? |
|---|---|
| Module with only standard props (TextProp, ColorProp, etc.) | No |
| PropType composed from standard props | No |
| Custom Prop with a custom Vue widget (e.g. chart data grid) | Yes |
| Module that needs a Vue component on the frontend canvas | Yes |
Setting up the plugin's build pipeline
Add a vite.config.js at the plugin root that builds an IIFE or ES module targeting the builder's Vue instance:
// tagixo-charts/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
build: {
lib: {
entry: 'resources/js/widgets.js',
name: 'AcmeTagixoCharts',
fileName: () => 'widgets.js',
formats: ['iife'],
},
outDir: 'dist',
rollupOptions: {
// Mark Vue and the builder core as external —
// the consumer app provides these at runtime.
external: ['vue'],
output: {
globals: { vue: 'Vue' },
},
},
},
plugins: [vue()],
});
Critical: mark
vueas external. If you bundle Vue, the consumer ends up with two separate Vue instances, which breaks Pinia, reactivity, and prop registration. The builder injectswindow.Vueat boot; your IIFE receives it viaglobals.
Your entry point registers widget components with Tagixo's frontend registry:
// resources/js/widgets.js
import ChartDataWidget from './components/ChartDataWidget.vue';
if (window.__tagixo_widgets) {
window.__tagixo_widgets.register('chart-data', ChartDataWidget);
}
On the PHP side, your custom Prop declares the widget type:
class ChartDataProp extends AbstractProp
{
public function widgetType(): string
{
return 'chart-data'; // must match the registered key above
}
}
Publishing plugin assets
Register a publish tag in the service provider so consumers can copy dist/ to public/vendor/:
$this->publishes([
__DIR__.'/../dist' => public_path('vendor/acme-tagixo-charts'),
], 'acme-tagixo-charts-assets');
Document the required consumer steps:
# After install
php artisan vendor:publish --tag=acme-tagixo-charts-assets --force
# After every plugin upgrade
php artisan vendor:publish --tag=acme-tagixo-charts-assets --force
Reference the published file from a view or a script injection hook:
// In your service provider:
\Ccast\Tagixo\Facades\Tagixo::addBuilderScript(
asset('vendor/acme-tagixo-charts/widgets.js')
);
Commit dist/ to the package repo. Unlike application code, plugin
dist/files should be committed. Consumerscomposer installa tagged version; they receive the committeddist/and runvendor:publish. They do not runnpm run build. This is the same approachccast/tagixouses forpublic/vendor/tagixo/.
Versioning
Tagixo plugins follow SemVer strictly. The key rule is that saved page JSON is forever — once you publish a module with a type ID, that ID must continue to deserialize correctly in every version of your plugin that consumers might already have content for.
| Change type | Version bump |
|---|---|
| New module, PropType, or Prop added | Minor |
| Bug fix in CSS generation (no schema change) | Patch |
| New optional Prop added to an existing module | Minor (safe — missing key falls back to default) |
| Rename or remove a module type ID | Major |
| Change a Prop key on an existing module | Major |
| Change the structure of a PropType's stored JSON | Major |
| Change the semantics of an existing Prop's value (e.g. units) | Major |
When you need to rename or restructure saved props in a major bump, provide a migration helper — a console command or artisan task that reads existing page records and rewrites the affected JSON fields before the site goes live on the new version.
Type ID stability is sacred. If
BarCharthas type IDbar-chartin v1, it must staybar-chartin v2. A renamed type ID silently drops every existing bar-chart module from every page that uses your plugin.
Testing the plugin in isolation
Use Orchestra Testbench to spin up a minimal Laravel environment inside your plugin's own test suite. You do not need a separate Laravel app for basic registration tests.
# In tagixo-charts/
composer require --dev orchestra/testbench
// tests/RegistrationTest.php
namespace Acme\TagixoCharts\Tests;
use Acme\TagixoCharts\AcmeTagixoChartsServiceProvider;
use Ccast\Tagixo\Tagixo;
use Orchestra\Testbench\TestCase;
class RegistrationTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [
\Ccast\Tagixo\TagixoServiceProvider::class,
AcmeTagixoChartsServiceProvider::class,
];
}
public function test_modules_are_registered(): void
{
$modules = app(Tagixo::class)->getCustomModules();
$this->assertContains(\Acme\TagixoCharts\Modules\BarChart::class, $modules);
$this->assertContains(\Acme\TagixoCharts\Modules\PieChart::class, $modules);
}
public function test_prop_types_are_registered(): void
{
$registry = app(\Ccast\Tagixo\Core\Registries\PropTypeRegistry::class);
$this->assertNotNull($registry->get('chart-legend'));
}
public function test_module_type_ids_are_stable(): void
{
// Lock type IDs in a test so a rename causes a visible failure.
$modules = app(Tagixo::class)->getCustomModules();
$ids = array_map(fn ($class) => (new $class)->type(), $modules);
$this->assertContains('bar-chart', $ids);
$this->assertContains('pie-chart', $ids);
}
}
The last test is the one that matters most for long-term stability: if someone renames type() on a module, the test fails loudly before any content is silently broken in production.
For integration tests that exercise rendering, load the plugin into a full SQLite-backed Testbench app and call the page renderer directly:
public function test_bar_chart_renders_without_errors(): void
{
$html = app(\Ccast\Tagixo\Services\PageRenderer::class)
->renderModule('bar-chart', [
'title' => 'Monthly Revenue',
'data' => [],
'legend' => ['enabled' => true, 'position' => 'bottom'],
]);
$this->assertStringContainsString('bar-chart', $html);
}
Distribution
Public (Packagist)
- Tag a release:
git tag v1.0.0 && git push --tags - Submit the GitHub URL at packagist.org/packages/submit
- Set up a GitHub webhook so Packagist picks up new tags automatically
- Consumers install with:
composer require acme/tagixo-charts
Private VCS (GitHub + Composer)
Add the repository to the consumer's composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/acme/tagixo-charts"
}
],
"require": {
"acme/tagixo-charts": "^1.0"
}
}
For private repos, the consumer needs a GitHub PAT with Contents: Read configured in their Composer auth:
composer config --global --auth github-oauth.github.com <PAT>
Private Composer repo (Satis)
For teams distributing multiple private plugins, self-host a Satis instance. Add it once in the consumer's composer.json:
{
"repositories": [
{ "type": "composer", "url": "https://satis.acme.dev" }
]
}
Satis regenerates its index on a cron or post-receive hook. All packages hosted there resolve without individual VCS entries.
Multi-tenant note
Tagixo::registerModules() accumulates registrations for the process lifetime. For per-tenant module sets in Octane or Roadrunner environments:
- Register the shared set in
boot()for all tenants - Add per-tenant extras in a middleware that runs after tenant context is resolved
The registry is not request-scoped. Conditional registration inside a request cycle leaks into subsequent requests. Structure tenant-specific module sets as tenant config that maps to pre-registered modules, not as dynamic registrations per request.
Complete example: PricingTablePlugin
This is the full source for a minimal but production-shaped plugin that adds a PricingTable module with a custom PriceCardPropType.
Package structure
acme/tagixo-pricing/
├── composer.json
├── src/
│ ├── PricingServiceProvider.php
│ ├── PricingPlugin.php
│ ├── Modules/
│ │ └── PricingTable.php
│ └── PropTypes/
│ └── PriceCardPropType.php
└── resources/
└── views/
└── modules/
└── pricing-table.blade.php
composer.json
{
"name": "acme/tagixo-pricing",
"description": "Pricing table module for Tagixo",
"type": "library",
"require": {
"php": "^8.2",
"ccast/tagixo": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\Pricing\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Pricing\\PricingServiceProvider"]
}
}
}
PriceCardPropType
<?php
namespace Acme\Pricing\PropTypes;
use Ccast\Tagixo\Core\PropTypes\AbstractPropType;
use Ccast\Tagixo\Core\Props\ColorProp;
use Ccast\Tagixo\Core\Props\SelectProp;
class PriceCardPropType extends AbstractPropType
{
public function key(): string { return 'price-card'; }
public function label(): string { return __('Price Card Style'); }
public function tab(): string { return 'design'; }
public function schema(): array
{
return [
ColorProp::make('background')
->setLabel(__('Card background'))
->default('#ffffff'),
ColorProp::make('highlight')
->setLabel(__('Highlight colour'))
->default('#6366f1'),
SelectProp::make('radius')
->setLabel(__('Corner radius'))
->options([
'none' => __('None'),
'small' => __('Small'),
'medium' => __('Medium'),
'large' => __('Large'),
])
->default('medium'),
];
}
public function generateCss(array $props, string $selector): string
{
$bg = $props['background'] ?? '#ffffff';
$radius = match ($props['radius'] ?? 'medium') {
'none' => '0',
'small' => '4px',
'medium' => '8px',
'large' => '16px',
default => '8px',
};
return "{$selector} { background: {$bg}; border-radius: {$radius}; }";
}
}
PricingTable module
<?php
namespace Acme\Pricing\Modules;
use Ccast\Tagixo\Core\Modules\AbstractModule;
use Ccast\Tagixo\Core\ModuleDefinition;
use Ccast\Tagixo\Core\Props\RepeaterProp;
use Ccast\Tagixo\Core\Props\TextProp;
use Ccast\Tagixo\Core\Props\ToggleProp;
use Ccast\Tagixo\Core\Props\NumberProp;
class PricingTable extends AbstractModule
{
public function type(): string { return 'pricing-table-acme'; }
public function label(): string { return __('Pricing Table'); }
public function icon(): string { return 'heroicon-o-table-cells'; }
public function contexts(): array
{
return ['page'];
}
public function definition(): ModuleDefinition
{
return ModuleDefinition::make()
->content(
TextProp::make('heading')->default(__('Our Plans')),
RepeaterProp::make('plans')
->setLabel(__('Plans'))
->sub(
TextProp::make('name')->default(__('Pro')),
NumberProp::make('price')->default(49),
TextProp::make('period')->default('/month'),
ToggleProp::make('highlighted')->default(false),
RepeaterProp::make('features')
->setLabel(__('Features'))
->sub(
TextProp::make('text'),
ToggleProp::make('included')->default(true),
),
),
)
->propTypes([
'price-card', // resolved from PropTypeRegistry by key
]);
}
public function view(): string
{
return 'acme-tagixo-pricing::modules.pricing-table';
}
}
Type ID uniqueness: use a vendor-prefixed type ID (
pricing-table-acme, notpricing-table) to avoid collisions with the built-inpricing-tablemodule or other plugins. A collision means one module silently shadows the other — there is no warning at boot.
PricingPlugin and PricingServiceProvider
<?php
namespace Acme\Pricing;
use Ccast\Tagixo\Tagixo;
use Ccast\Tagixo\TagixoPluginBase;
class PricingPlugin extends TagixoPluginBase
{
public function getId(): string { return 'acme/tagixo-pricing'; }
public function boot(Tagixo $tagixo): void
{
$tagixo->registerModules([
\Acme\Pricing\Modules\PricingTable::class,
]);
$tagixo->registerPropTypes([
\Acme\Pricing\PropTypes\PriceCardPropType::class,
]);
}
}
<?php
namespace Acme\Pricing;
use Ccast\Tagixo\Facades\Tagixo;
use Illuminate\Support\ServiceProvider;
class PricingServiceProvider extends ServiceProvider
{
public function boot(): void
{
Tagixo::plugin(PricingPlugin::make());
$this->loadViewsFrom(__DIR__.'/../resources/views', 'acme-tagixo-pricing');
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/acme-tagixo-pricing'),
], 'acme-tagixo-pricing-views');
}
}
Blade view
{{-- resources/views/modules/pricing-table.blade.php --}}
@php
/** @var array $props */
$plans = $props['plans'] ?? [];
@endphp
<div class="pricing-table" data-vb-element="pricing-table-acme">
@if (!empty($props['heading']))
<h2 class="pricing-table__heading">{{ $props['heading'] }}</h2>
@endif
<div class="pricing-table__grid">
@foreach ($plans as $plan)
<div class="pricing-table__card {{ ($plan['highlighted'] ?? false) ? 'pricing-table__card--highlighted' : '' }}">
<div class="pricing-table__plan-name">{{ $plan['name'] ?? '' }}</div>
<div class="pricing-table__price">
{{ $plan['price'] ?? 0 }}<span>{{ $plan['period'] ?? '' }}</span>
</div>
<ul class="pricing-table__features">
@foreach ($plan['features'] ?? [] as $feature)
<li class="{{ ($feature['included'] ?? true) ? 'included' : 'excluded' }}">
{{ $feature['text'] ?? '' }}
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
</div>
Reference implementations
The two official SDK plugins are full-featured Tagixo plugins you can study directly:
| Package | Entry point |
|---|---|
ccast/tagixo-filament |
src/TagixoFilamentServiceProvider.php |
ccast/tagixo-primix |
src/TagixoPrimixServiceProvider.php |
Both wire modules, PropTypes, custom commands, publishable views, and frontend assets. Read them before designing a non-trivial plugin — they cover edge cases that are not always obvious from documentation alone.
See also
- Module System and Catalog
- Custom Page Modules
- PropTypes System
- Custom PropTypes
- Props System
- Custom Props
- Sub-elements and States
- Scaffold Commands
- Filament SDK Integration
- Primix SDK Integration