Skip to content

Creating Extensions#

Extensions integrate third-party functionality with Entity Builder by adding custom YAML keys, operations, and dependencies.

Extension Architecture#

classDiagram
    class EbExtensionInterface {
        <<interface>>
        +buildOperations(array data) array
        +getOperationDependencies(array operation, array batch) array
        +detectChanges(array operation, array context) ?string
        +checkDependencies(array operation) array
        +appliesTo(array operation) bool
        +getYamlKeys() array
        +getOperations() array
        +extractConfig(string entityType, string bundle) array
    }

    class EbExtensionBase {
        <<abstract>>
        #pluginDefinition
        #entityTypeManager
        +getYamlKeys() array
        +getOperations() array
    }

    class FieldGroupExtension {
        +buildOperations(array data) array
        +getOperationDependencies(array operation, array batch) array
        +detectChanges(array operation, array context) ?string
        +extractConfig(string entityType, string bundle) array
    }

    EbExtensionInterface <|.. EbExtensionBase
    EbExtensionBase <|-- FieldGroupExtension

Creating an Extension#

Step 1: Create Module Structure#

my_extension/
├── src/
│   └── Plugin/
│       ├── EbExtension/
│       │   └── MyExtension.php          # Extension plugin
│       └── EbOperation/
│           ├── CreateMyEntityOperation.php
│           └── UpdateMyEntityOperation.php
├── tests/
│   └── src/Unit/Plugin/EbExtension/
│       └── MyExtensionTest.php
├── my_extension.info.yml
└── README.md

Step 2: Create the Info File#

1
2
3
4
5
6
7
8
9
# my_extension.info.yml
name: 'My Extension'
type: module
description: 'Entity Builder extension for My Feature'
package: Entity Builder
core_version_requirement: ^11
dependencies:
  - eb:eb
  - my_dependency:my_dependency  # The module this integrates

Step 3: Create the Extension Plugin#

<?php

namespace Drupal\my_extension\Plugin\EbExtension;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\eb\Attribute\EbExtension;
use Drupal\eb\PluginBase\EbExtensionBase;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Extension for My Feature integration.
 */
#[EbExtension(
    id: 'my_extension',
    label: new TranslatableMarkup('My Extension'),
    description: new TranslatableMarkup('Adds My Feature support to Entity Builder'),
    yaml_keys: ['my_entity_definitions'],
    operations: ['create_my_entity', 'update_my_entity', 'delete_my_entity'],
    module_dependencies: ['my_dependency'],
)]
class MyExtension extends EbExtensionBase implements ContainerFactoryPluginInterface {

    /**
     * The entity type manager.
     */
    protected EntityTypeManagerInterface $entityTypeManager;

    /**
     * {@inheritdoc}
     */
    public function __construct(
        array $configuration,
        $plugin_id,
        $plugin_definition,
        EntityTypeManagerInterface $entityTypeManager,
    ) {
        parent::__construct($configuration, $plugin_id, $plugin_definition);
        $this->entityTypeManager = $entityTypeManager;
    }

    /**
     * {@inheritdoc}
     */
    public static function create(
        ContainerInterface $container,
        array $configuration,
        $plugin_id,
        $plugin_definition
    ): static {
        return new static(
            $configuration,
            $plugin_id,
            $plugin_definition,
            $container->get('entity_type.manager'),
        );
    }

    /**
     * {@inheritdoc}
     *
     * This is the PRIMARY method that extensions must implement.
     * Converts definition data to operation arrays.
     */
    public function buildOperations(array $data): array {
        if (empty($data['my_entity_definitions'])) {
            return [];
        }

        $operations = [];
        foreach ($data['my_entity_definitions'] as $item) {
            if (!is_array($item) || empty($item['entity_id'])) {
                continue;
            }

            $operations[] = [
                'operation' => 'create_my_entity',
                'entity_type' => $item['entity_type'] ?? '',
                'bundle' => $item['bundle'] ?? '',
                'entity_id' => $item['entity_id'],
                'label' => $item['label'] ?? '',
                'settings' => $item['settings'] ?? [],
            ];
        }

        return $operations;
    }

    /**
     * {@inheritdoc}
     *
     * Declares dependencies for dependency resolution.
     */
    public function getOperationDependencies(array $operation, array $batch): array {
        $dependencies = [];

        if (!$this->appliesTo($operation)) {
            return [];
        }

        // Depend on the bundle
        $entityType = $operation['entity_type'] ?? '';
        $bundle = $operation['bundle'] ?? '';

        if ($entityType && $bundle) {
            $dependencies[] = "bundle:{$entityType}:{$bundle}";
        }

        return $dependencies;
    }

    /**
     * {@inheritdoc}
     *
     * Custom change detection for sync mode.
     */
    public function detectChanges(array $operation, array $context): ?string {
        if (!$this->appliesTo($operation)) {
            return NULL;
        }

        $entityId = $operation['entity_id'] ?? '';
        if (empty($entityId)) {
            return NULL;
        }

        // Check if entity exists
        $existing = $this->loadExisting($entityId);

        if (!$existing) {
            return 'create_my_entity';
        }

        // Compare settings
        if ($this->hasChanges($operation, $existing)) {
            return 'update_my_entity';
        }

        // No changes - skip
        return NULL;
    }

