Octibiz
Demo

Durchsucht Website und Dokumentation gemeinsam. Enter zeigt alle Treffer, Esc schließt.

Dein erstes Plugin

Ein kompletter Durchlauf. Am Ende hast du ein Plugin namens AcmeShipping, das eine eigene Tabelle mitbringt, sie über /api/v1/acme-shipping/shipments ausliefert, ein eigenes Recht dafür prüft und eine Seite in der Oberfläche hat.

Jede Datei steht hier vollständig. Nichts wird angedeutet, nichts ausgelassen. Wenn du von oben nach unten durchgehst, läuft es am Ende.

Dauer: etwa eine Stunde beim ersten Mal.

Was du brauchst

Einen Entwicklungs-Checkout mit laufender Datenbank. Alle Befehle laufen im Projektverzeichnis.

Falls du keinen Checkout hast, geht das Backend auch eigenständig; das steht am Ende unter Ohne Checkout arbeiten.


Schritt 1: Gerüst erzeugen

bin/console octibiz:plugin:create AcmeShipping \
  --namespace "Acme\Shipping" \
  --label "Acme Versand"

Der Name ist PascalCase mit eigenem Prefix. Daraus leitet sich alles ab:

Aus AcmeShipping wirdWert
NamensraumAcme\Shipping
Plugin-Schlüsselacme.shipping
Modul-Schaltermodule.acme-shipping
API-Segment/api/v1/acme-shipping
Rechte-Prefixacme.shipping.*

Der Prefix ist Pflicht und kein Stil: Er verhindert, dass zwei Plugins denselben Schlüssel oder dasselbe Adress-Segment beanspruchen.

Du bekommst plugins/AcmeShipping/ mit einem lauffähigen Skelett. Die folgenden Schritte ersetzen die erzeugten Beispieldateien durch echte.


Schritt 2: Registrieren

Zwei Einträge im Host. Ohne sie existiert dein Plugin für die Anwendung nicht.

composer.json der Instanz, im Abschnitt autoload:

{
  "autoload": {
    "psr-4": {
      "App\\": "src/",
      "Acme\\Shipping\\": "plugins/AcmeShipping/src/"
    }
  }
}
composer dump-autoload

config/plugins.php:

<?php

return [
    Acme\Shipping\AcmeShippingPlugin::class => ['all' => true],
];

Schritt 3: Das Bundle

plugins/AcmeShipping/src/AcmeShippingPlugin.php

<?php

declare(strict_types=1);

namespace Acme\Shipping;

use App\Plugin\AbstractPlugin;

final class AcmeShippingPlugin extends AbstractPlugin
{
}

Das ist vollständig. Ein leerer Rumpf genügt, und das ist der Normalfall.

AbstractPlugin erledigt alles Weitere: Dienste unter src/ finden und verdrahten, src/Entity/ als Datenmodell anmelden, src/Migrations/ als Migrationspfad anmelden, templates/ als Vorlagen-Namensraum. src/ApiResource/ findet die API-Schicht von selbst.

Es gibt kein services.yaml in einem Plugin. Kein einziges der ausgelieferten Plugins hat eines.


Schritt 4: Der Descriptor

Die einzige Pflicht-Implementierung. Sie beantwortet sieben Fragen über dein 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
{
    /** Massgebliche Quelle der Version. Dieselbe Nummer steht in composer.json,
     *  plugin.json und im obersten Abschnitt des CHANGELOG. */
    public const VERSION = '0.1.0';

    public function getPluginKey(): string
    {
        return 'acme.shipping';
    }

    public function getDisplayName(): string
    {
        return 'Acme Versand';
    }

    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';
    }

    /**
     * JEDES neue API-Praefix gehoert hier hinein.
     *
     * Fehlt eines, greift fuer dieses Segment keine Modul-Sperre: Die Route bleibt
     * erreichbar, auch wenn das Modul ausgeschaltet ist.
     */
    public function getRouteSegments(): array
    {
        return ['acme-shipping'];
    }
}

