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.