    /**
     * {@inheritdoc}
     *
     * Check if this extension handles the given operation.
     */
    public function appliesTo(array $operation): bool {
        $operationType = $operation['operation'] ?? '';
        return in_array($operationType, $this->getOperations(), TRUE);
    }

    /**
     * {@inheritdoc}
     *
     * Extract existing configuration for "Import from Drupal" feature.
     */
    public function extractConfig(string $entityType, string $bundle): array {
        $entities = $this->loadEntitiesForBundle($entityType, $bundle);

        if (empty($entities)) {
            return [];
        }

        $definitions = [];
        foreach ($entities as $entity) {
            $definitions[] = [
                'entity_type' => $entityType,
                'bundle' => $bundle,
                'entity_id' => $entity->id(),
                'label' => $entity->label(),
                'settings' => $entity->getSettings(),
            ];
        }

        return ['my_entity_definitions' => $definitions];
    }

    /**
     * Load existing entity.
     */
    protected function loadExisting(string $entityId): ?object {
        return $this->entityTypeManager
            ->getStorage('my_entity')
            ->load($entityId);
    }

    /**
     * Check if operation has changes compared to existing.
     */
    protected function hasChanges(array $operation, object $existing): bool {
        // Compare relevant fields
        if ($operation['label'] !== $existing->label()) {
            return TRUE;
        }

        // Compare settings
        $newSettings = $operation['settings'] ?? [];
        $existingSettings = $existing->getSettings();

        return $newSettings !== $existingSettings;
    }

    /**
     * Load entities for a bundle.
     */
    protected function loadEntitiesForBundle(string $entityType, string $bundle): array {
        return $this->entityTypeManager
            ->getStorage('my_entity')
            ->loadByProperties([
                'entity_type' => $entityType,
                'bundle' => $bundle,
            ]);
    }

}

Step 4: Create Operation Plugins#

<?php

namespace Drupal\my_extension\Plugin\EbOperation;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\eb\Attribute\EbOperation;
use Drupal\eb\Exception\ExecutionException;
use Drupal\eb\PluginBase\OperationBase;
use Drupal\eb\Result\ExecutionResult;
use Drupal\eb\Result\PreviewResult;
use Drupal\eb\Result\RollbackResult;
use Drupal\eb\Result\ValidationResult;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Operation for creating My Entity.
 */
#[EbOperation(
    id: 'create_my_entity',
    label: new TranslatableMarkup('Create My Entity'),
    description: new TranslatableMarkup('Creates a My Entity configuration'),
    operationType: 'create',
)]
class CreateMyEntityOperation extends OperationBase implements ContainerFactoryPluginInterface {

    /**
     * My custom service.
     */
    protected $myService;

    /**
     * {@inheritdoc}
     */
    public function __construct(
        array $configuration,
        string $plugin_id,
        mixed $plugin_definition,
        EntityTypeManagerInterface $entityTypeManager,
        LoggerInterface $logger,
        $myService,
    ) {
        parent::__construct($configuration, $plugin_id, $plugin_definition, $entityTypeManager, $logger);
        $this->myService = $myService;
    }

    /**
     * {@inheritdoc}
     */
    public static function create(
        ContainerInterface $container,
        array $configuration,
        $plugin_id,
        $plugin_definition
    ): static {
        return new static(
            $configuration,
            $plugin_id,
            $plugin_definition,
            $container->get('entity_type.manager'),
            $container->get('logger.channel.eb'),
            $container->get('my_service'),
        );
    }

    /**
     * {@inheritdoc}
     */
    public function validate(): ValidationResult {
        $result = new ValidationResult();

        $this->validateRequiredFields(['entity_type', 'bundle', 'entity_id'], $result);

        if (!$result->isValid()) {
            return $result;
        }

        // Custom validation
        $entityId = $this->getDataValue('entity_id');
        if (!preg_match('/^[a-z_]+$/', $entityId)) {
            $result->addError(
                'Entity ID must be lowercase with underscores only.',
                'entity_id',
                'invalid_entity_id'
            );
        }

        return $result;
    }

    /**
     * {@inheritdoc}
     */
    public function preview(): PreviewResult {
        $preview = new PreviewResult();
        $entityId = $this->getDataValue('entity_id');
        $label = $this->getDataValue('label', $entityId);

        $preview->addOperation(
            'create',
            'my_entity',
            $entityId,
            $this->t('Create My Entity "@label"', ['@label' => $label])
        );

        $preview->addDetails([
            'Entity ID' => $entityId,
            'Label' => $label,
            'Entity Type' => $this->getDataValue('entity_type'),
            'Bundle' => $this->getDataValue('bundle'),
        ]);

        return $preview;
    }

