Zum Inhalt springen

Shopware 6: Custom Entities with the Data Abstraction Layer (DAL)

Veröffentlicht am Apr 8, 2025 | ca. 1 Min. Lesezeit |

Shopware 6 deliberately chose against Doctrine ORM and developed its own Data Abstraction Layer (DAL). Doctrine does offer repositories, lifecycle events and query mechanisms -- but the crucial difference lies in runtime extensibility: The DAL allows plugins to extend existing entity definitions at runtime with custom fields and associations, without modifying the original code. Doctrine entities, by contrast, are statically bound to their PHP class and cannot be extended with additional fields from the outside. On top of that, the DAL offers built-in versioning, automatic API integration (ApiAware), multilingual fields (TranslatedField) and field inheritance for product variants -- features that Doctrine does not provide natively.

Fundamental Concepts

The DAL consists of three main components:

Entity: A PHP class that represents a database record. EntityDefinition: Describes the fields and associations of the entity -- analogous to Doctrine mapping. EntityRepository: Used for all database operations, never direct SQL.

Creating a Custom Entity

Let's create an example: A Testimonial entity for customer reviews.

EntityDefinition

<?php

declare(strict_types=1);

namespace MyPlugin\Core\Content\Testimonial;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\ApiAware;
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\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\LongTextField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\System\SalesChannel\SalesChannelDefinition;

class TestimonialDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'my_plugin_testimonial';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return TestimonialEntity::class;
    }

    public function getCollectionClass(): string
    {
        return TestimonialCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey(), new ApiAware()),
            (new StringField('author_name', 'authorName'))->addFlags(new Required(), new ApiAware()),
            (new LongTextField('content', 'content'))->addFlags(new Required(), new ApiAware()),
            (new IntField('rating', 'rating'))->addFlags(new Required(), new ApiAware()),
            (new BoolField('is_active', 'isActive'))->addFlags(new ApiAware()),
            new ManyToOneAssociationField('salesChannel', 'sales_channel_id', SalesChannelDefinition::class),
        ]);
    }
}

Entity Class

<?php

declare(strict_types=1);

namespace MyPlugin\Core\Content\Testimonial;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;

class TestimonialEntity extends Entity
{
    use EntityIdTrait;

    protected string $authorName;
    protected string $content;
    protected int $rating;
    protected bool $isActive = true;

    public function getAuthorName(): string
    {
        return $this->authorName;
    }

    public function setAuthorName(string $authorName): void
    {
        $this->authorName = $authorName;
    }

    public function getContent(): string
    {
        return $this->content;
    }

    public function setContent(string $content): void
    {
        $this->content = $content;
    }

    public function getRating(): int
    {
        return $this->rating;
    }

    public function setRating(int $rating): void
    {
        $this->rating = $rating;
    }

    public function isActive(): bool
    {
        return $this->isActive;
    }

    public function setIsActive(bool $isActive): void
    {
        $this->isActive = $isActive;
    }
}

Collection Class

<?php

declare(strict_types=1);

namespace MyPlugin\Core\Content\Testimonial;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
 * @extends EntityCollection<TestimonialEntity>
 */
class TestimonialCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return TestimonialEntity::class;
    }

    public function getActive(): self
    {
        return $this->filter(fn (TestimonialEntity $t): bool => $t->isActive());
    }
}

Service Registration in services.xml

<service id="MyPlugin\Core\Content\Testimonial\TestimonialDefinition">
    <tag name="shopware.entity.definition" entity="my_plugin_testimonial"/>
</service>

Migration for the Database Table

<?php

declare(strict_types=1);

namespace MyPlugin\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1712500000CreateTestimonialTable extends MigrationStep
{
    public function getCreationTimestamp(): int
    {
        return 1712500000;
    }

