Shopware plugin development sounds like a big project, but at its core it is surprisingly small. A working plugin that shows a configurable delivery notice on every product page takes us exactly six files and 95 lines. The merchant can switch it on and off in the admin, change the text per sales channel, and uninstalling it leaves nothing behind in the database. A first plugin needs nothing more.
The hard part is somewhere else. Shopware ships a generator that builds a plugin skeleton in seconds. That skeleton, however, is a collection of examples, not a starting point. On 25 October 2026 we set up a fresh test store running Shopware 6.6.8.2, tried the generator, built the plugin by hand, and then installed, updated, deactivated and uninstalled it. Along the way we hit five traps you won’t find in most guides: an infinite loop in the generator, a Store API route that answers with HTTP 500, two plugins that block each other, a template override that silently does nothing, and a switch that saves “off” but stays on.
This guide shows the path that worked for us. It is written for developers who know PHP and want to extend Shopware for the first time, and for store owners who want to understand what their agency is actually building. Where Shopware 6.7 behaves differently from 6.6, we say so, based on the 6.7.15.1 source code rather than memory.
Video: Shopware Knowledge DE, “Your first Shopware 6 Plugin within an hour!” (January 2022). An official Shopware workshop. It is older, but the basics of subscribers, services and templates still hold. Some template paths have changed since, see below.Shopware plugin development: what a plugin actually is
Before you create the first file, one sentence of theory is worth it, because it explains many later errors. A Shopware plugin is a Symfony bundle with some extra metadata. Shopware itself is a Symfony application. A plugin hooks into exactly the same mechanisms the core uses: the service container, the event dispatcher, routing and Twig templates.
That means two things. First, a plugin can change almost anything Shopware does. Second, it can break almost anything too. A plugin doesn’t run in a sandbox, it runs with the same privileges as the core. If your plugin throws an exception while a product page loads, the product page is broken, not just your plugin.
Plugin or app: the decision before the first line
Shopware has two kinds of extensions, and the choice belongs at the very beginning:
| Plugin | App | |
|---|---|---|
| Runs where? | Inside the Shopware server, as PHP code | On your own server, Shopware calls it via webhooks |
| Works in Shopware Cloud (SaaS)? | No | Yes |
| Can run its own PHP code in the store? | Yes, anything | No, only scripts in a sandbox |
| Own database tables? | Yes, via migrations | Only via custom entities |
| Typical use cases | Deep changes, checkout logic, performance-critical code | Integrations with third-party services, marketing, ERP bridges |
| Learning curve | Medium (PHP, Symfony) | Low to medium (any language plus a manifest) |
Rule of thumb: if the store is self-hosted and you need the logic inside it, a plugin is the direct route. If you build for Shopware Cloud or your code runs on your own server anyway, build an app. Anyone building a product for the Shopware Store that should run in both worlds often ends up with apps today. We described the differences between cloud and self-hosted in more detail in our article on Shopware Cloud.
This guide is about classic plugins for self-hosted stores.

