Maintaining database integrity while evolving your Shopware plugins is a cornerstone of professional development. With the release of Shopware 6.7, the platform reinforces its commitment to type safety, architectural consistency, and developer ergonomics. While the fundamental migration mechanism remains rooted in Doctrine DBAL, Shopware 6.7 introduces stricter enforcement patterns, improved schema helpers, and refined context management that plugin developers must adopt to ensure compatibility and performance.

This guide provides a comprehensive walkthrough of creating robust migrations, leveraging Shopware 6.7 best practices, and handling complex data scenarios safely.

Understanding Migration Detection and Architecture

In Shopware, migrations are detected automatically based on the namespace structure within your plugin. Since version 6.7, it is critical to ensure your migration classes adhere to the strict naming and location conventions to prevent registration failures in distributed installations or multi-core setups.

Your migrations should reside in src/Migrations/ and must implement Shopware\Core\Framework\Migration\AbstractMigration. The class name should follow the timestamp convention (e.g., MyPlugin_20241001_AddCustomTable).

Namespace Configuration: Ensure your plugin.xml or composer autoload configuration registers the migration namespace. Shopware's migration registry scans these paths during installation and updates.

<autoload>
    <namespace name="Shopware6MyPlugin">
        <directory src="src/" />
    </namespace>
</autoload>

Step 1: Generating and Structuring Your Migration

Use the Shopware console to generate a migration boilerplate. This ensures proper dependency injection setup and class hierarchy.

bin/migration-generate MyPlugin_AddOrderExtensionTable

The generated class will look like this. Note the strict typing required in Shopware 6.7:

<?php declare(strict_types=1);

namespace Shopware6MyPlugin\Migrations;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use Shopware\Core\Framework\Migration\AbstractMigration;

final class MyPlugin_AddOrderExtensionTable extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Adds custom extension table for orders.';
    }

    public function up(Schema $schema, Connection $connection): void
    {
        // Implementation here
    }
}

Step 2: Implementing Safe Schema Migrations

In Shopware 6.7, idempotency is non-negotiable. Your up method must be safe to execute repeatedly without throwing errors if the schema already matches the desired state. Always check for table and column existence before altering structures.

Adding Tables with Foreign Keys

When defining relations to Shopware core entities, use the {core.entity_name} syntax. This abstraction allows Shopware to resolve internal renaming or namespace shifts transparently across minor versions.

public function up(Schema $schema, Connection $connection): void
{
    if (!$schema->hasTable('my_plugin_order_extension')) {
        $table = $schema->createTable('my_plugin_order_extension');

        // Shopware 6.7 recommends using GUID for primary keys to align with core standards
        $table->addColumn('id', Types::GUID);
        $table->addColumn('order_id', Types::STRING, ['length' => 36]);
        $table->addColumn('extension_payload', Types::TEXT, ['nullable' => true]);
        
        $table->addPrimaryKey(['id']);
        $table->addIndex(['order_id'], 'idx_my_plugin_order_ext_order');

        // Add Foreign Key using Core alias syntax
        $table->addForeignKeyConstraint(
            $schema->getTable('{core.order}'),
            ['order_id'],
            ['id'],
            ['onDelete' => 'CASCADE']
        );
    }
}

Step 3: Data Migrations and Context Awareness

Data migrations (DML) are often where plugins face the most challenges. In Shopware 6.7, you have access to the container via ContainerAwareInterface. This allows you to utilize the EntityManager for safe entity manipulation, which is preferred over raw SQL inserts as it respects lifecycle events and type conversions defined in your entity definitions.

Safe Data Migration Pattern

<?php declare(strict_types=1);

namespace Shopware6MyPlugin\Migrations;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Migration\AbstractMigration;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;

final class MyPlugin_MigrateVipCustomerStatus extends AbstractMigration implements ContainerAwareInterface
{
    use ContainerAwareTrait;

    public function getDescription(): string
    {
        return 'Migrates legacy VIP tags to new extension attribute.';
    }

    public function up(Schema $schema, Connection $connection): void
    {
        // 1. Load customers with legacy tag using EntityRepository
        $customerRepo = $this->container->get(EntityRepositoryInterface::class . '.customer');
        
        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('tags.name', 'legacy-vip'));
        
        // Use search() to get entities directly for writing back
        $customers = $customerRepo->search($criteria)->getEntities();

        if ($customers->count() === 0) {
            return;
        }

        // 2. Prepare extension data repository
        // Assuming you defined a custom entity definition 'my_plugin_vip_status'
        $vipRepo = $this->container->get(EntityRepositoryInterface::class . '.my_plugin_vip_status');
        
