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, andRegistry. - Build a
PluginContextwith 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
| Area | Test |
|---|---|
| Filters | Value in, expected value out; wrong type; missing context; no listener |
| Actions | Callback runs; payload shape; duplicate delivery is idempotent |
| Slots | Escape output; empty context; throwing renderer is skipped |
| Registry | Entry shape; priority order; duplicate ID overwrites |
| Permissions | Route is denied without the permission |
| Migrations | up() creates; down() removes |
| Money | Rounding, currency, zero, negative, very large |
| Webhooks | Signature, replay, timeout, retry |
| Settings | Defaults, 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:
- Directory exists.
plugin.jsonexists and parses.- Required fields are valid.
- Entry file exists.
- Every PHP file passes
php -l. - PHP version satisfies the constraint.
- Framework version satisfies the constraint.
- Plugin dependencies are active.
- Entry class exists and implements
PluginInterface. - Constructor accepts
PluginContext. - A sandboxed boot test succeeds (skip with
--skip-boot).
Warnings:
php -lunavailable.- 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.jsonsrc/routes/views/database/migrations/lang/config/README.mdphpunit.xmlandtests/(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,
.envfiles, 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
- Open Plugin Manage.
- Choose Upload Plugin.
- Select the zip.
- Wait for validation.
- 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
- Extract into
plugins/<slug>. - Run
php core/artisan plugins validate <slug>. - 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
-
slugmatches the directory. -
versionbumped. -
entry.fileandentry.classcorrect. -
autoload.psr-4correct. -
requires.frameworkandrequires.phpset. -
requires.pluginsaccurate. -
providesmatches whatboot()registers. -
capabilitiesaccurate. - 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
| Change | Bump |
|---|---|
| Bug fix, translation, style | Patch |
| New hook, setting, provider | Minor |
| Manifest or public contract change | Major |
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:
- Deactivate the plugin.
- Restore the previous plugin directory.
- 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.
- Clear caches.
- 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