Registriert wird das nirgends. PluginDescriptorInterface trägt ein Kennzeichen, das die Implementierung von selbst einsammelt. Implementieren genügt — dieses Prinzip gilt für alle Verträge in diesem System.


Schritt 5: Das Recht

Bevor es Daten gibt, gibt es das Recht darauf. Es kommt aus einem Vertrag, nie aus einer 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}> Recht => [Modul, Beschreibung]
     */
    public function getPermissions(): array
    {
        return [
            'acme.shipping.view' => ['Acme Versand', 'Sendungen sehen'],
            'acme.shipping.manage' => ['Acme Versand', 'Sendungen verwalten'],
        ];
    }

    /**
     * Rollen ADDITIV erweitern. Ein Plugin definiert keine Systemrolle um.
     *
     * @return array<string, list<string>>
     */
    public function getRoleGrants(): array
    {
        return [
            'PM' => ['acme.shipping.view', 'acme.shipping.manage'],
            'Support' => ['acme.shipping.view'],
        ];
    }

    /**
     * MUSS der Plugin-Schluessel sein.
     *
     * Er ist das Bindeglied zur Eigentums-Erfassung: Beim Entfernen raeumt das System
     * genau die Katalogzeilen weg, die diesen Schluessel tragen. Ein abweichender Wert
     * hinterlaesst Waisen.
     */
    public function getProviderKey(): string
    {
        return 'acme.shipping';
    }
}

Schritt 6: Die 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 als Zeichenkette, nicht als Aufzaehlungstyp. Die Katalogzeile kommt
     *  aus dem StandardDataProvider in Schritt 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;
    }
}

Vier geteilte Merkmale, die du nicht selbst bauen musst:

MerkmalWas es beisteuert
HasUlidÖffentliche Kennung. Der API-Bezeichner ist immer die ULID, nie die interne Zahl
TimestampablecreatedAt, updatedAt
SoftDeletabledeletedAt statt echtem Löschen
BrandScopedbrandId für den Marken-Schnitt

Der Tabellenname trägt ein fachliches Präfix, kein Hersteller-Präfix: acme_shipment, nicht acme_plugin_shipment.


Schritt 7: Die 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: Tabelle acme_shipment anlegen.';
    }

    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
    {
        // Kein Rueckwaertsschritt auf einer laufenden Datenbank.
    }
}

Nur Schema, keine Inhalte. Kein einziges INSERT. Standarddaten, Rechte und Nachschlagewerte kommen aus den Verträgen. Eine Migration, die Zeilen einfügt, erzeugt eine zweite Wahrheit, die beim nächsten Abgleich auseinanderläuft.


Schritt 8: Die API-Ressource

Sie ist eine eigene Klasse, nicht die Entity. Das trennt, was nach außen sichtbar ist, von dem, was in der Datenbank steht.

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',
    ) {
    }
}

Der Unterschied zwischen den beiden Zeilen ist der wichtigste Absatz dieser Anleitung:

security: "is_granted('acme.shipping.view')"          // Sammlung: OHNE Objekt
security: "is_granted('acme.shipping.view', object)"  // Einzeln:  MIT Objekt

Die Sammel-Prüfung beantwortet der PermissionVoter des Kerns aus dem gemeinsamen Katalog.

Die Einzel-Prüfung beantwortet er nicht. Er enthält sich bewusst, sobald ein Objekt übergeben wird. Ohne diese Zurückhaltung würde das Katalog-Recht jede Objektregel überstimmen, weil in Symfonys Strategie eine einzige Zustimmung genügt.

Die Folge: Ohne eigenen Voter prüft bei einer Einzel-Operation niemand. Dafür ist Schritt 11 da.


Schritt 9: Der State-Provider

Er liefert die Daten für beide Operationen.

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 ergibt 404. Das gefundene Objekt wird zum `object` der Item-Pruefung.
        return $shipment ? $this->toResource($shipment) : null;
    }

    private function toResource(Shipment $shipment): ShipmentResource
    {
        return new ShipmentResource(
            ulid: $shipment->getUlid(),
            trackingCode: $shipment->getTrackingCode(),
            status: $shipment->getStatus(),
        );
    }
}