        $extensionData = [];
        foreach ($customers as $customer) {
            $extensionData[] = [
                'id' => Uuid::randomBytes(),
                'customer_id' => $customer->getUniqueIdentifier(),
                'status_level' => 5,
                'created_at' => (new \DateTime())->toISOString(),
            ];
        }

        // 3. Bulk write using upsert to handle existing records safely
        $vipRepo->upsert($extensionData);
    }
}

Shopware 6.7 Tip: When writing data, always consider the Context. If your migration affects storefront or API visibility, ensure you use Context::createDefaultContext() or pass the appropriate context to repositories if running in a specific scope.

Step 4: Transactions for Atomicity

For data migrations involving multiple steps, wrap operations in transactions. Shopware's connection object supports this natively. This ensures that either all changes are committed or none are, preventing partial states during failed runs.

public function up(Schema $schema, Connection $connection): void
{
    try {
        $connection->beginTransaction();

        // Schema change
        if (!$schema->hasTable('my_plugin_log')) { /* create table... */ }

        // Data logic using connection directly for high performance bulk ops
        $builder = $connection->createQueryBuilder();
        $builder->insert('my_plugin_log')
            ->values(['id' => ':id', 'created_at' => ':created_at']);
        
        // Example loop with prepared statements
        foreach ($this->getLogData() as $data) {
            $builder->setValue('id', ':id')->executeStatement($data);
        }

        $connection->commit();
    } catch (\Exception $e) {
        $connection->rollBack();
        throw $e;
    }
}

Shopware 6.7 Specific Enhancements and Best Practices

  1. Strict Type Enforcement: Ensure all methods in your migration class use strict types (declare(strict_types=1)). Method signatures must match void returns where applicable to avoid deprecation warnings that may fail in production error-reporting modes.
  2. Deprecated APIs: Review the Shopware 6.7 release notes for removed APIs. For instance, accessing the kernel directly within service scopes is discouraged. Use dependency injection via the container exclusively.
  3. SchemaHelper Improvements: If you need to inspect existing metadata dynamically, use $schema->getTable() methods rather than querying information_schema directly, as this improves cross-database driver compatibility (e.g., PostgreSQL vs MySQL differences).
  4. Performance with Large Datasets: For data migrations affecting thousands of records, avoid loading entities into PHP memory if possible. Use the QueryBuilder for bulk updates or chunked iterations via EntityDefinition repositories to keep memory usage stable.

Testing Your Migrations

Shopware provides a testing infrastructure for migrations. You can extend MigrationTestCase in your plugin's unit tests to verify that your migration runs without errors and performs the expected schema changes.

class MyPlugin_AddOrderExtensionTableTest extends MigrationTestCase
{
    protected function getMigrationClass(): string
    {
        return MyPlugin_AddOrderExtensionTable::class;
    }

    public function testUpAppliesSchemaChanges(): void
    {
        $migration = new MyPlugin_AddOrderExtensionTable();
        $this->migrate($migration);

        // Assert table exists using the migration's connection state
        $this->assertTableExists('my_plugin_order_extension');
        
        // You can also assert column existence
        $this->assertColumnExists('my_plugin_order_extension', 'order_id');
    }
}

Execution and Deployment Workflow

Once your migrations are coded, integrated, and tested:

  1. Local Verification: Run bin/migration-run in your development environment. Use bin/migration-status to verify the timestamp is registered.
  2. Rollback Strategy: Implement a robust down method for development rollbacks. While not always used in CI/CD, it is vital for local debugging.
  3. Production Safety: Before deploying migrations in Shopware 6.7+, ensure your deployment process includes a database backup. Migrations that alter schema are generally safe but should be reviewed by the team for production impact.
  4. Installation Hooks: If your migration must run only during plugin installation, use the plugin.xml <install> tag or handle it conditionally in your plugin's InstallCommand. However, standard migrations in the migrations/ folder usually suffice for automated handling by Shopware's core updater.

Conclusion

Migrating a Shopware 6 plugin requires a disciplined approach to database schema and data management. By adhering to the patterns outlined above—leveraging idempotent schema checks, utilizing container-aware entity repositories, and respecting Shopware 6.7's strict typing and architectural improvements—you can deliver plugins that are resilient, performant, and seamlessly upgradeable.

As Shopware continues to evolve, staying updated with the latest migration guidelines in the official documentation will ensure your plugin remains compatible with future releases while maintaining the highest standards of database integrity.