Your first plugin
A complete walkthrough. At the end you will have a plugin called AcmeShipping that brings its own table, serves it at /api/v1/acme-shipping/shipments, checks its own permission for it, and has a page in the interface.
Every file is shown in full. Nothing is hinted at, nothing is left out. Work through it from top to bottom and it runs at the end.
Time: about an hour the first time.
What you need
A development checkout with a running database. All commands run in the project directory.
If you do not have a checkout, the backend also works standalone; that is covered at the end under Working without a checkout.
Step 1: Generate the scaffold
bin/console octibiz:plugin:create AcmeShipping \
--namespace "Acme\Shipping" \
--label "Acme Shipping"
The name is PascalCase with your own prefix. Everything else is derived from it:
AcmeShipping becomes | Value |
|---|---|
| Namespace | Acme\Shipping |
| Plugin key | acme.shipping |
| Module switch | module.acme-shipping |
| API segment | /api/v1/acme-shipping |
| Permission prefix | acme.shipping.* |
The prefix is mandatory, not a matter of taste: it stops two plugins from claiming the same key or the same address segment.
You get plugins/AcmeShipping/ with a runnable skeleton. The steps below replace the generated example files with real ones.
Step 2: Register
Two entries in the host. Without them your plugin does not exist as far as the application is concerned.
The instance's composer.json, in the autoload section:
{
"autoload": {
"psr-4": {
"App\\": "src/",
"Acme\\Shipping\\": "plugins/AcmeShipping/src/"
}
}
}
composer dump-autoload
config/plugins.php:
<?php
return [
Acme\Shipping\AcmeShippingPlugin::class => ['all' => true],
];
Step 3: The bundle
plugins/AcmeShipping/src/AcmeShippingPlugin.php
<?php
declare(strict_types=1);
namespace Acme\Shipping;
use App\Plugin\AbstractPlugin;
final class AcmeShippingPlugin extends AbstractPlugin
{
}
That is complete. An empty body is enough, and it is the normal case.
AbstractPlugin does the rest: it finds and wires the services under src/, registers src/Entity/ as a data model, registers src/Migrations/ as a migration path, and templates/ as a template namespace. src/ApiResource/ is picked up by the API layer on its own.
There is no services.yaml in a plugin. Not one of the shipped plugins has one.
Step 4: The descriptor
The only mandatory implementation. It answers seven questions about your plugin.
plugins/AcmeShipping/src/AcmeShippingDescriptor.php
<?php
declare(strict_types=1);
namespace Acme\Shipping;
use App\Platform\Plugin\Contract\PluginDescriptorInterface;
final class AcmeShippingDescriptor implements PluginDescriptorInterface
{
/** The authoritative source of the version. The same number goes into composer.json,
* plugin.json and the top section of the CHANGELOG. */
public const VERSION = '0.1.0';
public function getPluginKey(): string
{
return 'acme.shipping';
}
public function getDisplayName(): string
{
return 'Acme Shipping';
}
public function getVersion(): string
{
return self::VERSION;
}
public function getTrustTier(): string
{
return self::TRUST_COMMUNITY;
}
public function getBundleClass(): string
{
return AcmeShippingPlugin::class;
}
public function getModuleFlag(): string
{
return 'module.acme-shipping';
}
/**
* EVERY new API prefix belongs in here.
*
* If one is missing, no module gate applies to that segment: the route stays
* reachable even when the module is switched off.
*/
public function getRouteSegments(): array
{
return ['acme-shipping'];
}
}
This is registered nowhere. PluginDescriptorInterface carries a marker that the implementation is collected by. Implementing is enough — that principle holds for every contract in this system.
Step 5: The permission
Before there is data, there is the permission to see it. It comes from a contract, never from a migration.
plugins/AcmeShipping/src/Catalog/AcmeShippingPermissionProvider.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\Catalog;
use App\Platform\Auth\Contract\PermissionProviderInterface;
final class AcmeShippingPermissionProvider implements PermissionProviderInterface
{
/**
* @return array<string, array{0: string, 1: string}> permission => [module, description]
*/
public function getPermissions(): array
{
return [
'acme.shipping.view' => ['Acme Shipping', 'View shipments'],
'acme.shipping.manage' => ['Acme Shipping', 'Manage shipments'],
];
}
/**
* Extend roles ADDITIVELY. A plugin does not redefine a system role.
*
* @return array<string, list<string>>
*/
public function getRoleGrants(): array
{
return [
'PM' => ['acme.shipping.view', 'acme.shipping.manage'],
'Support' => ['acme.shipping.view'],
];
}
/**
* MUST be the plugin key.
*
* It is the link to ownership tracking: on removal the system sweeps exactly those
* catalogue rows that carry this key. A different value leaves orphans behind.
*/
public function getProviderKey(): string
{
return 'acme.shipping';
}
}
Step 6: The entity
plugins/AcmeShipping/src/Entity/Shipment.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\Entity;
use App\Shared\Trait\BrandScoped;
use App\Shared\Trait\HasUlid;
use App\Shared\Trait\SoftDeletable;
use App\Shared\Trait\Timestampable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'acme_shipment')]
#[ORM\HasLifecycleCallbacks]
class Shipment
{
use HasUlid;
use Timestampable;
use SoftDeletable;
use BrandScoped;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private int $id;
#[ORM\Column(type: 'string', length: 120)]
private string $trackingCode;
/** Status as a string, not as an enum type. The catalogue row comes from the
* StandardDataProvider in step 10. */
#[ORM\Column(type: 'string', length: 30)]
private string $status = 'open';
public function __construct(string $trackingCode)
{
$this->trackingCode = $trackingCode;
$this->initUlid();
}
public function getId(): int
{
return $this->id;
}
public function getTrackingCode(): string
{
return $this->trackingCode;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): void
{
$this->status = $status;
}
}
Four shared traits you do not have to build yourself:
| Trait | What it contributes |
|---|---|
HasUlid | Public identifier. The API identifier is always the ULID, never the internal number |
Timestampable | createdAt, updatedAt |
SoftDeletable | deletedAt instead of a real delete |
BrandScoped | brandId for the brand scope |
The table name carries a domain prefix, not a vendor one: acme_shipment, not acme_plugin_shipment.
Step 7: The migration
plugins/AcmeShipping/src/Migrations/Version20260909120000.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260909120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'AcmeShipping: create table acme_shipment.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE acme_shipment (
id BIGINT AUTO_INCREMENT NOT NULL,
ulid VARCHAR(26) CHARACTER SET ascii NOT NULL,
tracking_code VARCHAR(120) NOT NULL,
status VARCHAR(30) DEFAULT 'open' NOT NULL,
brand_id BIGINT DEFAULT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
updated_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
UNIQUE INDEX uniq_acme_shipment_ulid (ulid),
INDEX idx_acme_shipment_status (status),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
}
public function down(Schema $schema): void
{
// No backward step on a live database.
}
}
Schema only, no content. Not a single INSERT. Master data, permissions and lookups come from the contracts. A migration that inserts rows creates a second truth that drifts apart at the next reconciliation.
Step 8: The API resource
It is a class of its own, not the entity. That separates what is visible on the outside from what is stored in the database.
plugins/AcmeShipping/src/ApiResource/ShipmentResource.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\ApiResource;
use Acme\Shipping\State\ShipmentProvider;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
shortName: 'AcmeShipment',
routePrefix: '/v1',
operations: [
new GetCollection(
uriTemplate: '/acme-shipping/shipments',
security: "is_granted('acme.shipping.view')",
provider: ShipmentProvider::class,
),
new Get(
uriTemplate: '/acme-shipping/shipments/{ulid}',
uriVariables: ['ulid'],
security: "is_granted('acme.shipping.view', object)",
provider: ShipmentProvider::class,
),
],
normalizationContext: ['groups' => ['acme_shipping:read']],
)]
final class ShipmentResource
{
public function __construct(
#[ApiProperty(identifier: true)]
#[Groups(['acme_shipping:read'])]
public string $ulid = '',
#[Groups(['acme_shipping:read'])]
public string $trackingCode = '',
#[Groups(['acme_shipping:read'])]
public string $status = 'open',
) {
}
}
The difference between those two lines is the most important paragraph in this guide:
security: "is_granted('acme.shipping.view')" // collection: WITHOUT an object
security: "is_granted('acme.shipping.view', object)" // item: WITH an object
The collection check is answered by the core's PermissionVoter from the shared catalogue.
The item check is not. It deliberately abstains as soon as an object is passed. Without that restraint the catalogue permission would override every object rule, because a single approval is enough under Symfony's strategy.
The consequence: without your own voter, nobody checks an item operation. That is what step 11 is for.
Step 9: The state provider
It supplies the data for both operations.
plugins/AcmeShipping/src/State/ShipmentProvider.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\State;
use Acme\Shipping\ApiResource\ShipmentResource;
use Acme\Shipping\Entity\Shipment;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* @implements ProviderInterface<ShipmentResource>
*/
final class ShipmentProvider implements ProviderInterface
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
/**
* @return list<ShipmentResource>|ShipmentResource|null
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ShipmentResource|null
{
$repo = $this->em->getRepository(Shipment::class);
if ($operation instanceof CollectionOperationInterface) {
return array_map(
$this->toResource(...),
$repo->findBy(['deletedAt' => null], ['id' => 'DESC'], 100),
);
}
$shipment = $repo->findOneBy([
'ulid' => $uriVariables['ulid'] ?? null,
'deletedAt' => null,
]);
// null produces a 404. The object found becomes the `object` of the item check.
return $shipment ? $this->toResource($shipment) : null;
}
private function toResource(Shipment $shipment): ShipmentResource
{
return new ShipmentResource(
ulid: $shipment->getUlid(),
trackingCode: $shipment->getTrackingCode(),
status: $shipment->getStatus(),
);
}
}
You do not need a repository. Only 13 of 372 entities in the whole system have one; the normal case is EntityManagerInterface directly.
The constructor is filled automatically. No entry, no configuration.
Step 10: Master data
The status open needs a catalogue row, otherwise the interface shows a raw key.
plugins/AcmeShipping/src/Catalog/AcmeShippingStandardDataProvider.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\Catalog;
use App\Platform\Plugin\Contract\StandardDataProviderInterface;
use App\Platform\Setting\Entity\Setting;
final class AcmeShippingStandardDataProvider implements StandardDataProviderInterface
{
public function getStatusSets(): array
{
return [];
}
public function getLookupSets(): array
{
return [];
}
/**
* @return list<array{key: string, value: string, type: string, scope: string, brandId: int|null, category: string|null}>
*/
public function getSettings(): array
{
return [
[
'key' => 'acme_shipping.default_carrier',
'value' => 'dhl',
'type' => Setting::TYPE_STRING,
'scope' => Setting::SCOPE_SYSTEM,
'brandId' => null,
'category' => 'acme-shipping',
],
];
}
/**
* The plugin ships its own module switch.
*
* Without this entry the row does not exist after installation, and because the gate
* is fail-closed, ALL routes answer with 404.
*
* @return list<array{name: string, enabled: bool, description: string|null}>
*/
public function getFeatureFlags(): array
{
return [
['name' => 'module.acme-shipping', 'enabled' => true, 'description' => 'Acme Shipping'],
];
}
public function getProviderKey(): string
{
return 'acme.shipping';
}
}
Step 11: The voter
Without it, every item operation is a candidate for reading someone else's data through a guessed identifier.
plugins/AcmeShipping/src/Security/AcmeShippingVoter.php
<?php
declare(strict_types=1);
namespace Acme\Shipping\Security;
use Acme\Shipping\ApiResource\ShipmentResource;
use App\Platform\Auth\Entity\User;
use App\Platform\Auth\Security\PermissionChecker;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<string, ShipmentResource>
*/
final class AcmeShippingVoter extends Voter
{
public const VIEW = 'acme.shipping.view';
public function __construct(private readonly PermissionChecker $permissionChecker)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::VIEW === $attribute && $subject instanceof ShipmentResource;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// Object rules belong here: brand membership, ownership, status.
return $this->permissionChecker->hasPermission($user, self::VIEW);
}
}
Step 12: Install and switch on
Time to run it for the first time. The order matters.
bin/console doctrine:migrations:migrate --no-interaction
bin/console octibiz:plugin:install acme.shipping --activate
The install command runs no migrations. It records catalogue ownership and creates the module switch, and it creates it disabled. Without --activate everything stays at 404.
Check:
bin/console octibiz:plugin:list
You should see your row with version, trust tier and enabled state.
And the first real call:
curl -H "Authorization: Bearer <token>" \
https://<instance>/api/v1/acme-shipping/shipments
Expected: an empty collection, no error. If you get 404 instead, the module switch is off. If you get 403, the user is missing acme.shipping.view.
Step 13: The interface
plugins/AcmeShipping/frontend/plugin.manifest.ts
import type { OctibizPluginManifest } from '@/plugins/manifest'
const manifest: OctibizPluginManifest = {
// Paths WITHOUT a leading slash. Components ALWAYS lazily loaded.
routes: [
{
path: 'acme-shipping',
name: 'acme-shipping-list',
component: () => import('./views/ShipmentListView.vue'),
meta: { permission: 'acme.shipping.view' },
},
],
navLinks: [
{
group: 'documents',
link: {
to: '/acme-shipping',
icon: 'pi-truck',
label: 'nav.links.acmeShipments',
permission: 'acme.shipping.view',
},
},
],
// LEFT the route prefix, RIGHT the short name of the switch WITHOUT "module.".
// Mixing those two up is a common mistake: if the right-hand value does not match
// a real switch, the module counts as ALWAYS on.
routeModules: { 'acme-shipping': 'acme-shipping' },
// Lazy loaders, not direct imports. Otherwise every language of every plugin ends up
// in the entry bundle.
localeLoaders: {
de: () => import('./locales/de'),
en: () => import('./locales/en'),
},
}
export default manifest
plugins/AcmeShipping/frontend/locales/en.ts
export default {
nav: {
links: {
acmeShipments: 'Shipments',
},
},
acmeShipping: {
title: 'Shipments',
empty: 'No shipment recorded yet.',
},
}
plugins/AcmeShipping/frontend/views/ShipmentListView.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/sdk'
const { t } = useI18n()
const shipments = ref<Array<{ ulid: string; trackingCode: string; status: string }>>([])
onMounted(async () => {
const response = await api.get('/v1/acme-shipping/shipments')
shipments.value = response.data.member ?? []
})
</script>
<template>
<div>
<h1>{{ t('acmeShipping.title') }}</h1>
<p v-if="shipments.length === 0">{{ t('acmeShipping.empty') }}</p>
<ul v-else>
<li v-for="s in shipments" :key="s.ulid">
{{ s.trackingCode }} — {{ s.status }}
</li>
</ul>
</div>
</template>
Core building blocks come exclusively through @/sdk. Deeper imports are rejected at build time, because they break at the next refactor.
Then rebuild the interface:
bin/console octibiz:plugin:rebuild-spa
Step 14: Testing
PHP tests do not live in the plugin. A test under plugins/AcmeShipping/tests/ is never executed, because the test configuration does not include it.
It belongs in tests/Api/AcmeShipping/ShipmentSecurityTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Api\AcmeShipping;
use App\Tests\Api\ApiTestCase;
final class ShipmentSecurityTest extends ApiTestCase
{
public function testNoAccessWithoutPermission(): void
{
$user = $this->seedUser([]);
$client = $this->clientFor($user);
$client->request('GET', '/api/v1/acme-shipping/shipments');
self::assertResponseStatusCodeSame(403);
}
public function testVisibleWithPermission(): void
{
$user = $this->seedUser(['acme.shipping.view']);
$client = $this->clientFor($user);
$client->request('GET', '/api/v1/acme-shipping/shipments');
self::assertResponseIsSuccessful();
}
}
That is the whole file. No setUp(), no preparation step, no trait.
make test-db
vendor/bin/phpunit tests/Api/AcmeShipping
Why make test-db is not optional
A fresh test database has no switch rows. Because the plugin gate is fail-closed, all plugin modules would count as switched off and every route would answer with 404. A test asserting "no access" would still pass — it would simply have checked nothing.
That is why app:test:prepare-schema, behind make test-db, creates the switches. And ApiTestCase verifies once per process that they are actually there. If they are missing, the run fails with instructions instead of silently returning 404.
You benefit from this without doing anything: your module switch is declared in your StandardDataProvider from step 10, and that is exactly where the preparation reads it from.
The special case: tests without the HTTP layer
If your test does not extend ApiTestCase but KernelTestCase directly — everything under tests/Functional/ — that check does not apply. There you seed yourself, right after booting the kernel:
use App\Tests\Support\Plugin\SeedsPluginModuleFlagsTrait;
final class ShipmentServiceTest extends KernelTestCase
{
use SeedsPluginModuleFlagsTrait;
public function testSomething(): void
{
self::bootKernel();
$this->seedPluginModuleFlags();
// from here on your module counts as switched on
}
}
What you have now
plugins/AcmeShipping/
├── composer.json
├── plugin.json
├── CHANGELOG.md
├── src/
│ ├── AcmeShippingPlugin.php
│ ├── AcmeShippingDescriptor.php
│ ├── ApiResource/ShipmentResource.php
│ ├── Catalog/AcmeShippingPermissionProvider.php
│ ├── Catalog/AcmeShippingStandardDataProvider.php
│ ├── Entity/Shipment.php
│ ├── Migrations/Version20260909120000.php
│ ├── Security/AcmeShippingVoter.php
│ ├── State/ShipmentProvider.php
│ └── Resources/icon.svg
└── frontend/
├── plugin.manifest.ts
├── locales/de.ts, en.ts
└── views/ShipmentListView.vue
And without any action on your part, this also produced:
- A tool for AI access. Every API operation becomes one automatically, module gate included.
- An entry in the OpenAPI description.
- A row in the plugin inventory of the interface.
When it does not work
| What you see | What is going on |
|---|---|
| All routes 404 although installed | Module switch off. octibiz:plugin:activate acme.shipping |
| 404 even after switching on | Segment missing from getRouteSegments() |
| 403 instead of data | The user lacks the permission, or app:auth:sync-permissions did not run |
| Table does not exist | Migration not run, or parent:: forgotten in prependExtension() |
| Class not found | composer dump-autoload forgotten |
| Interface route is blank | octibiz:plugin:rebuild-spa forgotten |
| Test run aborts: "test database is not prepared" | run make test-db |
Working without a checkout
The contract surface is available as separate packages, in both cases as a development dependency only:
{
"require-dev": { "octibiz/plugin-sdk": "~3.15.0" },
"devDependencies": { "@octibiz/plugin-sdk": "~3.15.0" }
}
That gives you autocompletion, static analysis and standalone unit tests. What is not included are entities, API resources and the services behind them; for integration tests you need a checkout.
Next
- Guidelines — what you have to comply with, as a checklist
- Backend — the full extension surface
- Interface — attachment points in other people's views
- Lifecycle — install, update, remove
- Distribution — shipping it