Octibiz
Demo

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

Guidelines

Every binding rule in one place. Each line states the rule and what happens if you break it — because nearly all of these failures are silent. They throw no exception, they simply do not do what you expect.

The complete walkthrough shows every one of these rules in use.

How to read this page

Three levels:

  • Hard — the build, a guard or the gate aborts. You cannot get past it.
  • Silent — nothing breaks, but the result is wrong. These are the expensive ones.
  • Convention — not enforced, but consistent across the codebase. Deviating costs questions.

Naming and identity

RuleLevelConsequence of breaking it
Plugin key as <vendor>.<name>, lower case, dottedConventionCollision with another plugin
getPluginKey() in the descriptor, name in plugin.json and extra.octibiz.pluginKey in composer.json are identicalHardThe installer aborts
getModuleFlag() is module.<hyphenated-name> and reads the same in plugin.jsonHardThe installer aborts
getProviderKey() in every provider is the plugin keySilentCatalogue orphans are left behind on removal
Address segments carry your prefix and claim no core segmentHardAbort before any side effect
Permissions are <plugin>.<object>.<verb>, usually view and manageConvention
Tables carry a domain prefix, not a vendor one: acme_shipment, not acme_plugin_shipmentConvention
The version is identical in AcmeShippingDescriptor::VERSION, composer.json, plugin.json and the top CHANGELOG.md sectionHardA checker trips

The descriptor

RuleLevelConsequence of breaking it
PluginDescriptorInterface is the only mandatory implementationHardWithout it the plugin does not exist
Every new API prefix is listed in getRouteSegments()SilentNo module gate applies to that segment — the route stays reachable although the module is off
Every coupling to another plugin is listed in getDependencies()SilentThe dry run before removal wrongly reports "no dependants"
spiVersion in plugin.json names the contract surface you built againstHardIf missing, installation proceeds with a warning; a wrong major version aborts

The bundle

RuleLevelConsequence of breaking it
No services.yaml in a pluginConventionNot one of the shipped plugins has one
If you override prependExtension(), call parent::SilentEntity registration, migration path and templates fall away silently. Invisible on an existing developer database because the tables are already there — only a fresh install creates none at all
A core service is replaced through serviceAliases(), not #[AsAlias]SilentThe core configuration wins the merge, the replacement has no effect
A replaced core service checks at entry whether its plugin is activeSilentIt keeps working although the module is off. Toggling does not rebuild the container

Data model

RuleLevelConsequence of breaking it
Integer primary key plus HasUlid; the API identifier is always the ULIDSilentInternal counts leak outward; guessable identifiers
Timestampable and, where it makes sense, SoftDeletableConvention
BrandScoped for documents and transactions, not for master dataConvention
Money always in cents as an integerConventionRounding errors
Statuses are data in a string column, not an enum in codeConventionThe status can never be configured
No foreign keys across plugin boundariesSilentRemoving one plugin tears the other apart
Never write raw into another plugin's tableSilentThis actually happened: after removal with data deletion the project list answered every call with a server error

Migrations

RuleLevelConsequence of breaking it
Strictly additive, no backward step on a live databaseConventionData loss on rollback
Schema only, not a single INSERTSilentA second truth that drifts apart at the next reconciliation
isTransactional(): bool { return false; }ConventionMatches the codebase
Own namespace Acme\Shipping\MigrationsHardOtherwise not recognised as a migration path

A plugin that only migrates and ships no contracts has empty tables after installation.

API and permissions

RuleLevelConsequence of breaking it
API resources are classes of their own in src/ApiResource/, not the entityConventionInternal fields become externally visible
Collection operation: is_granted('permission') without an objectConvention
Item operation: is_granted('permission', object) and a voter of your ownSilentNobody checks. The core PermissionVoter abstains when an object is passed. Every such operation is a candidate for reading someone else's data through a guessed identifier
In services never call the generic check with a brand-scoped object; ask your own voter directlySilentThe same trap
Brand-scoped lists are cut by the permission, not by membershipSilentThe most common real security pattern in the codebase: a member of a second brand without a permission there still saw its data
Every collection filter is declared as a query parameterSilentIt works, but appears in neither the OpenAPI description nor the tool catalogue. An AI would have to guess it
Permissions come from PermissionProviderInterface, never from a migrationSilentThey are not swept up on removal
getRoleGrants() extends roles additivelyConventionA plugin does not redefine a system role
Public endpoints via publicApiPathPatterns() need a verification of their ownSilentAn open door. It disables the login requirement, not the check
Callback receivers are plain controllers, not API resourcesConventionThey would otherwise show up as tools for AI access

