Small Business: Technical details
Sylius plugin Small Business Β§19 UStG: architecture, plugin structure, tables and migrations, admin routes and hooks, credit memo compiler pass and replaceable services.
Small Business: Technical details
Live Demo: Try this and other plugins live β no installation, no risk: demo.markus-michalski.net
Architecture
graph TD
A["KleinunternehmerSettings (singleton)"] --> B["NoticeApplicabilityChecker"]
C["ZeroTaxCategoryProvider"] --> D["ProductPriceMigrationService"]
D --> E["MigratedProductVariant (tracking table)"]
B --> F["KleinunternehmerExtension (Twig)"]
B --> G["ShopNoticeNormalizer (Shop API)"]
F --> H["Twig hooks: storefront"]
F --> I["credit_memo.html.twig (refund plugin)"]
J["Admin: settings page"] --> A
J --> D
K["Console: migrate-prices"] --> D
One class decides whether the notice applies: NoticeApplicabilityChecker. Storefront Twig, Shop API and credit memo PDF all ask it, so the three paths always agree.
Plugin structure
src/
βββ Command/ # MigrateProductPricesCommand, SetupCommand
βββ Controller/Admin/ # KleinunternehmerSettingsController, MigrateProductPricesAction
βββ DependencyInjection/ # Extension, Configuration, Compiler/CreditMemoPdfTemplatePass
βββ Entity/ # KleinunternehmerSettings, MigratedProductVariant
βββ Form/Type/ # KleinunternehmerSettingsType
βββ Menu/ # AdminMenuListener, MenuGroup
βββ Migrations/ # three Doctrine migrations
βββ Provider/ # ZeroTaxCategoryProvider
βββ Repository/ # repositories for both entities
βββ Serializer/ # ShopNoticeNormalizer
βββ Service/ # NoticeApplicabilityChecker, ProductPriceMigrationService
βββ Twig/ # KleinunternehmerExtension
config/
βββ routes/ # admin.yaml (shop.yaml is empty)
βββ services/ # service definitions
βββ twig_hooks/ # admin_settings.yaml, shop_product_prices.yaml
templates/
βββ admin/kleinunternehmer/ # settings page, migration card
βββ shop/ # notice templates (paragraph and table row)
βββ refund/ # credit_memo.html.twig
translations/ # messages and flashes, German and English
The plugin defines no configurable keys (Configuration is empty) and reads no environment variables.
Database
erDiagram
mmd_kleinunternehmer_settings {
integer id PK
boolean active
text noticeTextDe
text noticeTextEn
}
mmd_kleinunternehmer_migrated_variant {
integer id PK
string productVariantCode UK
string originalTaxCategoryCode
datetime_immutable migratedAt
}
| Table | Purpose |
|---|---|
mmd_kleinunternehmer_settings |
Exactly one row with id 1: the switch and the optional notice texts (at most 1000 characters each). Without a row the plugin returns defaults without saving them |
mmd_kleinunternehmer_migrated_variant |
One row per currently migrated variant with its original tax category, so that --restore puts the right category back |
The entities are plain Doctrine entities with PHP attributes, not Sylius resources. That is why there is no grid and no CRUD routes.
Three migrations under the namespace Mmd\SyliusKleinunternehmerPlugin\Migrations:
| Version | Content |
|---|---|
Version20260828121602 |
Table mmd_kleinunternehmer_settings |
Version20260828132209 |
Table mmd_kleinunternehmer_migrated_variant with a unique index on productVariantCode |
Version20260829160426 |
Columns noticeTextDe and noticeTextEn |
The migrations use Doctrine's Schema API instead of raw SQL so they do not depend on one database platform. The namespace must stay unique in the host application.
How the bundle hooks in
- Doctrine mapping and migrations: The extension registers the ORM mapping for
src/Entity/and the migrations inprepend(). - Twig hooks: The extension also loads the YAML files from
config/twig_hooks/itself inprepend(). It could not do so inload(), because Symfony hands each extension an empty container builder there in whichsylius_twig_hooksis never registered. - Credit memo template: The compiler pass
CreditMemoPdfTemplatePassswaps, in the servicesylius_refund.generator.credit_memo_pdf_file, the template@SyliusRefundPlugin/download/credit_memo.html.twig(argument 2) for@MmdSyliusKleinunternehmerPlugin/refund/credit_memo.html.twig. The pass does nothing if the refund plugin is missing or the generator already uses another template. It is registered inMmdSyliusKleinunternehmerPlugin::build(). - Admin menu:
AdminMenuListeneradds the entry under the shared group "MMKreativ Plugins".MenuGroupis deliberately copied into every MMKreativ plugin, only the group codemmd_pluginshas to match. Whichever runs first creates the group. - Admin routes: Both paths build on the parameter
sylius_admin.path_nameinstead of a fixed/admin, so they stay inside the firewall if you change the admin path.
Admin hooks
The settings page renders under the hook prefix sylius_admin.mmd_kleinunternehmer_settings.index and inherits its basic frame (sidebar, navbar, header) from sylius_admin.common.index. The hookables in config/twig_hooks/admin_settings.yaml are activation_error (priority 110), form (100), additional_languages_hint (75) and migration (50) in the content area, as well as title (100) and actions (0) in the title block.
Routes
| Route | Path | Methods |
|---|---|---|
mmd_sylius_kleinunternehmer_admin_settings |
/<admin>/kleinunternehmer/settings |
GET, POST |
mmd_sylius_kleinunternehmer_admin_migrate |
/<admin>/kleinunternehmer/migrate |
POST |
Tax category and zone
The constants for category and tax rate are kleinunternehmer_zero and kleinunternehmer_zero_rate. The zone DE is hard-coded. This is a deliberate simplification, the zone is not derived dynamically.
Extensibility
The plugin defines no events, service tags or interfaces of its own for third parties. Through normal Symfony service decoration you can replace three public services:
| Interface | Default implementation | Task |
|---|---|---|
ZeroTaxCategoryProviderInterface |
ZeroTaxCategoryProvider |
Create the 0% category and tax rate |
ProductPriceMigrationServiceInterface |
ProductPriceMigrationService |
Migrate and restore prices |
NoticeApplicabilityCheckerInterface |
NoticeApplicabilityChecker |
Decides whether the notice applies |
The methods at a glance:
| Interface | Methods |
|---|---|
NoticeApplicabilityCheckerInterface |
applies(?order, ?variant, ?product), appliesToTaxTotal(int) |
ProductPriceMigrationServiceInterface |
migrateToSmallBusiness(?callable), restoreFromSmallBusiness(?callable), countPendingMigration(), countMigrated() |
ZeroTaxCategoryProviderInterface |
getOrCreate() |
In addition, KleinunternehmerSettingsRepository::getSettings() returns the settings record. If the row is missing, it returns an unsaved default so that a storefront page view never writes.
If you replace NoticeApplicabilityChecker, your decision applies equally to storefront, Shop API and credit memo PDF. Otherwise the plugin hooks in without anything special: AdminMenuListener listens to the Sylius event sylius.menu.admin.main, and ShopNoticeNormalizer is a regular serializer normalizer with priority 60.
Version display
Below the title of the settings page, the installed version is shown (kleinunternehmer_plugin_version(), reads Composer\InstalledVersions). For a tagged release this is the version number, for a local path installation dev-main plus the first seven characters of the commit hash.