Verified on Shopware 6.7

Every developer who comes to Shopware 6 with a MySQL background runs into the same fork in the road: the DAL looks unfamiliar and verbose, raw SQL feels comfortable and fast. Why not just inject Doctrine\DBAL\Connection and write queries the way you always have?

Sometimes that is the right call. Often it is not. The distinction is not a matter of style - it has concrete technical consequences that will surface in production.


What the DAL Actually Gives You

The DataAbstractionLayer is not just a query builder with extra steps. It is a full entity lifecycle system. When you write through an EntityRepository, several things happen automatically that a raw DBAL query bypasses entirely.

Lifecycle events. Every create, update, and delete through the DAL dispatches EntityWrittenEvent (and its container EntityWrittenContainerEvent). Any subscriber in your codebase - or in any installed plugin - listening for changes to that entity will fire. If you write directly to the product table via DBAL, product indexers will not run, search index updates will not trigger, and any plugin listening for ProductEvents::PRODUCT_WRITTEN_EVENT will never know the change happened.

Transparent UUID handling. Shopware stores all primary keys as BINARY(16) in the database. The DAL's entity hydrator converts them to 32-character hex strings automatically on read, and back to binary on write. You work exclusively with readable hex IDs in application code and never think about the storage format.

Entity hydration. The DAL maps database rows to typed entity objects with associations. Criteria associations load related entities in a single optimized query. You get ProductEntity objects with getCover(), getCategories(), and getTranslations() fully resolved rather than flat associative arrays.

Versioning. Entities that support versioning (products, orders, categories) get full draft/live separation and version history tracking. DBAL writes go directly to the live table with no version awareness.

Field validation and cascading. The DAL validates field types, enforces constraints, and handles cascading operations according to entity definitions before anything hits the database.


Where Shopware Itself Uses DBAL Instead

Shopware's own codebase is instructive here. Several tables are deliberately never accessed through the DAL - they either have no entity definition at all, or the overhead of the full entity lifecycle is inappropriate for their purpose.

cart - persisted by CartPersister via raw SQL using INSERT ... ON DUPLICATE KEY UPDATE. Cart data changes on every page load, every item addition, every price recalculation. The DAL's event dispatch and hydration cycle would be unnecessary overhead for what is essentially a serialized blob keyed by session token.

sales_channel_api_context - persisted by SalesChannelContextPersister using REPLACE INTO. Session-like data that is read on every request and written frequently. No entity lifecycle is needed.

payment_token - simple hashed token with an expiry. No repository, just direct reads and deletes.

Aggregate queries are also consistently done via DBAL. CustomerMetaFieldSubscriber calculates order counts and total amounts with a raw GROUP BY query across multiple joined tables. ProductReviewCountService updates customer.review_count with a direct correlated subquery. These operations do not map naturally to DAL criteria and would be significantly less efficient if they tried to hydrate full entity collections.

The pattern in Shopware's source is clear: DBAL for temporary/session storage, bulk statistics, validation checks before writes, and complex aggregations. DAL for everything that is a business entity with relationships, change tracking, and plugin extensibility.


Why DBAL Fails Where DAL Is Expected

This is where the familiarity with raw SQL causes real problems. The failure modes are not obvious until they appear in production.

Binary IDs

Every time you query a Shopware entity table via DBAL, you must handle UUID conversion manually. IDs are stored as BINARY(16). Reading them without conversion gives you binary garbage. Writing a hex string where binary is expected silently inserts nothing or corrupts the record.

Reading requires explicit conversion in the SELECT:

$result = $this->connection->fetchAssociative(
    'SELECT LOWER(HEX(id)) as id, LOWER(HEX(category_id)) as category_id, name
     FROM product_translation
     WHERE product_id = :id AND language_id = :lang',
    [
        'id'   => Uuid::fromHexToBytes($productId),
        'lang' => Uuid::fromHexToBytes($languageId),
    ]
);

Miss a single LOWER(HEX(...)) on a column and you get binary data in your result. Miss a Uuid::fromHexToBytes() on a parameter and the WHERE clause matches nothing. The DAL handles all of this transparently. DBAL pushes it entirely onto you for every query, every parameter, every column in every result.