    public function update(Connection $connection): void
    {
        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `my_plugin_testimonial` (
                `id`             BINARY(16)   NOT NULL,
                `sales_channel_id` BINARY(16) NULL,
                `author_name`    CHAR(255) NOT NULL,
                `content`        LONGTEXT     NOT NULL,
                `rating`         INT(11)      NOT NULL,
                `is_active`      TINYINT(1)   NOT NULL DEFAULT 1,
                `created_at`     DATETIME(3)  NOT NULL,
                `updated_at`     DATETIME(3)  NULL,
                PRIMARY KEY (`id`),
                KEY `fk_testimonial_sales_channel` (`sales_channel_id`),
                CONSTRAINT `fk_testimonial_sales_channel`
                    FOREIGN KEY (`sales_channel_id`)
                    REFERENCES `sales_channel` (`id`)
                    ON DELETE SET NULL
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

CRUD with the Repository

The repository is injected via dependency injection:

<service id="MyPlugin\Service\TestimonialService">
    <argument type="service" id="my_plugin_testimonial.repository"/>
</service>
<?php

declare(strict_types=1);

namespace MyPlugin\Service;

use MyPlugin\Core\Content\Testimonial\TestimonialCollection;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Uuid\Uuid;

class TestimonialService
{
    public function __construct(
        private readonly EntityRepository $testimonialRepository,
    ) {
    }

    public function findActive(Context $context): TestimonialCollection
    {
        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('isActive', true));
        $criteria->addSorting(new FieldSorting('createdAt', FieldSorting::DESCENDING));
        $criteria->setLimit(10);

        /** @var TestimonialCollection $result */
        $result = $this->testimonialRepository->search($criteria, $context)->getEntities();

        return $result;
    }

    public function create(string $authorName, string $content, int $rating, Context $context): string
    {
        $id = Uuid::randomHex();

        $this->testimonialRepository->create([
            [
                'id'         => $id,
                'authorName' => $authorName,
                'content'    => $content,
                'rating'     => max(1, min(5, $rating)),
                'isActive'   => true,
            ],
        ], $context);

        return $id;
    }

    public function deactivate(string $id, Context $context): void
    {
        $this->testimonialRepository->update([
            ['id' => $id, 'isActive' => false],
        ], $context);
    }

    public function delete(string $id, Context $context): void
    {
        $this->testimonialRepository->delete([['id' => $id]], $context);
    }
}

Criteria and Filters

The DAL offers an extensive criteria API:

<?php

declare(strict_types=1);

use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\AndFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\AvgAggregation;

$criteria = new Criteria();

// Combined filters (AND logic is the default)
$criteria->addFilter(
    new AndFilter([
        new EqualsFilter('isActive', true),
        new RangeFilter('rating', [
            RangeFilter::GTE => 4,
        ]),
    ])
);

// Calculate average rating
$criteria->addAggregation(new AvgAggregation('avg_rating', 'rating'));

// Pagination
$criteria->setLimit(20);
$criteria->setOffset(0);

$result = $this->testimonialRepository->search($criteria, $context);
$avgRating = $result->getAggregations()->get('avg_rating')?->getAvg();

Creating Migrations via Console Commands

Shopware provides two console commands to generate migrations instead of writing them entirely by hand.

Generate an empty migration skeleton:

bin/console database:create-migration -p MeinPlugin --name CreateTestimonialTable

This command creates an empty class with update() and updateDestructive() -- you write the SQL code yourself. The -p parameter specifies the plugin name, --name adds a descriptive suffix to the timestamp.

Generate a migration automatically from the EntityDefinition:

bin/console dal:migration:create --bundle=MeinPlugin --entities=my_plugin_testimonial

This command compares the EntityDefinition with the current database schema and automatically generates CREATE TABLE or ALTER TABLE statements. The plugin must be active for the definition to be found.

Run migrations:

# Non-destructive changes (update method)
bin/console database:migrate MeinPlugin --all

# Destructive changes (updateDestructive method)
bin/console database:migrate-destructive MeinPlugin --all

Important: Migrations are automatically executed during plugin installation and updates. Already executed migrations must not be modified. update() should only contain reversible changes, updateDestructive() irreversible ones (deleting columns or tables).

Entity Extensions: Extending Entities from the Outside

The real advantage of the DAL becomes apparent with its extensibility. Other plugins can extend existing entities with custom fields and associations -- without modifying the original entity.

Example: Extending a Product with a 1:1 Relationship

Step 1: Custom EntityDefinition for the extension data:

<?php

declare(strict_types=1);

namespace MeinPlugin\Core\Content\ProductRating;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\ApiAware;
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\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class ProductRatingDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'my_plugin_product_rating';

    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 ApiAware()),
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new Required()),
            (new IntField('expert_score', 'expertScore'))->addFlags(new ApiAware()),
            new OneToOneAssociationField('product', 'product_id', 'id', ProductDefinition::class, false),
        ]);
    }
}

Step 2: EntityExtension class that registers the association on the product:

<?php

declare(strict_types=1);