Ein Repository brauchst du nicht. Nur 13 von 372 Entities im ganzen System haben eines; der Normalfall ist der EntityManagerInterface direkt.

Der Konstruktor wird automatisch befüllt. Kein Eintrag, keine Konfiguration.


Schritt 10: Standarddaten

Der Status open braucht eine Katalogzeile, sonst zeigt die Oberfläche einen rohen Schlüssel.

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',
            ],
        ];
    }

    /**
     * Das Plugin liefert seinen eigenen Modul-Schalter mit.
     *
     * Ohne diesen Eintrag existiert die Zeile nach der Installation nicht, und weil die
     * Sperre fail-closed ist, antworten ALLE Routen mit 404.
     *
     * @return list<array{name: string, enabled: bool, description: string|null}>
     */
    public function getFeatureFlags(): array
    {
        return [
            ['name' => 'module.acme-shipping', 'enabled' => true, 'description' => 'Acme Versand'],
        ];
    }

    public function getProviderKey(): string
    {
        return 'acme.shipping';
    }
}

Schritt 11: Der Voter

Ohne ihn ist jede Einzel-Operation ein Kandidat für fremden Datenzugriff über eine geratene Kennung.

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;
        }

        // Hier gehoeren die Objektregeln hin: Marken-Zugehoerigkeit, Eigentuemerschaft, Status.
        return $this->permissionChecker->hasPermission($user, self::VIEW);
    }
}

Schritt 12: Installieren und einschalten

Jetzt zum ersten Mal ausführen. Die Reihenfolge ist wichtig.

bin/console doctrine:migrations:migrate --no-interaction
bin/console octibiz:plugin:install acme.shipping --activate

Der Installations-Befehl führt keine Migrationen aus. Er erfasst das Katalog-Eigentum und legt den Modul-Schalter an, und zwar deaktiviert. Ohne --activate bleibt alles bei 404.

Prüfen:

bin/console octibiz:plugin:list

Du solltest deine Zeile mit Version, Vertrauensstufe und Aktiv-Zustand sehen.

Und der erste echte Abruf:

curl -H "Authorization: Bearer <token>" \
     https://<instanz>/api/v1/acme-shipping/shipments

Erwartung: eine leere Sammlung, kein Fehler. Wenn stattdessen 404 kommt, ist der Modul-Schalter aus. Wenn 403 kommt, fehlt dem Nutzer acme.shipping.view.


Schritt 13: Die Oberfläche

plugins/AcmeShipping/frontend/plugin.manifest.ts

import type { OctibizPluginManifest } from '@/plugins/manifest'

const manifest: OctibizPluginManifest = {
  // Pfade OHNE fuehrenden Schraegstrich. Komponenten IMMER als Nachlader.
  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',
      },
    },
  ],

  // LINKS das Routen-Praefix, RECHTS der Kurzname des Schalters OHNE "module.".
  // Die beiden zu verwechseln ist ein haeufiger Fehler: Trifft der rechte Wert
  // keinen echten Schalter, gilt das Modul IMMER als eingeschaltet.
  routeModules: { 'acme-shipping': 'acme-shipping' },

  // Nachlader, kein direkter Verweis. Sonst landet jede Sprache jedes Plugins
  // im Einstiegspaket.
  localeLoaders: {
    de: () => import('./locales/de'),
    en: () => import('./locales/en'),
  },
}

export default manifest

plugins/AcmeShipping/frontend/locales/de.ts

export default {
  nav: {
    links: {
      acmeShipments: 'Sendungen',
    },
  },
  acmeShipping: {
    title: 'Sendungen',
    empty: 'Noch keine Sendung erfasst.',
  },
}

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 antwort = await api.get('/v1/acme-shipping/shipments')
  shipments.value = antwort.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>

Kern-Bausteine kommen ausschließlich über @/sdk. Tiefere Verweise werden beim Bau abgewiesen, weil sie beim nächsten Umbau brechen.

Danach die Oberfläche neu bauen:

bin/console octibiz:plugin:rebuild-spa

Schritt 14: Testen

