Verified on Shopware 6.7
The Problem Nobody Talks About
Every Shopware 6 project starts with 2–3 plugins. Fast forward 18 months and you have 30+ plugins, some depending on each other, some decorating the same service, and a developer who left six months ago is the only person who knew why AcmeHelperPlugin exists.
We run a production Shopware 6 project with 33 custom plugins. This article documents the architecture that keeps it maintainable - the real categories, the real patterns, and the real mistakes we made along the way.
The Resources Plugin in Depth
The Resources plugin is where the architecture lives or dies. Here is what ours actually contains.
Shared Services
<!- services.xml - all public so other plugins can inject by ID ->
<service id="Acme\Resources\Service\AcmeApiTokenService" public="true">
<argument type="service" id="request_stack"/>
<argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
<argument type="service" id="event_dispatcher"/>
</service>
<service id="Acme\Resources\Service\EntityService" public="true">
<argument type="service" id="service_container"/>
<!- intentional service locator - documented anti-pattern for Twig usage ->
</service>
<service id="Acme\Resources\Service\SalesChannelService" public="true"/>
<service id="Acme\Resources\Service\CustomerService" public="true"/>
API Token Service
Any project that talks to an external API needs centralized token management. Ours handles OAuth2 with session caching:
class AcmeApiTokenService
{
public const TOKEN_SESSION_KEY = 'acme-api-token-data-key';
public function getToken(bool $ignoreCache = false, ?SalesChannelContext $context = null): ?string
{
if (!$ignoreCache && $this->validateTokenCache()) {
return $this->getCachedTokenData()['value'];
}
return $this->reloadTokenCache($context);
}
private function validateTokenCache(): bool
{
$data = $this->getCachedTokenData();
if (!$data) return false;
$elapsed = time() - $data['timestamp'];
$maxDuration = $this->config->get('AcmeResources.config.tokenCachingInterval');
return $elapsed < $maxDuration;
}
private function fetchTokenFromRemote(?SalesChannelContext $context): array
{
try {
// HTTP call to OAuth2 endpoint
return $this->httpClient->post($this->getTokenUrl(), [...]);
} catch (\Throwable $e) {
// Dispatch to Flow system - admin can set up email alerts
$this->eventDispatcher->dispatch(
new AcmeTokenFetchFailedEvent($e->getMessage(), $context)
);
// Graceful degradation: return fake token so the storefront doesn't crash
return ['access_token' => 'fallback-token', 'expires_in' => 3600];
}
}
}
The fallback token pattern is intentional. If your API is down, you want the storefront to stay up and log the failure - not throw a 500 to the customer.
Decorators
The Resources plugin owns framework-level decorators. The most important one intercepts order creation:
class CheckoutControllerDecorator extends CheckoutController
{
public function order(RequestDataBag $data, SalesChannelContext $context, Request $request): Response
{
$event = new AcmeOrderPreCreateEvent($data, $context, $request);
$this->eventDispatcher->dispatch($event);
// Any listener can call $event->stopPropagation() to block order creation
if ($event->isPropagationStopped()) {
return $this->createActionResponse($request);
}
return parent::order($data, $context, $request);
}
}
<!- decorators.xml ->
<service id="Acme\Resources\Decorator\CheckoutControllerDecorator"
decorates="Shopware\Storefront\Controller\CheckoutController">
<argument type="service" id="Acme\Resources\Decorator\CheckoutControllerDecorator.inner"/>
<argument type="service" id="event_dispatcher"/>
</service>
Now any plugin can react to or block order creation without knowing about any other plugin:
// In AcmeVoucherPayment - adjusts voucher before order goes through
class OrderPreCreateSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [AcmeOrderPreCreateEvent::class => 'onPreCreate'];
}
public function onPreCreate(AcmeOrderPreCreateEvent $event): void
{
$result = $this->voucherApi->adjust($event->getContext());
if (!$result->isSuccess()) {
$event->stopPropagation(); // Block the order
}
}
}
Inter-Plugin Communication
The rule: plugins must not call each other directly. No new OtherPluginService(), no $container->get('other.plugin.service') from feature plugins.
The two permitted channels:
1. Events
Events registered in the Resources plugin. Any plugin can dispatch, any plugin can listen.
Resources plugin owns:
AcmeOrderPreCreateEvent → dispatched by CheckoutControllerDecorator
AcmeErrorEvent → dispatched by any plugin on failure
AcmeTokenFetchFailedEvent → dispatched by AcmeApiTokenService
Feature plugins own their own events but register them via Resources:
AcmeVoucherFetchFailedEvent → dispatched by VoucherApiService
AcmeVoucherAdjustFailedEvent → dispatched by VoucherApiService
GoGiftPurchaseFailedEvent → dispatched by GoGiftMiddlewareService
Business events implement Shopware's Flow interfaces so admins can wire up automated actions without code:
class AcmeTokenFetchFailedEvent extends Event implements
SalesChannelAware,
MailAware,
CustomerAware,
FlowEventAware,
ScalarValuesAware
{
public const EVENT_NAME = 'acme.token.fetch.failed';
public function getScalarValues(): array
{
return [
'errorCode' => $this->errorCode,
'errorMessage' => $this->errorMessage,
];
}
}
With this, an admin can create a Flow rule: "When token fetch fails → Send Slack notification" - zero code.
2. Shared Services from Resources
Feature plugins inject Resources services via DI:
<!- In AcmeCustomerVoucher/DependencyInjection/services.xml ->
<service id="Acme\CustomerVoucher\Service\VoucherApiService">
<!- Inject from Resources - not from another feature plugin ->
<argument type="service" id="Acme\Resources\Service\AcmeApiTokenService"/>
<argument type="service" id="Acme\CustomerVoucher\Service\SessionCacheService"/>
</service>
If plugin A needs data from plugin B's domain, it either: (a) listens to an event that B dispatches, or (b) the shared data moves to the Resources plugin.
Decorator Chains at Scale
With 30+ plugins you will have multiple plugins decorating the same Shopware service. Manage it explicitly.
<!- Plugin with higher priority executes first (outer decorator) ->
<service id="Acme\Search\Decorator\ProductSearchBuilderDecorator"
decorates="Shopware\Core\Content\Product\SearchKeyword\ProductSearchBuilder"
decoration-priority="200">
<argument type="service"
id="Acme\Search\Decorator\ProductSearchBuilderDecorator.inner"/>
</service>
<service id="Acme\OtherPlugin\Decorator\ProductSearchBuilderDecorator"
decorates="Shopware\Core\Content\Product\SearchKeyword\ProductSearchBuilder"
decoration-priority="100">
<argument type="service"
id="Acme\OtherPlugin\Decorator\ProductSearchBuilderDecorator.inner"/>
</service>
Keep a decorator registry. One markdown table per project:
| Service Decorated | Plugin | Priority |
|-----------------------------------------|---------------------------------|----------|
| CheckoutController | AcmeResources | default |
| ProductSearchBuilder | AcmeSearch | 200 |
| CheckoutGatewayRoute | AcmePaymentSwitchNotification | 150 |
| PaymentMethodValidator | AcmePaymentSwitchNotification | 100 |
| SuggestPageLoader | AcmeSearch | default |
When a new developer decorates a service that's already decorated, they check this table. No surprises.
Entity Extension Pattern
When you need extra data on a core entity (Product, OrderLineItem), extend it via DAL - don't fork core definitions.
// Adds many-to-many: Product <-> CustomType
class ProductCustomTypeAssignedDefinition extends EntityDefinition
{
public function getEntityName(): string
{
return 'product_custom_type_assigned';
}
protected function defineFields(): FieldCollection
{
return new FieldCollection([
(new IdField('id', 'id'))->addFlags(new PrimaryKey(), new Required()),
new FkField('product_id', 'productId', ProductDefinition::class),
new FkField('product_custom_type_id', 'productCustomTypeId', ProductCustomTypeDefinition::class),
new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
new ManyToOneAssociationField('customType', 'product_custom_type_id', ProductCustomTypeDefinition::class),
]);
}
}
// Add the relation back to the Product entity without touching Shopware's ProductDefinition
class ProductExtensionDefinition extends EntityExtension
{
public function extendFields(FieldCollection $collection): void
{
$collection->add(
new ManyToManyAssociationField(
'customTypes',
ProductCustomTypeDefinition::class,
ProductCustomTypeAssignedDefinition::class,
'product_id',
'product_custom_type_id'
)
);
}
public function getDefinitionClass(): string
{
return ProductDefinition::class;
}
}
Now product.customTypes is available everywhere in the DAL - in criteria, in Twig, in API responses - without a single line changed in core.
Session Caching for External API Calls
If your plugins talk to external APIs (voucher balance, token endpoints, gift card APIs), cache aggressively in the session. One uncached API call per page load becomes 50 API calls per session.
class SessionCacheService
{
private const CACHE_KEY = 'acme-voucher-balance-data-key';
public function getCachedBalance(): ?array
{
$session = $this->requestStack->getSession();
$data = $session->get(self::CACHE_KEY);
if (!$data) return null;
$elapsed = time() - $data['timestamp'];
if ($elapsed > $data['maxDuration']) {
$session->remove(self::CACHE_KEY);
return null;
}
return $data;
}
public function setCachedBalance(array $balance, int $ttl): void
{
$this->requestStack->getSession()->set(self::CACHE_KEY, [
'value' => $balance,
'timestamp' => time(),
'maxDuration' => $ttl,
]);
}
}
Session-based caching is per-user and auto-invalidates on logout. For balance data or token data, this is exactly what you want.
Naming and Structure Conventions
Consistency at 30+ plugins is what separates maintainable from unmanageable.
Plugin naming:
AcmeResources # Foundation - one
AcmeCustomerVoucher # Feature
AcmeWishlist # Feature
AcmeProductCustomTypes # Feature
AcmeVoucherPayment # Payment
AcmeFinancePayment # Payment
AcmeGoGiftGiftCards # Integration
AcmeCBProductSlider # CMS Block (CB prefix)
AcmeCBFourImageGrid # CMS Block
AcmeTheme # Theme
AcmeStripePayment # Wrapper
AcmeAdminCacheNotification # Admin tooling
Database table prefix: acme_ for every custom entity. One query on information_schema tells you every table your plugins own.
DI file split: don't put everything in one services.xml:
DependencyInjection/
├── services.xml # business services
├── subscribers.xml # event subscribers
├── decorators.xml # service decorators
├── data-resolver.xml # CMS resolvers
└── payment-methods.xml
Dependency Graph (Keep It Updated)
Shopware Core
└── AcmeResources (Foundation)
├── AcmeCustomerVoucher
│ └── AcmeVoucherPayment (listens to voucher events)
├── AcmeGoGiftGiftCards
├── AcmeCustomerSession
├── AcmeWishlist
├── AcmeProductCustomTypes
│ └── AcmeSearch (decorates search, filters by custom type)
├── AcmeVoucherPayment
├── AcmeFinancePayment
├── AcmePaymentSwitchNotification
├── AcmeCBProductSlider (independent, uses DAL only)
├── AcmeTheme (no PHP deps)
└── AcmeAdminCacheNotification (independent)
Third-party
├── StripeShopwarePayment
│ └── AcmeStripePayment (wrapper)
└── IwvTwoFactorAuthentication
└── AcmeTwoFactorAuth (wrapper)
The rule for this graph: arrows point down. If you ever draw an arrow that points sideways between two feature plugins, stop and design an event instead.
When to Split vs. Merge
From 30+ plugins worth of experience:
Split when:
- The feature can be toggled per environment (dev/staging/prod)
- Different clients in your pipeline might need one but not the other
- The plugin has evolved into two or more completely different functionalities
- A third-party plugin exists for the job (wrap it, don't fork it)
Merge when:
- Splitting them requires an event just to pass a single string
- One plugin's only purpose is providing data to exactly one other plugin
Summary
- One Resources plugin. Foundation for everything. No business logic.
- Plugins don't call each other. Events for communication, shared services from Resources for utilities.
- Events live in Resources, even if dispatched by feature plugins. That's the registry.
- Decorators get a registry. One table, one place, every plugin checks it.
- DAL extensions, not core modifications. Products and order line items get extra fields cleanly.
- Wrapper plugins for third-party code. Never patch, always decorate.
- Document intentional anti-patterns. The service locator in Twig is real, necessary, and should be explained in the file.
Extending Third-Party Plugins
When a third-party plugin gets you 80% there but needs to behave differently in your project, the options depend on what you need to change.
Twig template overrides
The least invasive approach. Create a template at the same relative path inside your plugin's Resources/views directory. Shopware's template inheritance picks it up automatically - your plugin's template takes priority over the third-party one.
StripeShopwarePayment/Resources/views/storefront/component/payment/payment-method.html.twig
AcmeStripeShopwarePayment/Resources/views/storefront/component/payment/payment-method.html.twig ← wins
If you need most of the original output intact, use {% sw_extends %} and override only the blocks that need to change. If the original template structure gets in the way entirely, skip the extends and render from scratch.
Service decoration
When you need to change how a service behaves - not just how it looks - decorate it. Symfony's decoration pattern wraps the original service without touching its class:
<service id="Acme\StripeShopwarePayment\Decorator\StripePaymentHandlerDecorator">
<argument type="service" id="Acme\StripeShopwarePayment\Decorator\StripePaymentHandlerDecorator.inner"/>
<tag name="shopware.payment.method.sync" decorates="Stripe\ShopwarePayment\Handler\SyncPaymentHandler"/>
</service>
Your decorator receives the original service as a constructor argument, calls through to it for the parts you want to preserve, and replaces only what needs to change. The original service is untouched.
Event subscribers
If the third-party plugin dispatches events, subscribe to them. This is the cleanest extension point when it exists - no decoration, no template overrides, no coupling to internal implementation details.
For Stripe specifically, Shopware's payment lifecycle events (PaymentMethodHandler, OrderTransactionStateHandler) fire at well-defined points and can be intercepted without touching any Stripe class directly.
Dependency declaration
Your wrapper plugin must declare the third-party plugin as a composer dependency. This guarantees load order - the original plugin boots first, your extension second, so template and service priorities resolve correctly:
{
"require": {
"stripe/shopware-payment": "^1.16.0"
}
}
Without this, Shopware may load your plugin first and the overrides will silently fail.
What not to do
Do not copy files from the original plugin into yours and modify them. The moment the original plugin updates, your copy is stale and the divergence is invisible. Decorate, override, subscribe - always from outside.
Never raise the original plugin's template priority or modify its composer.json. Both create hidden coupling that breaks when the plugin is updated.
If you find yourself replicating large parts of the original plugin's logic just to change one behavior, it is usually a sign that the extension point is wrong - look for an event, a service interface, or a more targeted template block.
Running a complex Shopware 6 plugin ecosystem? We've untangled 30+ plugin projects. Talk to us.