Purpose: This document defines coding standards, best practices, and preferred solutions that AI assistants (like GitHub Copilot, ChatGPT, etc.) should follow when generating code for this Shopware 6 project.
- Development Environment
- General Principles
- Shopware-Specific Rules
- PHP Standards
- Service & Dependency Injection
- Commands
- Event Handling
- Database & Migrations
- Frontend/Twig
- Testing
This project uses Docker Compose for local development. Critical: The Docker container ports are dynamically assigned and change when containers are restarted.
- Configuration file:
.env.local(not.env) - Database port: Check with
docker ps- the MySQL container maps to a random host port - Connection string format:
mysql://root:root@127.0.0.1:PORT/shopware?serverVersion=8.0
- Don't assume containers are stopped - they are likely running with a different port
- Check current port:
docker ps --format "table {{.Names}}\t{{.Ports}}" - Update
.env.localwith the correct port from the0.0.0.0:XXXXX->3306/tcpmapping - Clear cache after updating:
bin/console cache:clear
# Check running containers and ports
docker ps --format "table {{.Names}}\t{{.Ports}}"
# Output shows:
# shopware-tutorial-olli-database-1 0.0.0.0:50170->3306/tcp
# ^^^^^
# This is the host port to use
# Update .env.local:
DATABASE_URL=mysql://root:root@127.0.0.1:50170/shopware?serverVersion=8.0- Always use strict types: Every PHP file must start with
<?php declare(strict_types=1); - Type everything: Use type hints for all parameters and return types
- Follow PSR-12: Adhere to PSR-12 coding standards
- Document intentionally: Add PHPDoc blocks for complex methods, but avoid obvious comments
- Fail fast: Use early returns and guard clauses
- Log appropriately: Use the logger service for debugging and error tracking
<?php declare(strict_types=1);
namespace Learning\Bundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class MyCommand extends Command
{
public function __construct()
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName('my-plugin:my-command')
->setDescription('Description of what the command does')
->addOption('option-name', 'o', InputOption::VALUE_OPTIONAL, 'Option description');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Command logic here
return Command::SUCCESS;
}
}// DON'T DO THIS - This is deprecated
protected static $defaultName = 'my-plugin:my-command';Rationale: The $defaultName static property is deprecated in Symfony/Shopware. Always use setName() in the configure() method instead.
<service id="MyPlugin\Service\MyService">
<argument type="service" id="logger"/>
<argument type="service" id="product.repository"/>
<argument type="string">%kernel.project_dir%</argument>
</service>
<service id="MyPlugin\Command\MyCommand">
<argument type="service" id="MyPlugin\Service\MyService"/>
<tag name="console.command"/>
</service>Key Points:
- Always specify full class names with namespace
- Use
type="service"for injected services - Use
type="string"for scalar values - Add appropriate tags (
console.command,kernel.event_subscriber, etc.)
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
class MyService
{
private EntityRepository $productRepository;
public function __construct(EntityRepository $productRepository)
{
$this->productRepository = $productRepository;
}
}services.xml:
<service id="MyPlugin\Service\MyService">
<argument type="service" id="product.repository"/>
</service>// Don't use generic interface - use EntityRepository
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;<?php declare(strict_types=1);
namespace MyPlugin\Service;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
/**
* Brief class description if needed
*/
class MyService
{
private LoggerInterface $logger;
private string $projectDir;
public function __construct(
LoggerInterface $logger,
string $projectDir
) {
$this->logger = $logger;
$this->projectDir = $projectDir;
}
public function doSomething(string $input): array
{
// Implementation
}
private function helperMethod(): void
{
// Implementation
}
}Key Points:
- Constructor first
- Public methods before private
- Properties before methods
- Group related methods together
public function processOrder(string $orderId): bool
{
try {
// Processing logic
$this->logger->info('Order processed', ['order_id' => $orderId]);
return true;
} catch (\Throwable $e) {
$this->logger->error('Order processing failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return false;
}
}Key Points:
- Catch
\Throwablenot\Exception(catches both exceptions and errors) - Always log errors with context
- Return meaningful values or re-throw if appropriate
<?php declare(strict_types=1);
namespace MyPlugin\Service\Decorator;
use MyPlugin\Service\MyServiceInterface;
class MyDecorator implements MyServiceInterface
{
private MyServiceInterface $decoratedService;
public function __construct(MyServiceInterface $decoratedService)
{
$this->decoratedService = $decoratedService;
}
public function doSomething(): string
{
$result = $this->decoratedService->doSomething();
// Add decoration logic
return $result . ' - decorated';
}
}services.xml:
<service id="MyPlugin\Service\Decorator\MyDecorator"
decorates="MyPlugin\Service\MyService"
decoration-priority="100">
<argument type="service" id=".inner"/>
</service>Key Points:
- Higher priority number = executed first
- Always inject via interface
- Use
.innerto reference decorated service
<?php declare(strict_types=1);
namespace MyPlugin\Subscriber;
use Shopware\Storefront\Page\Product\ProductPageLoadedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Psr\Log\LoggerInterface;
class ProductSubscriber implements EventSubscriberInterface
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
public static function getSubscribedEvents(): array
{
return [
ProductPageLoadedEvent::class => [
['onProductPageLoaded', 100], // Higher priority
['enrichProductData', 50], // Lower priority
],
];
}
public function onProductPageLoaded(ProductPageLoadedEvent $event): void
{
$product = $event->getPage()->getProduct();
$this->logger->info('Product page loaded', [
'product_id' => $product->getId(),
'product_name' => $product->getName(),
]);
}
public function enrichProductData(ProductPageLoadedEvent $event): void
{
// Additional logic
}
}services.xml:
<service id="MyPlugin\Subscriber\ProductSubscriber">
<argument type="service" id="logger"/>
<tag name="kernel.event_subscriber"/>
</service><?php declare(strict_types=1);
namespace MyPlugin\Event;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\Event\ShopwareEvent;
use Symfony\Contracts\EventDispatcher\Event;
class CustomEvent extends Event implements ShopwareEvent
{
private string $entityId;
private Context $context;
private array $metadata;
public function __construct(
string $entityId,
Context $context,
array $metadata = []
) {
$this->entityId = $entityId;
$this->context = $context;
$this->metadata = $metadata;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function getContext(): Context
{
return $this->context;
}
public function getMetadata(): array
{
return $this->metadata;
}
public function setMetadata(array $metadata): void
{
$this->metadata = $metadata;
}
}Key Points:
- Extend
Eventand implementShopwareEvent - Provide getters for all important data
- Include Context if the event relates to data operations
- Make events immutable where possible (except for metadata enrichment)
<?php declare(strict_types=1);
namespace MyPlugin\Core\Content\MyEntity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
class MyEntityDefinition extends EntityDefinition
{
public const ENTITY_NAME = 'my_entity';
public function getEntityName(): string
{
return self::ENTITY_NAME;
}
protected function defineFields(): FieldCollection
{
return new FieldCollection([
(new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
(new StringField('name', 'name'))->addFlags(new Required()),
]);
}
}<?php declare(strict_types=1);
namespace MyPlugin\Migration;
use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\MigrationStep;
class Migration1700000001CreateMyEntityTable extends MigrationStep
{
public function getCreationTimestamp(): int
{
return 1700000001;
}
public function update(Connection $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `my_entity` (
`id` BINARY(16) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$connection->executeStatement($sql);
}
public function updateDestructive(Connection $connection): void
{
// Only include if needed for destructive operations
}
}Key Points:
- Use
BINARY(16)for IDs - Use
DATETIME(3)for timestamps (millisecond precision) - Use
utf8mb4_unicode_cicollation - Add appropriate indexes
- Use
IF NOT EXISTSfor safety
{% sw_extends '@Storefront/storefront/page/product-detail/index.html.twig' %}
{% block page_product_detail_content %}
{{ parent() }}
<div class="my-custom-content">
{# Custom content #}
</div>
{% endblock %}Key Points:
- Use
{% sw_extends %}not{% extends %} - Always call
{{ parent() }}to preserve original content - Use descriptive block names from Shopware core
- Keep custom classes prefixed with plugin name
<?php declare(strict_types=1);
namespace MyPlugin\Tests\Unit\Service;
use MyPlugin\Service\MyService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class MyServiceTest extends TestCase
{
public function testDoSomething(): void
{
// Arrange
$logger = $this->createMock(LoggerInterface::class);
$service = new MyService($logger);
// Act
$result = $service->doSomething('input');
// Assert
static::assertIsArray($result);
static::assertNotEmpty($result);
}
}Key Points:
- Use
TestCasefrom PHPUnit - Follow Arrange-Act-Assert pattern
- Use
static::assert*()methods - Mock dependencies appropriately
- Test one thing per test method
// Deprecated command name definition
protected static $defaultName = 'my:command';
// Deprecated repository interface
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
// Missing strict types
<?php
namespace MyPlugin;// No try-catch, no logging
public function riskyOperation(): void
{
$this->doSomethingDangerous();
}// Bad naming
$d = $this->getData();
$arr = [];When generating code, always verify:
-
declare(strict_types=1)at the top of every PHP file - All parameters and return types are type-hinted
- Commands use
setName()inconfigure(), not$defaultName - Services are properly registered in
services.xml - Error handling includes try-catch with logging
- Event subscribers are tagged with
kernel.event_subscriber - Migrations use proper SQL with
BINARY(16)for IDs - Twig templates use
{% sw_extends %}and{{ parent() }} - Tests follow Arrange-Act-Assert pattern
- Code follows PSR-12 standards
- Shopware Version: 6.5+
- PHP Version: 8.1+
- Standards: PSR-4, PSR-12, PSR-3
This guide should be updated whenever:
- New patterns are established in the project
- Shopware releases breaking changes
- Team discovers better practices
- Common mistakes are identified
Last Updated: 2024-11-27