Xgenious/ docs

Testing & Packaging

A plugin that boots is not a plugin that works. This page covers the test harness, the validator, the zip layout, and the release checks that prevent a bad update.

Testing philosophy

The framework is pure PHP. You can test a plugin without booting Laravel:

  • Instantiate HookManager, SlotRenderer, and Registry.
  • Build a PluginContext with them.
  • Instantiate your plugin and call boot().
  • Apply filters, dispatch actions, render slots, and assert registry entries.

This keeps tests fast and hermetic. For code that needs the database, use an integration test with a test database.

Test layout

plugins/my-plugin/
├── phpunit.xml
└── tests/
    ├── PluginTest.php
    └── Integration/
        └── StoreTest.php

phpunit.xml

Copy the structure from plugins/coupons/phpunit.xml:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php"
         colors="true"
         failOnWarning="true"
         failOnRisky="true">
    <testsuites>
        <testsuite name="My Plugin">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <php>
        <ini name="error_reporting" value="-1"/>
    </php>
</phpunit>

tests/bootstrap.php should require the framework and your autoloader:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../../framework/bootstrap.php';

spl_autoload_register(static function (string $class): void {
    $prefix = 'MyPlugin\\';
    if (!str_starts_with($class, $prefix)) {
        return;
    }
    $relative = substr($class, strlen($prefix));
    $file = __DIR__ . '/../src/' . str_replace('\\', '/', $relative) . '.php';
    if (is_file($file)) {
        require_once $file;
    }
});

A unit test

<?php

declare(strict_types=1);

use MyPlugin\MyPlugin;
use PluginsFramework\Hooks\HookManager;
use PluginsFramework\Plugins\PluginContext;
use PluginsFramework\Registries\Registry;
use PluginsFramework\Slots\SlotRenderer;
use PHPUnit\Framework\TestCase;

final class PluginTest extends TestCase
{
    private HookManager $hooks;

    protected function setUp(): void
    {
        $this->hooks = new HookManager();
        $slots = new SlotRenderer();
        $registry = new Registry();

        $plugin = new MyPlugin(new PluginContext($this->hooks, $slots, $registry));
        $plugin->boot();
    }

    public function testFilterLowersStandardPrice(): void
    {
        $price = $this->hooks->applyFilters(
            'checkout.price.calculate',
            100.0,
            ['order_kind' => 'standard', 'freelancer_id' => 1, 'client_id' => 2]
        );

        self::assertSame(99.0, $price);
    }

    public function testFilterLeavesHourlyPriceAlone(): void
    {
        $price = $this->hooks->applyFilters(
            'checkout.price.calculate',
            100.0,
            ['order_kind' => 'hourly']
        );

        self::assertSame(100.0, $price);
    }

    public function testSlotEscapesOutput(): void
    {
        $slots = new SlotRenderer();
        $slots->register('order.details_after', 'test', static function (array $context): string {
            return '<div>' . htmlspecialchars((string) $context['name'], ENT_QUOTES) . '</div>';
        });

        $html = $slots->render('order.details_after', ['name' => '<script>']);

        self::assertStringNotContainsString('<script>', $html);
    }
}

What to test

AreaTest
FiltersValue in, expected value out; wrong type; missing context; no listener
ActionsCallback runs; payload shape; duplicate delivery is idempotent
SlotsEscape output; empty context; throwing renderer is skipped
RegistryEntry shape; priority order; duplicate ID overwrites
PermissionsRoute is denied without the permission
Migrationsup() creates; down() removes
MoneyRounding, currency, zero, negative, very large
WebhooksSignature, replay, timeout, retry
SettingsDefaults, save, masked secret, unset

Running tests

core/vendor/bin/phpunit -c plugins/my-plugin/phpunit.xml

Framework tests:

core/vendor/bin/phpunit -c plugins/framework/phpunit.xml

Run both in CI before packaging.

The validator

php core/artisan plugins validate my-plugin

What it checks:

  1. Directory exists.
  2. plugin.json exists and parses.
  3. Required fields are valid.
  4. Entry file exists.
  5. Every PHP file passes php -l.
  6. PHP version satisfies the constraint.
  7. Framework version satisfies the constraint.
  8. Plugin dependencies are active.
  9. Entry class exists and implements PluginInterface.
  10. Constructor accepts PluginContext.
  11. A sandboxed boot test succeeds (skip with --skip-boot).

Warnings:

  • php -l unavailable.
  • Composer autoloader unavailable.
  • Capability declared but not detected.