namespace MeinPlugin\Extension\Content\Product;

use MeinPlugin\Core\Content\ProductRating\ProductRatingDefinition;
use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityExtension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\ApiAware;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class ProductRatingExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new OneToOneAssociationField(
                'productRating',
                'id',
                'product_id',
                ProductRatingDefinition::class,
                true
            ))->addFlags(new ApiAware())
        );
    }

    public function getDefinitionClass(): string
    {
        return ProductDefinition::class;
    }
}

Step 3: Service registration in services.xml:

<service id="MeinPlugin\Core\Content\ProductRating\ProductRatingDefinition">
    <tag name="shopware.entity.definition" entity="my_plugin_product_rating"/>
</service>

<service id="MeinPlugin\Extension\Content\Product\ProductRatingExtension">
    <tag name="shopware.entity.extension"/>
</service>

Field Inheritance: Inheritance for Product Variants

If an extension should also be inherited by variants (the variant inherits data from the parent product), you need the Inherited flag and the InheritanceUpdaterTrait in the migration:

<?php

declare(strict_types=1);

namespace MeinPlugin\Extension\Content\Product;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityExtension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\ApiAware;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Inherited;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class ProductRatingExtension extends EntityExtension
{
    public const EXTENSION_NAME = 'productRating';

    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new OneToOneAssociationField(
                self::EXTENSION_NAME,
                'id',
                'product_id',
                ProductRatingDefinition::class,
                true
            ))->addFlags(new ApiAware(), new Inherited())
        );
    }

    public function getDefinitionClass(): string
    {
        return ProductDefinition::class;
    }
}

In the migration, updateInheritance() automatically creates the necessary inheritance column in the product table:

<?php

declare(strict_types=1);

namespace MeinPlugin\Migration;

use Doctrine\DBAL\Connection;
use MeinPlugin\Extension\Content\Product\ProductRatingExtension;
use Shopware\Core\Framework\Migration\InheritanceUpdaterTrait;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1712600000CreateProductRatingTable extends MigrationStep
{
    use InheritanceUpdaterTrait;

    public function getCreationTimestamp(): int
    {
        return 1712600000;
    }

    public function update(Connection $connection): void
    {
        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `my_plugin_product_rating` (
                `id`                 BINARY(16) NOT NULL,
                `product_id`         BINARY(16) NOT NULL,
                `product_version_id` BINARY(16) NOT NULL,
                `expert_score`       INT(11)    NULL,
                `created_at`         DATETIME(3) NOT NULL,
                `updated_at`         DATETIME(3) NULL,
                PRIMARY KEY (`id`),
                UNIQUE KEY `uniq_product` (`product_id`, `product_version_id`),
                CONSTRAINT `fk_product_rating_product`
                    FOREIGN KEY (`product_id`, `product_version_id`)
                    REFERENCES `product` (`id`, `version_id`) ON DELETE CASCADE
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        $this->updateInheritance($connection, 'product', ProductRatingExtension::EXTENSION_NAME);
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

Prerequisites for field inheritance: All inheritable fields must be nullable. The entity needs ParentFkField, ParentAssociationField and ChildrenAssociationField (already present in ProductDefinition). Fields must be marked with the Inherited flag.

Making Your Own Entity Extensible

For other plugins to be able to extend your entity via EntityExtension, no special measures are needed -- every registered EntityDefinition is automatically extensible. However, there are best practices:

  1. Include a CustomFields field -- allows simple extensions without a custom migration
  2. Entity name as a constant (public const ENTITY_NAME) -- other plugins can reference it
  3. Directory structure by domain (Core/Content/EntityName/) -- follows the Shopware core convention

Starting with Shopware 6.6.10, there is also the BulkEntityExtension, which allows you to extend multiple entities in a single class -- instead of creating a separate extension class for each entity.

Conclusion

The Shopware DAL has a steep learning curve for developers coming from Doctrine, but the system is thoroughly thought out. Doctrine does have repositories, events and inheritance strategies (mapped superclass, single table inheritance) -- but these must be defined at development time within the entity class. The crucial advantage of the DAL is runtime extensibility: via the EntityExtension mechanism, plugins can extend any entity definition with additional fields and associations without touching the original code. Combined with field inheritance for product variants, built-in versioning and automatic event dispatching for all CRUD operations, Shopware provides the tools to cleanly manage even complex plugin ecosystems.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.