    /**
     * {@inheritdoc}
     */
    public function execute(): ExecutionResult {
        try {
            $entityId = $this->getDataValue('entity_id');
            $label = $this->getDataValue('label', $entityId);
            $entityType = $this->getDataValue('entity_type');
            $bundle = $this->getDataValue('bundle');
            $settings = $this->getDataValue('settings', []);

            // Create the entity using your service
            $entity = $this->myService->create([
                'id' => $entityId,
                'label' => $label,
                'entity_type' => $entityType,
                'bundle' => $bundle,
                'settings' => $settings,
            ]);
            $entity->save();

            $result = new ExecutionResult(TRUE);
            $result->addMessage($this->t('My Entity "@label" created successfully.', [
                '@label' => $label,
            ]));

            $result->addAffectedEntity([
                'type' => 'my_entity',
                'id' => $entityId,
                'label' => $label,
            ]);

            // Store rollback data
            $result->setRollbackData([
                'entity_id' => $entityId,
                'was_new' => TRUE,
            ]);

            $this->logger->info('Created My Entity: @id', ['@id' => $entityId]);

            return $result;
        }
        catch (\Exception $e) {
            $this->logger->error('My Entity creation failed: @message', [
                '@message' => $e->getMessage(),
            ]);
            throw new ExecutionException($e->getMessage(), [], 0, $e);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function rollback(): RollbackResult {
        $rollbackData = $this->getDataValue('_rollback_data', []);
        $entityId = $rollbackData['entity_id'] ?? $this->getDataValue('entity_id');
        $wasNew = $rollbackData['was_new'] ?? FALSE;

        if ($wasNew) {
            // Delete the newly created entity
            $entity = $this->myService->load($entityId);
            if ($entity) {
                $entity->delete();
            }

            $result = new RollbackResult(TRUE);
            $result->addMessage($this->t('My Entity "@id" deleted.', ['@id' => $entityId]));
        }
        else {
            $result = new RollbackResult(FALSE);
            $result->addMessage($this->t('No rollback data available for "@id".', ['@id' => $entityId]));
        }

        $result->addRestoredEntity([
            'type' => 'my_entity',
            'id' => $entityId,
        ]);

        return $result;
    }

}

Step 5: Write Tests#

<?php

namespace Drupal\Tests\my_extension\Unit\Plugin\EbExtension;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\my_extension\Plugin\EbExtension\MyExtension;
use Drupal\Tests\UnitTestCase;

/**
 * Tests for MyExtension.
 *
 * @group my_extension
 * @coversDefaultClass \Drupal\my_extension\Plugin\EbExtension\MyExtension
 */
class MyExtensionTest extends UnitTestCase {

    /**
     * The extension under test.
     */
    protected MyExtension $extension;

    /**
     * {@inheritdoc}
     */
    protected function setUp(): void {
        parent::setUp();

        $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);

        $this->extension = new MyExtension(
            [],
            'my_extension',
            [
                'id' => 'my_extension',
                'yaml_keys' => ['my_entity_definitions'],
                'operations' => ['create_my_entity', 'update_my_entity'],
            ],
            $entityTypeManager,
        );
    }

    /**
     * @covers ::buildOperations
     */
    public function testBuildOperationsEmpty(): void {
        $result = $this->extension->buildOperations([]);
        $this->assertEmpty($result);
    }

    /**
     * @covers ::buildOperations
     */
    public function testBuildOperationsWithData(): void {
        $data = [
            'my_entity_definitions' => [
                [
                    'entity_type' => 'node',
                    'bundle' => 'article',
                    'entity_id' => 'test_entity',
                    'label' => 'Test Entity',
                ],
            ],
        ];

        $result = $this->extension->buildOperations($data);

        $this->assertCount(1, $result);
        $this->assertEquals('create_my_entity', $result[0]['operation']);
        $this->assertEquals('node', $result[0]['entity_type']);
        $this->assertEquals('article', $result[0]['bundle']);
        $this->assertEquals('test_entity', $result[0]['entity_id']);
    }

    /**
     * @covers ::appliesTo
     */
    public function testAppliesTo(): void {
        $this->assertTrue($this->extension->appliesTo(['operation' => 'create_my_entity']));
        $this->assertTrue($this->extension->appliesTo(['operation' => 'update_my_entity']));
        $this->assertFalse($this->extension->appliesTo(['operation' => 'create_field']));
    }

    /**
     * @covers ::getYamlKeys
     */
    public function testGetYamlKeys(): void {
        $keys = $this->extension->getYamlKeys();
        $this->assertContains('my_entity_definitions', $keys);
    }

}

Extension Interface Reference#

Method Required Purpose
buildOperations() Yes Convert definition data to operation arrays
getOperationDependencies() No Declare dependencies for topological sort
detectChanges() No Custom change detection for sync mode
checkDependencies() No Verify custom dependencies exist
appliesTo() No Check if extension handles an operation
getYamlKeys() No Get YAML keys from plugin definition
getOperations() No Get operation types from plugin definition
extractConfig() No Extract existing config for export

Integration Points#

Extensions integrate at these points in the processing pipeline:

  1. OperationDataBuilder::build() - Calls extension->buildOperations()
  2. DependencyResolver::findDependencies() - Calls extension->getOperationDependencies()
  3. ChangeDetector::determineOperation() - Calls extension->detectChanges()
  4. DefinitionGenerator::generate() - Calls extension->extractConfig()