What you need before you start
You need a local Shopware installation you are allowed to break. Never develop on the live store. It sounds obvious, but it is the most common cause of emergency calls: a plugin gets “quickly” edited on the server, the cache is cleared, and the home page shows a stack trace.
For our test we used dockware’s developer box, a Docker image with Shopware, a database, demo data and a mail catcher preinstalled. The container was ready after about a minute:
docker run -d --name shopware-dev -p 127.0.0.1:8888:80 dockware/dev:6.6.8.2
Inside the container were PHP 8.3.13 and Shopware 6.6.8.2 in dev mode. If you prefer working without Docker, our Shopware installation with real measurements covers the Composer route and every pitfall we hit.
You also need:
- PHP skills at the level of classes, namespaces and types. You don’t need to be a Symfony expert, but you should know what a constructor is.
- Composer basics, because every plugin has a
composer.json. - An editor with PHP support. PhpStorm with the Symfony plugin, or VS Code with Intelephense. The Shopware core has around 5,600 PHP files (5,655 files and 382 MB under
vendor/shopwarein our container), so autocompletion helps enormously. - Git. A plugin is code and belongs in a repository. Our list of essential Git commands is enough to get started.
Which Shopware version you target depends on what you build for. At the time of writing, 6.7.15.1 was the latest release (published 2 October 2026, PHP 8.2 to 8.5). Many stores still run 6.6, though. Develop for the version the target store runs, and test against the new version on purpose before an update.
The generator: plugin:create put to the test
Shopware ships a command that creates a plugin skeleton for you. Almost every guide shows it first. We tried it, and it gave us two surprises right away.
Trap 1: the infinite loop without a namespace
Our first attempt was the obvious one: pass only the plugin name and switch off prompts with --no-interaction, as you would in a script.
bin/console plugin:create GmLieferzeitHinweis --no-interaction
The result was not a plugin but the same message, “Answer cannot be empty”, printed 32,586 times until PHP crashed with a segmentation fault after 4.3 seconds. The reason is in the 6.6 code: the command asks for the namespace, gets an empty answer in non-interactive mode, prints an error and calls itself again. Forever, until memory runs out.
This is fixed in Shopware 6.7. Since 6.7.9.0, the command aborts cleanly without interactive input and tells you that either interactive mode or the argument is required. The fix works the same in both versions: always pass both name and namespace as arguments.
bin/console plugin:create GmLieferzeitHinweis GmLieferzeitHinweis --no-interaction
With that, the command finished in 0.2 seconds.
Trap 2: you get everything, whether you want it or not
We only wanted an event subscriber and a scheduled task and set the matching options. What we got were 23 files with 536 lines: a console command, a Store API route, a storefront controller, an admin module, a JavaScript plugin, custom fields, configuration and tests. A second run with no options at all produced exactly the same 23 files.
The reason: in non-interactive mode, Symfony answers every yes/no question with its default, and for every building block the default is “yes”. Options like --create-event-subscriber can only add something, never leave something out. Shopware 6.6 has no switch against it. Only since Shopware 6.7.13.0 is there a --no-scaffold option that creates just the required files.
Our advice: use the generator as a reference, not as a starting point. Generate a full skeleton once into a throwaway folder, read the examples, and build your real plugin by hand. The skeleton also contains things you don’t want in a store. The JavaScript example, for instance, opens an alert() box saying “Seems like there’s nothing more to see here.” for every visitor who scrolls to the bottom of a page.
Trap 3: the Store API route answers with 500
We installed the skeleton anyway to see what works. The console command dutifully printed “It works!”. The storefront controller served a page with “Hello world” at /example. The Store API route, however, answered with HTTP 500:
Controller "…\ExampleRoute" cannot be fetched from the container because it is private.
Did you forget to tag the service with "controller.service_arguments"?
The 6.6 generator registers the route as a private service, and Symfony isn’t allowed to fetch private services as controllers. The fix is a single attribute, public="true" in services.xml. The 6.7.15.1 generator now registers the route as public. But anyone who took over an older skeleton or copies examples from old tutorials trips right here.
Trap 4: two plugins from the same generator don’t get along
The fourth trap is the nastiest, because it only shows up at the client. Every skeleton gets the same hard-coded names, no matter what your plugin is called: the custom field set is swag_example_set, the field swag_example_size, the scheduled task swag.example_task, the admin module swag-example, the console command swag-commands:example. We counted 20 such swag occurrences in a single skeleton.
When we tried to install a second skeleton plugin, installation aborted with a database error:
Integrity constraint violation: 1062 Duplicate entry 'swag_example_size'
The second plugin tried to create a custom field the first one had already created. In agency life, where several plugins grow from the same skeleton, this isn’t theory. Rename every swag and example identifier before the plugin leaves your machine. Ideally with a prefix that belongs to you, such as your vendor abbreviation.
It gets worse. We uninstalled the skeleton plugin without keeping data. Afterwards the scheduled task was gone, but the custom field set swag_example_set was still in the database. The skeleton creates it on install but never removes it on uninstall; the uninstall() method only contains a comment at that spot. Reinstalling failed again with the same duplicate error, this time without any second plugin.
Building a plugin by hand: the delivery notice
Now the plugin we actually kept. It has a single goal: below the delivery time on every product page, it shows a notice such as “Order by 2 pm, ships the same business day.” The merchant can change the text in the admin and switch the notice off.
The structure looks like this:
custom/plugins/GmLieferhinweis/
├── composer.json
└── src/
├── GmLieferhinweis.php
├── Subscriber/
│ └── ProductPageSubscriber.php
└── Resources/
├── config/
│ ├── config.xml
│ └── services.xml
└── views/storefront/component/buy-widget/
└── buy-widget.html.twig
Six files, 95 lines. (“Lieferhinweis” is German for “delivery notice”; we kept the names from our test store so the measurements match.) Let’s go through them in order.
Step 1: composer.json
The composer.json is your plugin’s ID card. Shopware reads name, version, display label and, above all, which class is the plugin from it.
{
"name": "getmind/lieferhinweis",
"description": "Shows a configurable delivery notice on the product page",
"type": "shopware-platform-plugin",
"version": "1.0.0",
"license": "MIT",
"require": { "shopware/core": "~6.6.0" },
"extra": {
"shopware-plugin-class": "GetMind\\Lieferhinweis\\GmLieferhinweis",
"label": { "de-DE": "Lieferhinweis", "en-GB": "Delivery notice" }
},
"autoload": { "psr-4": { "GetMind\\Lieferhinweis\\": "src/" } }
}
Three fields matter. type must be exactly shopware-platform-plugin, otherwise Shopware won’t recognize the plugin. extra.shopware-plugin-class points to the plugin class with its full namespace and double backslashes. And require defines which Shopware versions your plugin may run on. ~6.6.0 means “6.6.anything, but not 6.7”. For a plugin meant to run on 6.6 and 6.7 you write ~6.6.0 || ~6.7.0, but only once you have actually tested against both.
The namespace (GetMind\Lieferhinweis) may differ from the folder name (GmLieferhinweis). We built it that way on purpose, because the generator always names both the same and many people assume they have to match.
Step 2: the plugin class
<?php declare(strict_types=1);
namespace GetMind\Lieferhinweis;
use Shopware\Core\Framework\Plugin;
class GmLieferhinweis extends Plugin
{
}
That is not a placeholder, it is the finished class. Our plugin doesn’t create tables, payment methods or custom fields, so it has nothing to do on install or uninstall. Shopware manages the configuration from config.xml on its own.
If your plugin creates data, you override install(), uninstall(), activate(), deactivate() and update() here. More on that in the lifecycle section.
Step 3: configuration in the admin
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/shopware/trunk/src/Core/System/SystemConfig/Schema/config.xsd">
<card>
<title>Lieferhinweis</title>
<title lang="en-GB">Delivery notice</title>
<input-field type="bool">
<name>active</name>
<label>Show notice</label>
<defaultValue>true</defaultValue>
</input-field>
<input-field type="text">
<name>text</name>
<label>Text</label>
<defaultValue>Order by 2 pm, ships the same business day.</defaultValue>
</input-field>
</card>
</config>
From this file Shopware automatically builds a settings page in the admin, with a toggle and a text field, optionally per sales channel. The values end up in the system_config table under the keys GmLieferhinweis.config.active and GmLieferhinweis.config.text. The prefix is the name of the plugin class, not the namespace.
Shopware writes the default values to the database on install. We checked: right after plugin:install, the table contained {"_value": true} and the default text.
Step 4: the event subscriber
This is where the actual work happens. When a product page loads, Shopware fires the ProductPageLoadedEvent. Our subscriber listens for it, reads the configuration and attaches the text to the page.
<?php declare(strict_types=1);
namespace GetMind\Lieferhinweis\Subscriber;
use Shopware\Core\Framework\Struct\ArrayStruct;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Shopware\Storefront\Page\Product\ProductPageLoadedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ProductPageSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly SystemConfigService $config)
{
}
public static function getSubscribedEvents(): array
{
return [ProductPageLoadedEvent::class => 'onProductPage'];
}
public function onProductPage(ProductPageLoadedEvent $event): void
{
$salesChannelId = $event->getSalesChannelContext()->getSalesChannelId();
if (!$this->config->getBool('GmLieferhinweis.config.active', $salesChannelId)) {
return;
}
$event->getPage()->addExtension('gmLieferhinweis', new ArrayStruct([
'text' => $this->config->getString('GmLieferhinweis.config.text', $salesChannelId),
]));
}
}
Two things matter here. First, we pass the sales channel ID. Without it, Shopware only reads the global value and ignores what the merchant set for a specific channel. Second, we attach the data to the page as an extension instead of setting some global variable. Extensions are the official way to add your own data to Shopware objects without touching the core. In the template they are available as page.extensions.gmLieferhinweis.
Step 5: registering the subscriber
For Symfony to know about the subscriber, it has to be in services.xml:
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="GetMind\Lieferhinweis\Subscriber\ProductPageSubscriber">
<argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
<tag name="kernel.event_subscriber"/>
</service>
</services>
</container>
The kernel.event_subscriber tag makes Symfony read getSubscribedEvents() and wire everything up. If the tag is missing, nothing happens, and without any error message. You can verify the wiring like this:
bin/console debug:event-dispatcher "Shopware\Storefront\Page\Product\ProductPageLoadedEvent"
For us it listed exactly one entry: our ProductPageSubscriber::onProductPage() with priority 0. This command is the fastest way to settle “my subscriber does nothing”.
Note on Shopware 6.7: the 6.7 generator no longer writes service definitions as XML but as a PHP file (services.php). XML keeps working; both are valid. If you build a plugin for 6.6 and 6.7, stick to the format your team knows.

