Skip to content

Creating a Model Owner

This guide walks through implementing a Model Owner plugin that integrates your config entities with the Modeler API's visual modelers.

Prerequisites

  • A Drupal module with an existing config entity type.
  • The modeler_api module as a dependency.

Step 1: Create the plugin class

Create a PHP class in your module's src/Plugin/ModelerApiModelOwner/ directory:

<?php

namespace Drupal\my_module\Plugin\ModelerApiModelOwner;

use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\modeler_api\Attribute\ModelOwner;
use Drupal\modeler_api\Component;
use Drupal\modeler_api\Api;
use Drupal\modeler_api\Plugin\ModelerApiModelOwner\ModelOwnerBase;

#[ModelOwner(
  id: "my_module_workflow",
  label: new TranslatableMarkup("My Workflow"),
  description: new TranslatableMarkup("Visual modeler for My Module workflows."),
)]
class Workflow extends ModelOwnerBase {

  /**
   * {@inheritdoc}
   */
  public function modelIdExistsCallback(): array {
    return [\Drupal\my_module\Entity\Workflow::class, 'load'];
  }

  /**
   * {@inheritdoc}
   */
  public function configEntityProviderId(): string {
    return 'my_module';
  }

  /**
   * {@inheritdoc}
   */
  public function configEntityTypeId(): string {
    return 'my_module_workflow';
  }

  /**
   * {@inheritdoc}
   */
  public function configEntityBasePath(): ?string {
    return 'admin/config/workflow/my-workflows';
  }

  /**
   * {@inheritdoc}
   */
  public function supportedOwnerComponentTypes(): array {
    return [
      Api::COMPONENT_TYPE_START => 'trigger',
      Api::COMPONENT_TYPE_ELEMENT => 'action',
      Api::COMPONENT_TYPE_LINK => 'condition',
    ];
  }

  // ... implement remaining abstract methods
}

Step 2: Implement component management

The core of a Model Owner is mapping between the Modeler API's generic component types and your module's domain-specific plugins.

availableOwnerComponents(int $type): array

Return all available plugins for a component type:

public function availableOwnerComponents(int $type): array {
  return match ($type) {
    Api::COMPONENT_TYPE_START => $this->getEventPlugins(),
    Api::COMPONENT_TYPE_ELEMENT => $this->getActionPlugins(),
    Api::COMPONENT_TYPE_LINK => $this->getConditionPlugins(),
    default => [],
  };
}

protected function getActionPlugins(): array {
  // Use lazy getter injection -- constructor is final.
  if (!isset($this->actionManager)) {
    $this->actionManager = \Drupal::service('plugin.manager.my_action');
  }
  $plugins = [];
  foreach ($this->actionManager->getDefinitions() as $id => $def) {
    $plugins[$id] = $this->actionManager->createInstance($id);
  }
  return $plugins;
}

ownerComponent(int $type, string $id, array $config): ?PluginInspectionInterface

Instantiate a specific plugin by type and ID:

public function ownerComponent(int $type, string $id,
  array $config = []): ?PluginInspectionInterface {
  $manager = match ($type) {
    Api::COMPONENT_TYPE_START => $this->getEventManager(),
    Api::COMPONENT_TYPE_ELEMENT => $this->getActionManager(),
    Api::COMPONENT_TYPE_LINK => $this->getConditionManager(),
    default => NULL,
  };
  if ($manager === NULL) {
    return NULL;
  }
  try {
    return $manager->createInstance($id, $config);
  }
  catch (\Exception) {
    return NULL;
  }
}

ownerComponentId(int $type): string

Generate a unique ID for a new component:

public function ownerComponentId(int $type): string {
  $prefix = match ($type) {
    Api::COMPONENT_TYPE_START => 'Event',
    Api::COMPONENT_TYPE_ELEMENT => 'Action',
    Api::COMPONENT_TYPE_LINK => 'Condition',
    default => 'Component',
  };
  return $prefix . '_' . $this->uuidGenerator->generate();
}

Step 3: Implement the save cycle

usedComponents(ConfigEntityInterface $model): array

Return the components currently stored in the config entity:

public function usedComponents(ConfigEntityInterface $model): array {
  $components = [];

  // Build start components from the entity's event configuration.
  foreach ($model->get('events') ?? [] as $eventId => $event) {
    $successors = [];
    foreach ($event['successors'] ?? [] as $successor) {
      $successors[] = new \Drupal\modeler_api\ComponentSuccessor(
        $successor['target'],
        $successor['condition'] ?? '',
      );
    }
    $components[] = new Component(
      $this,
      $eventId,
      Api::COMPONENT_TYPE_START,
      $event['plugin'],
      $event['label'] ?? '',
      $event['configuration'] ?? [],
      $successors,
    );
  }

  // Similarly for actions and conditions...

  return $components;
}

resetComponents(ConfigEntityInterface $model): ModelOwnerInterface

