Choosing Between Custom Fields and Custom Entities in Shopware 6
A decision guide for Shopware 6 developers choosing between Custom Fields (JSON metadata on existing tables) and Custom Entities (dedicated DAL definitions with relationships and indexes). Includes migration examples, performance trade-offs, and a validation checklist.
12 Jul 2026, 10:56 UTC

The Decision You're Facing
You need to extend a Shopware 6 entity—perhaps product, order, or customer—with additional data. The platform offers two supported paths: Custom Fields (low-code, JSON-backed attributes on existing tables) and Custom Entities (full DAL definitions with dedicated tables and relationships). The choice affects query performance, deployment complexity, and how your data behaves in the Store API and Administration.
Rule of thumb: Use Custom Fields for simple, sparse metadata (tags, flags, single values). Use Custom Entities when you need relationships, strict typing, indexing for high-volume filtering, or business logic that belongs in its own domain model.
Quick Comparison
| Factor | Custom Fields | Custom Entities |
|---|---|---|
| Storage | Single custom_fields JSON column on the target table | Dedicated table via migration |
| Relationships | None (flat key/value) | One-to-Many, Many-to-Many, Many-to-One |
| Indexing & Filtering | Limited; JSON path queries, no native indexes | Full SQL indexes, foreign keys, DAL search criteria |
| Deployment | Admin UI or custom_field_set migration; no schema change | PHP Definition class + migration; schema change required |
| Type Safety | String, int, float, bool, date—validated at API layer only | Strict PHP types, Doctrine column definitions |
| API Exposure | Automatic in customFields object on entity | Explicit via association in DAL definition |
| Admin UI | Built-in Custom Field management per entity | Requires custom module or entity listing |
| Performance at Scale | Degrades with large JSON blobs or frequent filtering | Scales with proper indexes and pagination |
When Custom Fields Are the Right Fit
Custom Fields shine for metadata that doesn't participate in core queries. Examples:
- Marketing flags:
is_featured,campaign_id - Integration tokens:
erp_sync_status,external_id - Editorial notes:
seo_notes,internal_comment - Feature toggles per product:
show_in_outlet
These are typically low cardinality, rarely filtered in SQL, and managed by non-developers via the Administration.
Creating a Custom Field Set via Migration
Run this in your plugin's Migration class (requires Shopware\Core\Framework\Migration\MigrationStep):
public function update(Connection $connection): void
{
$setId = Uuid::randomHex();
$connection->executeStatement(
'INSERT INTO `custom_field_set` (`id`, `name`, `relations`, `config`, `created_at`)
VALUES (UNHEX(?), ?, ?, ?, NOW())',
[$setId, 'product_marketing', '["product"]', '{}']
);
$fields = [
['id' => Uuid::randomHex(), 'set_id' => $setId, 'name' => 'is_featured', 'type' => 'bool', 'config' => ['label' => ['en-GB' => 'Featured']]],
['id' => Uuid::randomHex(), 'set_id' => $setId, 'name' => 'campaign_id', 'type' => 'text', 'config' => ['label' => ['en-GB' => 'Campaign ID']]],
];
foreach ($fields as $field) {
$connection->executeStatement(
'INSERT INTO `custom_field` (`id`, `set_id`, `name`, `type`, `config`, `created_at`)
VALUES (UNHEX(?), UNHEX(?), ?, ?, ?, NOW())',
[$field['id'], $setId, $field['name'], $field['type'], json_encode($field['config'])]
);
}
}
Where to run: In your plugin's src/Migration/ directory during bin/console database:migrate. Permissions: Database write access. Risk: Low—adds rows only. Verification: Check custom_field_set and custom_field tables; confirm the fields appear in Administration → Settings → Custom Fields.
When You Need a Custom Entity
Choose a Custom Entity when the data:
- Has a lifecycle independent of the parent (e.g., product reviews, warranty registrations)
- Requires referential integrity (foreign keys, cascade delete)
- Needs efficient filtering/sorting on large datasets (e.g., "all warranties expiring this month")
- Involves complex types (nested objects, collections, enums with constraints)
- Must be exposed as a first-class API resource with its own endpoints
Concrete Example: Product Warranty Registrations
A merchant sells electronics and wants customers to register warranties. Each product can have many registrations; each registration belongs to one customer and one product. You need to query "warranties expiring in 30 days" and enforce that a registration cannot exist without a valid product.
1. Define the Entity (PHP)
// src/Core/Content/Warranty/WarrantyDefinition.php
namespace App\Core\Content\Warranty;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\*
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
class WarrantyDefinition extends EntityDefinition
{
public const ENTITY_NAME = 'warranty_registration';
public function getEntityName(): string
{
return self::ENTITY_NAME;
}
public function getCollectionClass(): string
{
return WarrantyCollection::class;
}
public function getEntityClass(): string
{
return WarrantyEntity::class;
}
protected function defineFields(): FieldCollection
{
return new FieldCollection([
(new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
(new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new Required()),
(new FkField('customer_id', 'customerId', CustomerDefinition::class))->addFlags(new Required()),
(new DateField('purchase_date', 'purchaseDate'))->addFlags(new Required()),
(new DateField('expiry_date', 'expiryDate'))->addFlags(new Required()),
(new StringField('serial_number', 'serialNumber'))->addFlags(new Required()),
(new EnumField('status', 'status', ['active', 'expired', 'claimed']))->addFlags(new Required()),
(new CreatedAtField())->addFlags(new Required()),
(new UpdatedAtField())->addFlags(new Required()),
// Associations for DAL loading
(new OneToOneAssociationField('product', 'product_id', 'id', ProductDefinition::class)),
(new OneToOneAssociationField('customer', 'customer_id', 'id', CustomerDefinition::class)),
]);
}
}
2. Create the Migration
// src/Migration/Migration1700000000CreateWarrantyTable.php
public function update(Connection $connection): void
{
$connection->executeStatement('''
CREATE TABLE `warranty_registration` (
`id` BINARY(16) NOT NULL,
`product_id` BINARY(16) NOT NULL,
`customer_id` BINARY(16) NOT NULL,
`purchase_date` DATE NOT NULL,
`expiry_date` DATE NOT NULL,
`serial_number` VARCHAR(255) NOT NULL,
`status` VARCHAR(20) NOT NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) DEFAULT NULL,
PRIMARY KEY (`id`),
CONSTRAINT `fk_warranty_product` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_warranty_customer` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`id`) ON DELETE CASCADE,
INDEX `idx_warranty_expiry` (`expiry_date`),
INDEX `idx_warranty_product` (`product_id`),
INDEX `idx_warranty_customer` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
''');
}
Where to run: Same migration path. Permissions: Database CREATE TABLE, ALTER TABLE. Risk: Schema change—test in staging. Rollback: DROP TABLE warranty_registration in updateDestructive.
3. Register the Definition
// src/Resources/config/services.xml
4. Querying via DAL (Service or Controller)
// Find warranties expiring in the next 30 days
$criteria = new Criteria();
$criteria->addFilter(new RangeFilter('expiryDate', [
RangeFilter::GTE => (new \DateTime())->format('Y-m-d'),
RangeFilter::LTE => (new \DateTime('+30 days'))->format('Y-m-d'),
]));
$criteria->addAssociation('product');
$criteria->addAssociation('customer');
$criteria->setLimit(50);
$warranties = $this->warrantyRepository->search($criteria, $context);
Trade-offs in Practice
Filtering Performance
Custom Fields store everything in one JSON column. A query like "products where customFields.is_featured = true" translates to JSON_EXTRACT(custom_fields, '$.is_featured') = true. MySQL cannot use a standard B-tree index on this path (though generated columns + indexes are possible in 8.0+, Shopware doesn't automate this). At 100k+ products, expect full table scans.
Custom Entities let you declare INDEX idx_warranty_expiry (expiry_date). The DAL generates a straightforward WHERE expiry_date BETWEEN ? AND ? with index seek.
Deployment & CI/CD
Custom Fields: Add a field in Administration → Settings → Custom Fields → product. No code deploy. Changes are immediate but not version-controlled unless you export the set as a migration (as shown above).
Custom Entities: Require PHP class, migration, service registration, and bin/console database:migrate on every environment. Full version control, code review, and rollback via destructive migration.
API Contract Stability
Custom Fields appear under customFields in Store API responses. The shape is dynamic—clients must handle missing keys. Custom Entities expose a fixed schema via their own endpoint (/store-api/warranty-registration) and associations, enabling TypeScript/OpenAPI generation.
Validation Checklist Before You Decide
- Will this data be filtered in list views or reports? → Custom Entity
- Does it need referential integrity (cascade delete, FK)? → Custom Entity
- Is it managed by content editors without code changes? → Custom Field
- Does it belong to a distinct business concept with its own lifecycle? → Custom Entity
- Are there fewer than ~5 keys per entity, rarely queried? → Custom Field
- Do you need strict typing (enum, decimal precision, date-only)? → Custom Entity
Limitations & Gotchas
- Custom Fields cannot be unique. No database-level uniqueness constraint on a JSON key.
- Custom Entities don't appear in Administration automatically. You must build a module (Vue/ExtJS) or use the generic Entity Listing if available.
- Mixed approach is valid. A product can have Custom Fields for marketing flags and a One-to-Many to a Warranty Custom Entity.
- Store API depth. Custom Fields are always loaded with the parent. Custom Entities require explicit
associationsin the request or definition to avoid N+1.
How to Verify Your Implementation
- Custom Fields: Call
GET /store-api/product/{id}and confirmcustomFields.is_featuredandcustomFields.campaign_idappear in the response. - Custom Entity: Call
GET /store-api/warranty-registration?limit=1(requires route registration via@Routeon a controller orEntityController). Confirm the JSON structure matches your definition. - Database: Run
SHOW CREATE TABLE warranty_registrationand verify indexes and foreign keys exist. - DAL Filtering: Execute a search with
Criteriafiltering onexpiryDateand check the generated SQL viaecho $criteria->getQuery();(in dev) or enable Doctrine SQL logger.
Final Recommendation
Start with Custom Fields for anything that feels like "metadata." Graduate to a Custom Entity the moment you write a WHERE JSON_EXTRACT(...) in a repository, need a foreign key, or hear "we need to report on this." The migration path from Custom Field → Custom Entity is manual (read JSON, write rows), so err toward Custom Entities when the data smells like a domain concept.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.