Plugin Packages

Creating a Tagixo Plugin

Package your modules, PropTypes, and Props as a redistributable Composer plugin package.

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-primix does 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:

  1. A composer.json with autoload.psr-4 mapping and extra.laravel.providers for auto-discovery
  2. A Laravel service provider that registers modules / PropTypes / Props with the core
  3. 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/tagixo to 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 in boot().

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 vue as external. If you bundle Vue, the consumer ends up with two separate Vue instances, which breaks Pinia, reactivity, and prop registration. The builder injects window.Vue at boot; your IIFE receives it via globals.

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. Consumers composer install a tagged version; they receive the committed dist/ and run vendor:publish. They do not run npm run build. This is the same approach ccast/tagixo uses for public/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 BarChart has type ID bar-chart in v1, it must stay bar-chart in 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)

  1. Tag a release: git tag v1.0.0 && git push --tags
  2. Submit the GitHub URL at packagist.org/packages/submit
  3. Set up a GitHub webhook so Packagist picks up new tags automatically
  4. 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, not pricing-table) to avoid collisions with the built-in pricing-table module 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