Lifecycle Events Do Not Fire

This is the most damaging failure mode because it is invisible. Your code works. Records change in the database. But nothing that depends on entity change events runs.

In a Shopware project with 10+ plugins, entity change events are how the system stays consistent. Product indexers rebuild search indexes. Stock calculators update availability. Cache invalidators clear stale data. Audit subscribers log changes. When you write to product via DBAL, none of those subscribers know the write happened. The database has new data. The rest of the system still has the old state.

// This works - but dispatches no events:
$this->connection->executeStatement(
    'UPDATE product SET active = 0 WHERE id = :id',
    ['id' => Uuid::fromHexToBytes($productId)]
);

// Product is deactivated in the DB.
// The search index still shows it as active.
// Any plugin listening for ProductEvents::PRODUCT_WRITTEN_EVENT never fires.
// Cache is not invalidated.

No Versioning

Products and orders in Shopware support versioning - the live version and drafts are stored separately, and merge operations handle promoting a draft to live. DBAL queries operate directly on the table with no version awareness. Writing to a versioned entity via DBAL means writing to whatever row happens to match your WHERE clause, regardless of which version it belongs to.

Association Integrity

The DAL enforces foreign key constraints and cascading rules defined in entity definitions. A direct DBAL delete of a parent record leaves orphaned children that the DAL's cascade delete would have cleaned up. Dependent entities that the DAL would have updated automatically remain stale.


The Decision Rule

Use the DAL for any entity that has an EntityDefinition - products, orders, customers, categories, CMS pages, your own custom entities. If it is a business entity, it belongs to the DAL. The events, hydration, and lifecycle management are not optional extras - they are the contract that keeps the system consistent across plugins.

Use DBAL for:

  • Tables without an EntityDefinition (cart, sales_channel_api_context, payment_token, queue tables)
  • Validation queries that need to run before a write - checking uniqueness, reading constraints
  • Aggregation queries where you need COUNT, SUM, GROUP BY across multiple tables and hydrating full entity objects would be wasteful
  • Bulk operations where you are inserting or updating thousands of records and the per-row event overhead is genuinely too expensive - in which case, use DBAL and then trigger reindexing explicitly
  • Migration scripts, which always use DBAL because the entity system may not be in a consistent state during schema changes

When you do use DBAL against entity tables, handle the binary IDs without exception and be explicit in code comments about why DAL was bypassed.


Shopware's Own Helpers for DBAL

When DBAL is appropriate, Shopware provides utilities that solve common low-level problems.

Uuid::fromHexToBytes(string $hex): string and Uuid::fromBytesToHex(string $bytes): string - the standard conversion pair you use in every DBAL query touching entity tables.

RetryableQuery - wraps a prepared statement and retries up to 10 times on deadlock with linear backoff. Use it for any high-concurrency DBAL write.

MultiInsertQueryQueue - batches large numbers of inserts into chunks (default 250 rows per query) rather than one query per row. Used throughout Shopware's own import and migration logic.

RetryableTransaction - wraps a block of DBAL operations in a transaction that automatically retries on deadlock.

RetryableTransaction::retryable($this->connection, function () use ($data): void {
    foreach ($data as $row) {
        $this->connection->executeStatement(
            'INSERT INTO my_stats_table (id, value) VALUES (:id, :value)
             ON DUPLICATE KEY UPDATE value = :value',
            ['id' => Uuid::fromHexToBytes($row['id']), 'value' => $row['value']]
        );
    }
});

Summary

The DAL is not Shopware bureaucracy. It is the mechanism that keeps plugins, indexers, caches, and subscribers in sync with entity state. Bypassing it for business entities works right up until something in the system depends on the events it would have dispatched - and in any non-trivial Shopware project, something always does.

DBAL has a legitimate role for tables that exist outside the entity lifecycle, for aggregation queries, and for validation checks. Shopware itself uses it in exactly those contexts.

If the table has an EntityDefinition, use the repository. If it does not, or if you are counting rows and need nothing else, DBAL is the right tool. The distinction is consistent across the entire Shopware codebase - follow it and your code will behave consistently too.