Fix every error. Read every warning.

Zip layout

Two layouts are accepted:

Manifest at the root

my-plugin.zip
├── plugin.json
├── src/
└── views/

One folder deep

my-plugin.zip
└── my-plugin/
    ├── plugin.json
    ├── src/
    └── views/

Anything deeper is rejected. The uploader extracts to a temporary directory, finds the manifest, validates, then moves the plugin into plugins/<slug>.

What to include

  • plugin.json
  • src/
  • routes/
  • views/
  • database/migrations/
  • lang/
  • config/
  • README.md
  • phpunit.xml and tests/ (optional, but useful for review)

What to exclude

  • vendor/ unless you truly need it.
  • node_modules/.
  • .git/.
  • Local storage, logs, and caches.
  • Test databases.
  • Secrets, .env files, and key material.
  • Editor and OS files.

Build the zip

cd plugins
zip -r my-plugin-1.0.0.zip my-plugin \
  -x "my-plugin/.git/*" \
  -x "my-plugin/vendor/*" \
  -x "my-plugin/storage/*" \
  -x "my-plugin/tests/.phpunit.result.cache"

Verify the structure before uploading:

unzip -l my-plugin-1.0.0.zip | head -30

Install paths

Admin uploader

  1. Open Plugin Manage.
  2. Choose Upload Plugin.
  3. Select the zip.
  4. Wait for validation.
  5. Activate.

The uploader validates, runs migrations, activates, and clears caches. If any step fails, the plugin is not activated.

CLI

php core/artisan plugins validate my-plugin
php core/artisan plugins activate my-plugin

Manual

  1. Extract into plugins/<slug>.
  2. Run php core/artisan plugins validate <slug>.
  3. Run php core/artisan plugins activate <slug>.

Never copy a plugin into core/Modules. The framework does not scan there.

Release checklist

Code

  • boot() does no I/O.
  • All output escaped.
  • All input validated.
  • No core imports.
  • No secrets committed.
  • Every admin route permission-gated.
  • Writes idempotent.
  • Multi-row writes in a transaction.
  • Migration has down().
  • Tables prefixed with the slug.

Manifest

  • slug matches the directory.
  • version bumped.
  • entry.file and entry.class correct.
  • autoload.psr-4 correct.
  • requires.framework and requires.php set.
  • requires.plugins accurate.
  • provides matches what boot() registers.
  • capabilities accurate.
  • Menus use real route names.
  • No module.json.

Tests

  • Unit tests pass.
  • Integration tests pass.
  • Validator passes with no errors.
  • Boot test passes.
  • Tested on a clean install.
  • Tested with the plugin inactive.
  • Tested with a missing optional dependency.

UX

  • Empty states.
  • Error messages.
  • Loading states.
  • Mobile layout.
  • Dark mode if the admin supports it.
  • Translations.
  • Destructive actions confirm.

Documentation

  • README explains what it does.
  • Settings are documented.
  • Hooks and tables are listed.
  • Known limits are stated.
  • Upgrade notes cover schema changes.

Versioning and compatibility

ChangeBump
Bug fix, translation, stylePatch
New hook, setting, providerMinor
Manifest or public contract changeMajor

Rules:

  • Do not remove a hook in a minor release. Deprecate it for one minor, then remove in a major.
  • Do not rename a table without a migration that copies data.
  • Do not change a payload shape without a major bump.
  • Keep old settings readable. Migrate them on activation.

Data on uninstall

Deactivation does not delete data. Decide and document what uninstall means:

  • Keep data: safest for financial and legal records.
  • Archive data: move to a backup table.
  • Delete data: only with explicit operator confirmation.

Never drop tables on deactivate. A user who toggles a plugin off should not lose the records.

Rollback

If a release breaks a site:

  1. Deactivate the plugin.
  2. Restore the previous plugin directory.
  3. Run any down migrations only if the new version added them and the old version cannot read the schema. Prefer leaving additive tables in place.
  4. Clear caches.
  5. Restart workers.

Keep the previous zip and a database backup.

CI example

name: plugin
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --no-interaction --prefer-dist
        working-directory: core
      - run: core/vendor/bin/phpunit -c plugins/my-plugin/phpunit.xml
      - run: php core/artisan plugins validate my-plugin
        env:
          APP_ENV: testing

If the validator needs a database, use a disposable SQLite database in CI.


Last updated: October 21, 2026

Still stuck?
Our support team is ready to help you get set up.
Get support