Extending Transport

Transport resolves every element and custom field through pluggable handler registries. You can add support for your own element types or third-party field types without modifying Transport.

Concepts

  • Portable identity. A package never contains environment-local element IDs. Every reference is stored as a UID and resolved back to a local ID on import — your handlers must follow this rule.
  • Field handlers convert a single custom field’s value to and from the portable representation (FieldHandlerInterface).
  • Element handlers convert a whole element type — its type-specific attributes and how to recreate it (ElementHandlerInterface).

Registering a field handler

use justinholtweb\transport\services\FieldRegistry;
use justinholtweb\transport\events\RegisterFieldHandlersEvent;
use yii\base\Event;

Event::on(
    FieldRegistry::class,
    FieldRegistry::EVENT_REGISTER_FIELD_HANDLERS,
    function (RegisterFieldHandlersEvent $event) {
        // Earlier entries win, so prepend to take precedence over the built-ins.
        array_unshift($event->handlers, MyFieldHandler::class);
    }
);

A field handler declares which fields it handles and (de)serializes their values:

interface FieldHandlerInterface
{
    public function canHandle(FieldInterface $field): bool;
    public function serialize(ElementInterface $element, FieldInterface $field): mixed;
    public function normalize(mixed $data, FieldInterface $field, ?ElementInterface $element): mixed;
    public function collectReferences(ElementInterface $element, FieldInterface $field): array;
}

collectReferences() returns the UIDs the field value depends on, so the dependency resolver imports those elements first.

For scalar / self-contained fields you don’t need a handler at all — the generic BaseFieldHandler delegates to Craft’s own (de)serialization, which is correct for text, number, dropdown, date, money, and similar fields (including Google Maps address fields, which store self-contained JSON).

Registering an element handler

use justinholtweb\transport\services\ElementRegistry;
use justinholtweb\transport\events\RegisterElementHandlersEvent;

Event::on(
    ElementRegistry::class,
    ElementRegistry::EVENT_REGISTER_ELEMENT_HANDLERS,
    function (RegisterElementHandlersEvent $event) {
        $event->handlers[] = MyElementHandler::class;
    }
);

Extend BaseElementHandler and implement the type-specific pieces — the element type, a package key (its filename and UI bucket), the query, attribute (de)serialization as UIDs, and how to build and apply a new element. The serializer owns the common envelope (uid, per-site title/slug/enabled, custom field values); your handler only deals with what’s specific to the type.

Matching content that already exists

When a package element’s UID isn’t found in the target, Transport asks the handler whether that element already exists here under a different UID, and updates it instead of adding a duplicate. Handlers extending BaseElementHandler opt in by overriding matchExisting():

public function matchExisting(array $data, ?int $siteId = null): ?ElementInterface
{
    $sku = $data['attributes']['sku'] ?? null;
    if (!$sku) {
        return null;
    }

    return $this->scopeToSite(MyElement::find()->sku($sku)->status(null), $siteId)->one();
}

Return an element only when the natural key genuinely identifies the same content — the key Craft (or your own code) would refuse to duplicate anyway. $data is the full serialized payload and $siteId restricts the lookup to one site; scopeToSite() and slugsFrom() on the base class cover the common shapes. The fallback never applies to references between elements, and users can disable it in settings, so a handler must never depend on it running.

Run reports and progress

Both after-events carry a TransportReport describing what actually happened, element by element — totals per outcome, a breakdown by type, the rows behind each outcome, and any errors. The same report is stored on the history record, printed by the console commands, and emailed on completion.

$report->countOf(TransportReport::ACTION_CREATED);   // totals per outcome
$report->getCountsByKey();                           // the same, broken down by type
$report->itemsFor(TransportReport::ACTION_SKIPPED);  // rows: title, key, type, uid, detail
$report->summary();                                  // "created 12, updated 3, skipped 1, failed 0"

Long runs report progress through ProgressInterface. Pass your own implementation to Export::run() or Import::run() to drive a different display — Transport ships ConsoleProgress (used by the CLI), QueueProgress (used by the queue jobs), and NullProgress.

Conditional registration

Only register a handler when its host plugin is present, so Transport has no hard dependency:

if (class_exists(\some\plugin\fields\TheField::class)) {
    array_unshift($event->handlers, TheFieldHandler::class);
}

This is exactly how Transport registers its own Commerce, Hyper, Neo, and Super Table handlers. See the Integrations page for the full list of what ships built-in.

More in Development

Pairs well with Transport

For the developer on the project. Migrations in and between sites, a code editor field, schema docs, content inventories, reports and email testing.