Clear the entity's component storage before re-adding from the modeler:

public function resetComponents(ConfigEntityInterface $model): ModelOwnerInterface {
  $model->set('events', []);
  $model->set('actions', []);
  $model->set('conditions', []);
  return $this;
}

addComponent(ConfigEntityInterface $model, Component $component): bool

Add a single component from parsed raw data:

public function addComponent(ConfigEntityInterface $model,
  Component $component): bool {
  $type = $component->getType();
  $id = $component->getId();
  $pluginId = $component->getPluginId();
  $config = $component->getConfiguration();

  $successorData = [];
  foreach ($component->getSuccessors() as $successor) {
    $successorData[] = [
      'target' => $successor->getId(),
      'condition' => $successor->getConditionId(),
    ];
  }

  switch ($type) {
    case Api::COMPONENT_TYPE_START:
      $events = $model->get('events') ?? [];
      $events[$id] = [
        'plugin' => $pluginId,
        'label' => $component->getLabel(),
        'configuration' => $config,
        'successors' => $successorData,
      ];
      $model->set('events', $events);
      return TRUE;

    case Api::COMPONENT_TYPE_ELEMENT:
      // Similar for actions...
      return TRUE;

    case Api::COMPONENT_TYPE_LINK:
      // Similar for conditions...
      return TRUE;
  }

  return FALSE;
}

buildConfigurationForm(PluginInspectionInterface $plugin, ?string $modelId, bool $modelIsNew): array

Build the config form for a component plugin:

public function buildConfigurationForm(PluginInspectionInterface $plugin,
  ?string $modelId = NULL, bool $modelIsNew = TRUE): array {
  if ($plugin instanceof PluginFormInterface) {
    try {
      return $plugin->buildConfigurationForm([], new FormState());
    }
    catch (\Exception $e) {
      return ['error' => ['#markup' => $e->getMessage()]];
    }
  }
  return [];
}

Step 4: Using ComponentWrapperPlugin

If your components are not Drupal plugins (e.g., they are simple configuration arrays), wrap them using ComponentWrapperPlugin:

use Drupal\modeler_api\Plugin\ComponentWrapperPlugin;

public function availableOwnerComponents(int $type): array {
  if ($type === Api::COMPONENT_TYPE_START) {
    return [
      'manual_trigger' => new ComponentWrapperPlugin(
        type: Api::COMPONENT_TYPE_START,
        id: 'manual_trigger',
        configuration: [],
        label: 'Manual Trigger',
      ),
    ];
  }
  return [];
}

This pattern is used by the ai_agents module for agent sub-processes.

Step 5: Storage configuration

Override these methods to control how model data is stored:

// Default storage for all models of this owner.
public function defaultStorageMethod(): string {
  // Options: STORAGE_OPTION_THIRD_PARTY, STORAGE_OPTION_SEPARATE,
  // STORAGE_OPTION_NONE
  return Settings::STORAGE_OPTION_THIRD_PARTY;
}

// Prevent users from changing the storage method.
public function enforceDefaultStorageMethod(): bool {
  return FALSE; // TRUE to lock the storage method
}

AI Agents pattern

The ai_agents module uses STORAGE_OPTION_NONE with enforceDefaultStorageMethod() returning TRUE, because AI agent configuration is fully captured in the agent config entity itself -- no separate raw data storage is needed.

Step 6: Optional features

Replay data

If your model supports execution replay/debugging:

public function supportsReplayData(): bool {
  return TRUE;
}

public function getReplayData(string $hash): array {
  // Return execution trace data for the given hash.
  return $this->getReplayService()->load($hash);
}

public function getReplayDataByComponent(string $modelId,
  string $componentId): array {
  return $this->getReplayService()->loadByComponent($modelId, $componentId);
}

Testing

If your model supports in-modeler testing:

public function supportsTesting(): bool {
  return TRUE;
}

public function startTestJob(string $modelId,
  string $componentId): string|TranslatableMarkup {
  // Start an async test and return a job ID.
  return $this->getTestRunner()->start($modelId, $componentId);
}

public function pollTestJob(string $jobId): array|null|TranslatableMarkup {
  // NULL = still running, array = results, TranslatableMarkup = error.
  return $this->getTestRunner()->poll($jobId);
}

Provide links to external documentation for each component plugin:

public function docBaseUrl(): ?string {
  return 'https://docs.my-module.org/plugins';
}

public function pluginDocUrl(PluginInspectionInterface $plugin,
  string $pluginType): ?string {
  $base = $this->docBaseUrl();
  if ($base === NULL) {
    return NULL;
  }
  return $base . '/' . $pluginType . '/' . $plugin->getPluginId();
}

Complete minimal example

See the ai_agents module for a real-world Model Owner implementation, or the ECA module's eca_ui submodule for a full-featured implementation with status, templates, testing, and replay support.