Step 6: extending the template, the right one
Now the text should appear on the page. Shopware templates are split into blocks, and a plugin can extend any block without copying the whole file.
This is where we walked into the next trap, and it’s an instructive one. Many guides extend page/product-detail/buy-widget.html.twig and its block page_product_detail_delivery_informations. We did the same. The plugin was installed and active, the subscriber registered, the cache cleared. On the product page there was: nothing.
The explanation is in the first line of the core file:
{# @deprecated tag:v6.7.0 - File will be removed as it is no longer loaded by default. #}
In Shopware 6.6, product pages render through Shopping Experiences (CMS layouts) by default, and those use a different file: component/buy-widget/buy-widget.html.twig with the block buy_widget_delivery_informations. The old file is only loaded for product pages without a layout. In Shopware 6.7.15.1 it no longer exists at all; fetching it from GitHub returned 404.
The working template looks like this:
{% sw_extends '@Storefront/storefront/component/buy-widget/buy-widget.html.twig' %}
{% block buy_widget_delivery_informations %}
{{ parent() }}
{% if page.extensions.gmLieferhinweis %}
<p class="gm-lieferhinweis alert alert-info mt-2">
{{ page.extensions.gmLieferhinweis.text }}
</p>
{% endif %}
{% endblock %}
sw_extends instead of extends is mandatory. It lets several plugins extend the same file without overwriting each other. {{ parent() }} keeps the block’s original content, and our notice goes below it. Leave out parent() and your plugin replaces the delivery time display entirely. That can be intended, but it’s usually a mistake.
Twig also escapes the text automatically. If a merchant types HTML into the text field in the admin, it shows up as text, not as code. That’s exactly how it should be. If you add |raw so that bold text works, you open a hole for everyone who can access the plugin settings.
How do you find the right block? Not by guessing. Open the product page in the browser, find a CSS class near the spot in the page source (for us product-detail-delivery-information), then search vendor/shopware/storefront/Resources/views for that class. That gives you file and block. Then check the file’s first line for @deprecated.

Installing, activating, verifying
With the six files in custom/plugins/GmLieferhinweis, installation goes like this:
bin/console plugin:refresh
bin/console plugin:install --activate GmLieferhinweis
bin/console cache:clear
plugin:refresh makes Shopware rescan the plugin folder. Without it, Shopware doesn’t know your plugin exists. plugin:install --activate installs and activates in one go, 2.6 seconds for us. cache:clear took 3.1 seconds in dev mode.
After that, the product page contained:
<p class="gm-lieferhinweis alert alert-info mt-2">
Lieferung in 1–3 Werktagen
</p>
With the plugin active, the product page took a median of about 160 ms to first byte, measured over seven requests with a changing query parameter so no page cache kicked in. That was in dev mode, so slower than in production. A subscriber that reads two config values doesn’t register. That changes once your subscriber makes database queries or third-party requests on every page view.
Trap 5: a switch that saves “off” and stays on
This trap cost us the most time, and it doesn’t only affect plugin developers but anyone who configures Shopware from the command line or a script.
We wanted to test whether the notice disappears when switched off. So:
bin/console system:config:set GmLieferhinweis.config.active false
bin/console system:config:get GmLieferhinweis.config.active
# false
The output said “false”. The notice was still on the page. Even after clearing all cache pools. Even after cache:clear.
A look into the database explained it:
GmLieferhinweis.config.active {"_value": "false"}
"false" in quotes. The command hadn’t stored the boolean false but the word “false” as a string. And getBool() in the core does nothing more than (bool) $this->get($key). In PHP, every non-empty string is truthy, including “false”. The notice stayed on.
The correct way is:
bin/console system:config:set --json GmLieferhinweis.config.active false
With --json, the database held {"_value": false}, and the notice was gone immediately, without clearing any cache. getBool() is unchanged in Shopware 6.7.15.1, so the behavior applies there as well.
Two rules follow from this. For store operators: always set toggles on the command line with --json. That includes deploy scripts and Ansible roles that configure Shopware. For developers: don’t blindly trust that getBool() only ever sees real booleans. If a value can be set via import, API or script, validate it.

The plugin lifecycle: install, activate, update, uninstall
A Shopware plugin goes through five states, and there is a method in the plugin class for each. Mix them up and you build plugins that crash on the second install or lose data on uninstall.
| Command | Method | What belongs here |
|---|---|---|
plugin:install | install() | Create data the plugin needs: custom fields, payment methods, mail templates |
plugin:activate | activate() | Switch on things that already exist, such as setting a payment method active |
plugin:deactivate | deactivate() | The counterpart to activate, delete nothing |
plugin:update | update() | Adapt data to a new version, unless migrations handle it |
plugin:uninstall | uninstall() | Clean up, but only if the merchant doesn’t want to keep data |
What uninstalling really does
We uninstalled our plugin and deliberately didn’t pass --keep-user-data. Afterwards both config values were gone from system_config. On reinstall they came back with their defaults. So Shopware cleans up the configuration on its own.
Everything else is your job. In uninstall(), check whether the merchant wants to keep data, and only clean up if not:
public function uninstall(UninstallContext $uninstallContext): void
{
parent::uninstall($uninstallContext);
if ($uninstallContext->keepUserData()) {
return;
}
// Remove your own tables, custom fields, mail templates here
}
That’s exactly where the generator plugin from trap 4 only had a comment, and its custom field set stayed behind.
Updates need a new version number
We bumped the version in composer.json from 1.0.0 to 1.1.0. Afterwards, plugin:list still showed 1.0.0 with no hint of an update. Only after plugin:refresh did the “Upgrade version” column show 1.1.0. plugin:update GmLieferhinweis then took 0.5 seconds.
That order matters, and it’s behind a classic production problem: new code is copied to the server, the version isn’t bumped, and the store keeps serving old JavaScript and CSS because Shopware doesn’t know anything changed. Our rule: every deployment gets a new version number, even if only one line changed.
Deactivating takes effect immediately
Finally, we tested deactivation. After plugin:deactivate, the notice was gone on the next page view, even before we cleared the cache. After plugin:activate and cache:clear it was back. That’s reassuring: if a plugin causes trouble in a live store, deactivating it is the fastest emergency exit.
More building blocks: what else a plugin can do
Our delivery notice uses three building blocks: configuration, an event subscriber and a template. Most plugins get by with those three. Larger tasks need more, and the generator has examples for all of them:
Your own database tables come from an entity definition and a migration. Shopware has its own data abstraction layer (DAL) that brings permissions, translations, versioning and the admin API along automatically. bin/console dal:migration:create generates the matching migration from an entity definition. Budget much more time for this than for the rest; the DAL has its own learning curve. If you’re weighing database systems, our comparison of PostgreSQL vs MySQL helps, although Shopware itself only supports MySQL and MariaDB.
Console commands are regular Symfony commands. They are ideal for imports, maintenance jobs and anything that shouldn’t run through a web request.
Scheduled tasks run at fixed intervals, for example every night. They consist of two classes: the task itself and a handler that does the actual work. In 6.6, the generator skeleton creates only the task, no handler. After installation the task showed up in scheduled-task:list with a 300-second interval, and scheduled-task:run-single finished without errors. But the plugin folder contained no handler class at all, so nothing to do the actual work. A run without errors only proves the task was scheduled. For scheduled tasks to run at all, a message queue worker must be running on the server, typically as a systemd service or via a cron job.
Store API routes are your own endpoints for headless frontends and apps. As trap 3 showed, they need a public service. What an API is and how it differs from a web page is explained in What is an API?.
Admin modules extend the administration. They are written in JavaScript with Vue.js and have to be built before they appear in the admin. This is the most laborious part of plugin development, because you need two worlds at once: PHP for the data and Vue for the interface.
Storefront JavaScript also has to be built, via bin/build-storefront.sh or bin/console theme:compile. Without that step your JavaScript never reaches the store. In our test, that’s why the skeleton’s alert() example wasn’t in the delivered theme after installation. It would have been added automatically on the next theme build.
Shipping a plugin: ZIP, Composer or the Store
Once the plugin runs locally, it has to get to the live server. There are three ways.
As a ZIP file. Zip the plugin folder so the folder name is the top level. Our delivery notice was 5,964 bytes as a ZIP, 18 entries. Upload it in the admin under Extensions or on the command line with bin/console plugin:zip-import file.zip, which took 0.2 seconds for us. This is the way for single stores without a deployment process.
Via Composer. The plugin lives in a private Git repository and the store pulls it with composer require. This is the way for agencies and anyone who ships stores in a versioned way. The advantage: plugin version and store version sit together in composer.lock, and every deployment is reproducible.
Via the Shopware Store. If you want to sell your plugin, you submit it to Shopware for review. You need a Shopware Account, and the plugin is technically reviewed before publication. Allow a few days, and expect small things such as missing translations or unclean uninstall routines to come back.
Whichever route you take: afterwards run plugin:refresh, plugin:install --activate or plugin:update, and cache:clear on the server. If your plugin ships JavaScript or SCSS, add theme:compile. And please back up the database first. How to set that up sensibly is covered in our article on the 3-2-1 backup strategy.
What plugin development costs
A question store owners often ask us: is a custom plugin worth it, or is it better to buy one from the Store?
For a delivery notice like ours, the Store has ready-made solutions, some free, some on subscription. If one fits, it’s almost always cheaper than building your own, because the vendor handles updates. The real cost of a plugin doesn’t come from writing it but from what comes after. Shopware ships new versions frequently, six between 17 August and 2 October 2026 alone, and every bigger release can change templates or interfaces, as our buy widget shows.
A custom plugin pays off when:
- it covers a feature that sets your business apart and that you can’t buy as is,
- several Store plugins together would cost more and eat more load time than one lean custom solution,
- you want to bring data from your own systems (ERP, inventory) into the store.
For a small plugin like ours, expect a few hours of development and then a fixed maintenance effort with every major Shopware update. How plugin costs fit into the total cost of a store is worked out in Shopware 6 pricing and costs and What a Shopware store costs.
Checklist for your first Shopware plugin
Before your plugin leaves your machine, go through this list. Every item comes from a mistake we or our clients have already made.
- No
swagand noexampleleft in the code.grep -rn "swag\|example" src/must come back empty. composer.jsonis right: correcttype, correct plugin class,requireonly for versions you have tested against.- Template path checked: the core file you extend isn’t marked
@deprecated. sw_extendsinstead ofextends, andparent()wherever the original content should stay.- No
|rawon values a human can type in. - Subscriber registered:
debug:event-dispatchershows your entry. - Sales channel ID passed when reading configuration.
- Install, uninstall, reinstall works without errors, even without
--keep-user-data. - Version number bumped for every deployment.
- Tested on a copy of the live store, not just on the demo installation.
Frequently asked questions about Shopware plugin development
How do I create a Shopware 6 plugin?
The fastest way is bin/console plugin:create MyPlugin MyPlugin, followed by plugin:refresh and plugin:install --activate MyPlugin. Always pass both name and namespace, otherwise the command loops forever in Shopware 6.6. For a clean plugin we recommend creating only the required files (in 6.7.13.0 and later with --no-scaffold, in 6.6 by hand) and adding building blocks on purpose.
Which programming language do I need for Shopware plugins?
PHP for the logic, Twig for templates, XML or PHP for configuration and services. Admin extensions add JavaScript with Vue.js, and storefront styling adds SCSS. For a simple plugin like a delivery notice, PHP and Twig are enough.
Do I need to know Symfony to develop Shopware plugins?
Not beforehand, but you will learn it along the way. Shopware is built on Symfony, and terms like service container, event subscriber and bundle come straight from it. If you already know Symfony, you’ll find your way around Shopware much faster.
What is the difference between a Shopware plugin and an app?
A plugin is PHP code that runs inside the store and may change anything. An app runs on its own server and talks to the store via webhooks and the API. Plugins only work in self-hosted stores; apps also work in Shopware Cloud.
Where are Shopware plugins located in the file system?
Plugins you develop yourself live in custom/plugins/. Plugins installed via Composer live in vendor/. With the --static option, the generator creates plugins in custom/static-plugins/, which is meant for plugins that belong to the project and are included via Composer.
Why doesn’t my template change show up in the store?
Usually for one of three reasons: you are extending a deprecated file that is no longer loaded (for us page/product-detail/buy-widget.html.twig), the cache wasn’t cleared, or the plugin is installed but not activated. Check the first line of the core file for @deprecated first, then plugin:list, then cache:clear.
Why is my plugin setting “off” but still active?
If you set it with bin/console system:config:set ... false, the database contains the word “false” as a string, which is truthy in PHP. Always set booleans with --json. Toggles set through the admin are not affected.
How do I update a Shopware plugin?
Bump the version in composer.json, deploy the new code, run bin/console plugin:refresh and then bin/console plugin:update PluginName. Without plugin:refresh, Shopware doesn’t see the new version. Then run cache:clear and, if the plugin contains frontend code, theme:compile.
Does a plugin built for Shopware 6.6 also run on 6.7?
Not automatically. The composer.json has to allow 6.7, and you need to check that the templates, events and classes you use still exist in 6.7. Our 6.6 buy widget template path, for example, no longer exists in 6.7.15.1. Test against a real 6.7 installation before you widen the version constraint.
How long does it take to develop a Shopware plugin?
A small plugin like our delivery notice takes an experienced developer one to two hours, and more like a day the first time, because you first have to understand the structure. Plugins with their own tables, admin modules or integrations with third-party systems take days to weeks. In the long run, the bigger effort is maintenance across Shopware updates.
Can I sell a Shopware plugin?
Yes, through the Shopware Store. You need a Shopware Account, and the plugin goes through a technical review before publication. Alternatively, agencies sell plugins directly to clients and deliver them via Composer from a private repository.
Conclusion: Shopware plugin development is easy, doing it cleanly isn’t
The skeleton of a Shopware plugin is small. Six files, one subscriber, one template, one configuration, and you have a feature the merchant can control. Anyone who knows PHP can do that in an afternoon.
The work lies in the places where nothing happens and nothing is reported. A template that extends a deprecated file simply shows nothing. A subscriber without a tag simply doesn’t run. A switch that stores “false” as text simply stays on. A custom field that isn’t removed on uninstall only surfaces on the next install. None of these bugs throws an error that leads you to the cause.
So our advice: treat the generator as a textbook, build your plugin by hand and keep it small, verify every building block with the matching debug command, and run through the whole lifecycle once before the plugin goes into a real store. Install, change, update, deactivate, uninstall, reinstall. Whatever survives that will survive everyday use.