PHP-Tests liegen nicht im Plugin. Ein Test unter plugins/AcmeShipping/tests/ wird nie ausgeführt, weil die Testkonfiguration ihn nicht einbindet.

Er gehört nach 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 testOhneRechtKeinZugriff(): void
    {
        $nutzer = $this->seedUser([]);
        $client = $this->clientFor($nutzer);

        $client->request('GET', '/api/v1/acme-shipping/shipments');

        self::assertResponseStatusCodeSame(403);
    }

    public function testMitRechtSichtbar(): void
    {
        $nutzer = $this->seedUser(['acme.shipping.view']);
        $client = $this->clientFor($nutzer);

        $client->request('GET', '/api/v1/acme-shipping/shipments');

        self::assertResponseIsSuccessful();
    }
}

Das ist die ganze Datei. Kein setUp(), kein Aufbau-Schritt, kein Merkmal.

make test-db
vendor/bin/phpunit tests/Api/AcmeShipping

Warum make test-db nicht optional ist

Eine frische Testdatenbank hat keine Schalter-Zeilen. Weil die Sperre für Plugins fail-closed ist, gölten alle Plugin-Module als ausgeschaltet, und jede Route antwortete mit 404. Ein Test, der auf „kein Zugriff" prüft, wäre trotzdem grün — er hätte nur nichts geprüft.

Deshalb legt app:test:prepare-schema, hinter make test-db, die Schalter an. Und ApiTestCase prüft einmal je Prozess nach, ob sie tatsächlich dastehen. Fehlen sie, bricht der Lauf mit einer Anleitung ab, statt still 404 zu liefern.

Du profitierst davon, ohne etwas zu tun: Dein Modul-Schalter steht in deinem StandardDataProvider aus Schritt 10, und genau von dort liest die Vorbereitung ihn.

Der Sonderfall: Tests ohne HTTP-Schicht

Erbt dein Test nicht von ApiTestCase, sondern direkt von KernelTestCase — also alles unter tests/Functional/ —, greift diese Prüfung nicht. Dort seedest du selbst, direkt nach dem Hochfahren des Kerns:

use App\Tests\Support\Plugin\SeedsPluginModuleFlagsTrait;

final class ShipmentServiceTest extends KernelTestCase
{
    use SeedsPluginModuleFlagsTrait;

    public function testEtwas(): void
    {
        self::bootKernel();
        $this->seedPluginModuleFlags();

        // ab hier gilt dein Modul als eingeschaltet
    }
}

Was du jetzt hast

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

Und ohne dein Zutun ist daraus außerdem entstanden:

  • Ein Werkzeug für den KI-Zugang. Jede API-Operation wird automatisch eines, samt Modul-Sperre.
  • Ein Eintrag in der OpenAPI-Beschreibung.
  • Eine Zeile im Plugin-Inventar der Oberfläche.

Wenn es nicht läuft

Was du siehstWas los ist
Alle Routen 404, obwohl installiertModul-Schalter aus. octibiz:plugin:activate acme.shipping
404 auch nach dem EinschaltenSegment fehlt in getRouteSegments()
403 statt DatenDem Nutzer fehlt das Recht, oder app:auth:sync-permissions lief nicht
Tabelle existiert nichtMigration nicht gefahren, oder parent:: in prependExtension() vergessen
Klasse nicht gefundencomposer dump-autoload vergessen
Route der Oberfläche leeroctibiz:plugin:rebuild-spa vergessen
Testlauf bricht ab: „Test-Datenbank ist nicht vorbereitet"make test-db nachziehen

Ohne Checkout arbeiten

Die Vertragsfläche gibt es als eigene Pakete, jeweils nur als Entwicklungs-Abhängigkeit:

{
  "require-dev": { "octibiz/plugin-sdk": "~3.15.0" },
  "devDependencies": { "@octibiz/plugin-sdk": "~3.15.0" }
}

Damit laufen Autovervollständigung, statische Analyse und eigenständige Unit-Tests. Nicht darin enthalten sind Entities, API-Ressourcen und die Dienste dahinter; für Integrationstests brauchst du einen Checkout.


Weiter

plugin.tutorial · Gilt ab Version 0.6.22