Contracts

Implementing is enough. There is no registration, no manual tag, no configuration file.

RuleLevelConsequence of breaking it
A plugin ships its own module switch through getFeatureFlags()SilentThe row is missing after installation; the gate is fail-closed, all routes answer with 404
A plugin with its own domain table ships a demo contributionSilentA freshly set-up demo shows an empty state at that spot
Every new column with personal data gets a contribution to disclosure and erasureSilentThe row survives a completed erasure. No error, no warning, just data that should be gone
Own rows in core tables are swept by UninstallDataParticipantInterface on removalSilentOrphans in the core
A listener on a foreign event exits early when its plugin is inactiveSilentThe plugin keeps working although it is switched off
A veto listener only reads — no writing, no persistingSilentSome of these events run in the middle of a save

The naming rule for events is reliable: an event ending in -ing runs before and can be rejected; one in the past participle already happened and is a fact.

Automation

RuleLevelConsequence of breaking it
A trigger implements TriggerProviderInterface and TriggerCatalogProviderInterfaceSilentIt works but cannot be selected in the editor
getType() returns the class's own constant, not OtherClass::TYPEHardThe interface catalogue guard reads the source and knows only a literal or self::…
execute() honours $context->isDryRunSilentThe dry run changes data
Actions with required fields implement AutomationActionConfigValidatorInterfaceSilentAn incomplete configuration only surfaces at run time, and then at the recipient
Labels follow automation.trigger.<lowerCamel> and automation.action.<lowerCamel>SilentThe interface shows the raw key
Actions write no audit entry of their ownConventionDuplicate per field change; the change is recorded anyway

Interface

RuleLevelConsequence of breaking it
Core building blocks exclusively through @/sdkHardDeeper imports are rejected at build time
Route paths without a leading slashSilentThe route mounts in the wrong place
Components always lazily loaded (() => import(...))ConventionEverything lands in the entry bundle
localeLoaders are lazy loaders, not direct importsSilentEvery language of every plugin lands in the entry bundle
In routeModules the left side is the route prefix, the right side the switch short name without module.SilentIf the right-hand value matches no real switch, the module counts as always on
Backend translations live centrally, only interface translations in the pluginHardOtherwise they are not loaded

Tests

RuleLevelConsequence of breaking it
PHP tests live centrally under tests/, never under plugins/<Name>/tests/HardAn architecture guard holds this. A test the runner does not find is not a test
Interface tests do live in the plugin, under frontend/**/__tests__/Convention
Run make test-db before testingHardApiTestCase aborts with instructions instead of silently returning 404
A test extending KernelTestCase seeds the switches itself via SeedsPluginModuleFlagsTraitSilentAll plugin routes 404, and a test on an error case passes anyway
Every brand-scoped list gets a test on the brand cut that also checks the countSilentHiding a sum while returning the count still discloses the foreign data
make gate is green before anything is called "done"HardThe gate is the authoritative answer

The acceptance list

Before shipping, in this order:

Checked?What
The version is identical everywhere: descriptor, composer.json, plugin.json, CHANGELOG.md
Every API prefix is listed in getRouteSegments()
The module switch comes from your own StandardDataProvider
Migrations are additive only and contain no INSERT
Permissions, master data and lookups come from the contracts
Every item operation has a voter of its own
Every brand-scoped list is cut by the permission
Every collection filter is declared as a parameter
A demo contribution exists if there is an own domain table
Every column with personal data has a contribution to disclosure and erasure
Couplings to other plugins are listed in getDependencies()
prependExtension() calls parent:: if overridden
The generator leftovers are gone
make gate is green

Next

plugin.guidelines · Available from version 0.6.22