This document describes the generic, extensible transfer features added to HyperFields.
Goal:
- Keep HyperFields framework-agnostic.
- Support option-backed and content-backed portability.
- Allow downstream libraries/apps to plug in custom modules without forking HyperFields.
Class: HyperFields\ExportImport
Every exported option is wrapped in a typed-node envelope — a JSON object containing the raw value alongside a _schema descriptor that declares what the value should look like. This makes export files self-describing and allows import-time validation without external schema files.
{
"version": "1.0",
"type": "hyperfields_export",
"prefix": "",
"exported_at": "2026-03-31 12:00:00",
"site_url": "https://example.com",
"options": {
"my_plugin_color": {
"value": "#7f54b3",
"__strategy": "replace",
"_schema": {
"type": "string",
"format": "hex_color"
}
},
"my_plugin_settings": {
"value": {
"enabled": "yes",
"recipients": "admin@example.com"
},
"_schema": {
"type": "array",
"fields": {
"enabled": { "type": "string", "enum": ["yes", "no"] },
"recipients": { "type": "string", "max": 2000, "format": "email_csv" }
}
}
}
}
}On import, HyperFields rejects any option node that is missing the typed-node envelope (value + _schema). The value is validated against all _schema rules before being written to the database.
HyperFields supports an optional __strategy key per option node.
Default strategy is replace.
Supported values:
merge,migrate: import using merge semantics.replace,override: import using replace semantics.create: only create when option does not already exist.skip: ignore this node.delete: delete destination option.recreate: delete then create from incoming value.
If no strategy is provided, import mode falls back to the global import mode option.
In the HyperFields admin Import Preview screen, a Strategy Summary panel
shows the detected __strategy values and counts before confirmation.
Pass an optional $schemaMap to exportOptions() to embed full validation rules in each option's _schema. When no schema entry exists for an option, only the auto-detected PHP type is recorded.
use HyperFields\ExportImport;
// Auto-detected types only (basic safety)
$json = ExportImport::exportOptions(['my_plugin_options']);
// Full schema rules embedded in the export
$json = ExportImport::exportOptions(
['my_plugin_color', 'my_plugin_name', 'my_plugin_settings'],
'', // prefix
[
'my_plugin_color' => [
'type' => 'string',
'format' => 'hex_color',
],
'my_plugin_name' => [
'type' => 'string',
'max' => 255,
],
'my_plugin_settings' => [
'type' => 'array',
'fields' => [
'enabled' => ['type' => 'string', 'enum' => ['yes', 'no']],
'recipients' => ['type' => 'string', 'max' => 2000, 'format' => 'email_csv'],
],
],
]
);$result = ExportImport::importOptions(
$json,
['my_plugin_color', 'my_plugin_name', 'my_plugin_settings'],
'',
['mode' => 'replace']
);
if ($result['success']) {
// All values passed schema validation and were written.
}Import validation is automatic. Every option node in the JSON file must:
- Have a
valuekey and a_schemaobject. - Declare a valid
_schema.type(string,integer,double,boolean,array, ornull). - Have a
valuewhose actual PHP type matches the declared type. - Pass all additional schema rules (
max,min,enum,pattern,format,fields).
Options that fail validation are rejected with a descriptive error message. The import continues processing remaining options.
$diff = ExportImport::diffOptions(
$json,
['my_plugin_color', 'my_plugin_settings'],
'',
['mode' => 'replace']
);
// $diff['changes'] — options that would change
// $diff['skipped'] — options skipped (validation errors, whitelist misses)Diff applies the same typed-node validation as import. Invalid nodes appear in skipped with an error message.
merge(default): incoming array keys are merged into the existing option value. Existing keys not present in the import are preserved.replace: incoming value replaces the stored value entirely.
- Prefix filters apply to array keys only. Scalar option values are skipped when a non-empty prefix is provided.
- Backups use transient keys (
backup_keys) with 1-hour expiry. Restore viaExportImport::restoreBackup($key, $optionName). - The
hyperfields/export/afterandhyperfields/import/afteractions fire after export/import completes.
Class: HyperFields\Validation\SchemaValidator
A standalone validation engine for arbitrary PHP values. Does not require a bound Field instance — usable anywhere: import validation, REST API endpoints, form processing, CLI commands.
A schema rule is an associative array describing the expected shape of a value:
[
'type' => 'string', // Required. PHP type.
'max' => 255, // Max string length (mb_strlen).
'min' => 1, // Min string length (mb_strlen).
'pattern' => '/^[a-z0-9-]+$/', // PCRE regex the value must match.
'enum' => ['yes', 'no'], // Whitelist of allowed values.
'format' => 'email', // Semantic format (see table below).
'fields' => [ ... ], // Sub-schema for array values.
]All keys except type are optional.
| Type | PHP equivalent |
|---|---|
string |
is_string() |
integer |
is_int() |
double |
is_float() |
boolean |
is_bool() |
array |
is_array() |
null |
is_null() |
null values always pass validation regardless of declared type (the option may not exist on the source site).
| Format | Description |
|---|---|
email |
Valid email address (non-empty). |
email_or_empty |
Valid email or empty string. |
email_csv |
Comma-separated list of valid emails (non-empty). |
email_csv_or_empty |
Comma-separated emails or empty string. |
hex_color |
CSS hex colour: #rrggbb (6 digits, case-insensitive). |
url |
Valid URL (non-empty). |
url_or_empty |
Valid URL or empty string. |
Custom formats can be registered via the hyperfields/validation/format filter:
add_filter('hyperfields/validation/format', function (?string $error, string $fieldName, string $value, string $format): ?string {
if ($format === 'slug' && !preg_match('/^[a-z0-9-]+$/', $value)) {
return sprintf('"%s" is not a valid slug.', $fieldName);
}
return $error;
}, 10, 4);use HyperFields\Validation\SchemaValidator;
$error = SchemaValidator::validate('email_from', $value, [
'type' => 'string',
'max' => 320,
'format' => 'email',
]);
if ($error !== null) {
// $error is a human-readable string like:
// '"email_from" is not a valid email address.'
}$settings = [
'enabled' => 'yes',
'color' => '#ff0000',
'recipients' => 'a@b.com, c@d.com',
];
$schema = [
'enabled' => ['type' => 'string', 'enum' => ['yes', 'no']],
'color' => ['type' => 'string', 'format' => 'hex_color'],
'recipients' => ['type' => 'string', 'max' => 2000, 'format' => 'email_csv'],
];
$errors = SchemaValidator::validateMap($settings, $schema);
// [] — empty array means all values are valid.
// With prefix for error messages:
$errors = SchemaValidator::validateMap($settings, $schema, 'my_plugin');
// Error messages like: '"my_plugin.color" must be a hex colour (#rrggbb)...'if (SchemaValidator::isValid($value, ['type' => 'string', 'format' => 'email'])) {
// Value is a valid email string.
}$type = SchemaValidator::detectType($value);
// Returns: 'string', 'integer', 'double', 'boolean', 'array', or 'null'Use fields to validate the structure of an array value:
$schema = [
'type' => 'array',
'fields' => [
'enabled' => ['type' => 'string', 'enum' => ['yes', 'no']],
'subject' => ['type' => 'string', 'max' => 500],
'email_type' => ['type' => 'string', 'enum' => ['html', 'plain', 'multipart']],
],
];
$error = SchemaValidator::validate('new_order_settings', $settings, $schema);Fields not present in the fields sub-schema are ignored (pass-through). Fields present in fields but absent from the value are also ignored. Only fields that exist in both are validated.
Procedural wrappers are available for quick use without importing the class:
// Validate a single value
$error = hf_validate_value('field_name', $value, ['type' => 'string', 'max' => 255]);
// Validate a map
$errors = hf_validate_schema($values, $schemaMap, 'prefix');
// Boolean check
$ok = hf_is_valid($value, ['type' => 'string', 'format' => 'email']);
// Detect type
$type = hf_detect_type($value);This example shows how a WordPress plugin would use HyperFields to export and import its settings with full schema validation.
class MyPluginSettings
{
private const SCHEMA = [
'my_plugin_api_url' => ['type' => 'string', 'max' => 2083, 'format' => 'url'],
'my_plugin_api_key' => ['type' => 'string', 'max' => 255],
'my_plugin_enabled' => ['type' => 'string', 'enum' => ['yes', 'no']],
'my_plugin_max_retries'=> ['type' => 'string', 'pattern' => '/^\d{1,2}$/'],
'my_plugin_color' => ['type' => 'string', 'format' => 'hex_color'],
'my_plugin_recipients' => ['type' => 'string', 'max' => 2000, 'format' => 'email_csv_or_empty'],
];
public function export(): string
{
return ExportImport::exportOptions(
array_keys(self::SCHEMA),
'',
self::SCHEMA
);
}
public function import(string $json): array
{
// HyperFields validates every option against its _schema automatically.
return ExportImport::importOptions(
$json,
array_keys(self::SCHEMA),
'',
['mode' => 'replace']
);
}
public function validate_before_save(array $values): array
{
// Use SchemaValidator directly for form/REST validation.
return SchemaValidator::validateMap($values, self::SCHEMA);
}
}- HyperFields parses the JSON and finds
my_plugin_api_url. - It checks for a typed-node envelope (
value+_schema). Missing? Rejected. - It reads
_schema.type=stringand confirms the value is a PHP string. - It reads
_schema.format=urland validates the URL format. - It reads
_schema.max=2083and checks string length. - All checks pass? The value is written via
update_option(). - Any check fails? The option is skipped with a descriptive error, and import continues.
You can also use SchemaValidator directly for validating user input on settings pages, REST endpoints, or anywhere else:
// In your settings save handler:
$errors = hf_validate_schema($_POST['settings'], MyPluginSettings::SCHEMA);
if (!empty($errors)) {
foreach ($errors as $error) {
add_settings_error('my_plugin', 'validation', $error);
}
return;
}
// All valid — save.Class: HyperFields\ContentExportImport
This is a generic content portability utility for post-like records (pages + CPT).
use HyperFields\ContentExportImport;
$json = ContentExportImport::exportPosts(
['page', 'my_cpt'],
[
'post_status' => ['publish', 'draft', 'private'],
'include_meta' => true,
'include_private_meta' => false,
'include_meta_keys' => [], // optional allowlist
'exclude_meta_keys' => ['_edit_lock'],
'include_content' => true,
'include_excerpt' => true,
'include_parent' => true,
]
);$snapshot = ContentExportImport::snapshotPosts(['page', 'my_cpt']);$result = ContentExportImport::importPosts(
$json,
[
'allowed_post_types' => ['page', 'my_cpt'],
'dry_run' => false,
'create_missing' => true,
'update_existing' => true,
'include_meta' => true,
'meta_mode' => 'merge', // 'merge' | 'replace'
'include_private_meta' => false,
'include_meta_keys' => [],
'exclude_meta_keys' => ['_edit_lock'],
'normalization_profile' => '', // optional profile key for row normalization hooks
]
);$preview = ContentExportImport::diffPosts($json, [
'allowed_post_types' => ['page', 'my_cpt'],
]);Import matching rules:
- Canonical identity =
post_type + slug. - If incoming
idis present, HyperFields first checks destination post by ID and only treats it as the same record when bothpost_typeandslugalso match. - Otherwise (or when ID check fails), existing records are resolved by
get_page_by_path($slug, OBJECT, $post_type). - If canonical slug lookup misses, HyperFields also checks trashed records by
_wp_desired_post_slugto preserve slug ownership during restore flows.
Custom rule hooks:
hyperfields/content_import/resolve_existing_postlets you override how an existing destination post is resolved.hyperfields/content_import/row_decisionlets you decide per row whether tocreate,merge,delete,recreate, orskip(with optionaltarget_id).hyperfields/content_import/normalize_rowlets you normalize incoming rows before matching/writes.hyperfields/content_import/normalize_row/profile_{profile}lets you attach reusable profile-specific normalizers vianormalization_profile.
Each exported content row includes __strategy with default value replace.
You can edit this in JSON before import.
Supported values:
merge,migrate,override,replace: merge into existing if found, otherwise create.create,new: force create.delete: delete matched destination row.recreate: delete matched destination row (if present) then create new row.skip: do nothing for this row.
add_filter(
'hyperfields/content_import/row_decision',
static function (array $decision, array $row, string $postType, string $slug, array $options): array {
// If a row has recreate=true in payload meta, force recreate.
if (!empty($row['meta']['recreate'][0])) {
return [
'action' => 'recreate',
'reason' => 'forced_recreate',
];
}
return $decision;
},
10,
5
);add_filter(
'hyperfields/content_import/resolve_existing_post',
static function ($resolved, array $row, string $postType, string $slug) {
$externalId = isset($row['meta']['external_id'][0]) ? (string) $row['meta']['external_id'][0] : '';
if ($externalId === '') {
return $resolved;
}
$query = new WP_Query([
'post_type' => $postType,
'post_status' => 'any',
'posts_per_page' => 1,
'meta_key' => 'external_id',
'meta_value' => $externalId,
'fields' => 'ids',
'no_found_rows' => true,
]);
if (!empty($query->posts)) {
return (int) $query->posts[0];
}
return $resolved;
},
10,
4
);use HyperFields\Transfer\Manager;
// Skip selected modules in production.
add_filter(
'hyperfields/transfer_manager/import/module_decision',
static function (array $decision, string $moduleKey, $payload, array $context, array $bundle): array {
if (wp_get_environment_type() === 'production' && in_array($moduleKey, ['emails', 'content'], true)) {
return [
'action' => 'skip',
'reason' => 'blocked_in_production',
];
}
return $decision;
},
10,
5
);
// Inject per-module flags consumed by your importer callback.
add_filter(
'hyperfields/transfer_manager/import/module_context',
static function (array $context, string $moduleKey, $payload, array $bundle): array {
$context['strict_mode'] = ($moduleKey === 'content');
return $context;
},
10,
4
);Class: HyperFields\Transfer\Manager
Use this when you need HyperFields as the base transfer library, while composing project-specific modules externally.
use HyperFields\Transfer\Manager;
$manager = new Manager();
$manager->registerModule(
'options',
exporter: function (array $context): array {
return ['json' => \HyperFields\ExportImport::exportOptions($context['option_names'] ?? [])];
},
importer: function (array $payload, array $context): array {
return \HyperFields\ExportImport::importOptions(
(string) ($payload['json'] ?? ''),
$context['allowed_options'] ?? []
);
},
differ: function (array $payload, array $context): array {
return \HyperFields\ExportImport::diffOptions(
(string) ($payload['json'] ?? ''),
$context['allowed_options'] ?? []
);
}
);$bundle = $manager->export(); // or export(['options'])
$diff = $manager->diff($bundle);
$apply = $manager->import($bundle);Module payloads may include __strategy.
Default module strategy in runtime context is replace.
Current built-in behavior:
skip=> module is skipped automatically.- Any other value => module importer runs normally, and strategy is exposed to the importer via module context (
$context['strategy']).
This allows module authors to implement project-specific logic for values such as
merge, recreate, delete, override, or migrate.
Default bundle shape:
{
"schema_version": 1,
"type": "hyperfields_transfer_bundle",
"generated_at": "...",
"modules": {
"options": {}
},
"errors": []
}Class: HyperFields\Transfer\SchemaConfig
By default the export envelope uses HyperFields type identifiers and schema version 1. Use SchemaConfig to override any of these values and inject additional top-level keys (e.g. site, environment, app_version) without modifying HyperFields itself.
use HyperFields\Transfer\Manager;
use HyperFields\Transfer\SchemaConfig;
$manager = (new Manager())->withSchema(new SchemaConfig(
type: 'my_plugin_manifest',
schema_version: 2,
extra: [
'site' => [
'url' => get_site_url(),
'environment' => defined('WP_ENVIRONMENT_TYPE') ? WP_ENVIRONMENT_TYPE : 'production',
],
],
));
$bundle = $manager->export();Resulting bundle shape:
{
"site": {
"url": "https://example.com",
"environment": "staging"
},
"schema_version": 2,
"type": "my_plugin_manifest",
"generated_at": "...",
"modules": {
"options": {}
},
"errors": []
}withSchema() returns the same Manager instance for fluent chaining. Calling it is optional — omitting it preserves the original default envelope.
Reserved keys — the following keys may not be overridden via extra and are silently stripped if present: schema_version, type, generated_at, modules, errors.
HyperFields::diffOptions(...)HyperFields::exportPosts(...)HyperFields::snapshotPosts(...)HyperFields::importPosts(...)HyperFields::diffPosts(...)HyperFields::makeTransferManager()
Export/Import:
hf_export_options(array $optionNames, string $prefix = '', array $schemaMap = []): stringhf_import_options(string $json, array $allowed = [], string $prefix = '', array $options = []): arrayhf_diff_options(string $json, array $allowed = [], string $prefix = '', array $options = []): array
Content:
hf_export_posts(array $postTypes, array $options = []): stringhf_snapshot_posts(array $postTypes, array $options = []): arrayhf_import_posts(string $json, array $options = []): arrayhf_diff_posts(string $json, array $options = []): array
Schema Validation:
hf_validate_value(string $fieldName, mixed $value, array $rule): ?stringhf_validate_schema(array $values, array $schemaMap, string $prefix = ''): arrayhf_is_valid(mixed $value, array $rule): boolhf_detect_type(mixed $value): string
| Hook | Type | Fired by | Parameters |
|---|---|---|---|
hyperfields/export/after |
Action | ExportImport::exportOptions() |
$result, $payload, $optionNames, $prefix, $schemaMap |
hyperfields/export/node_strategy |
Filter | ExportImport::exportOptions() |
$strategy, $optionName, $value |
hyperfields/import/after |
Action | ExportImport::importOptions() |
$result, $decoded, $allowedOptionNames, $prefix, $options |
hyperfields/content_export/after |
Action | ContentExportImport::exportPosts() |
$result, $payload, $postTypes, $options |
hyperfields/content_export/row_strategy |
Filter | ContentExportImport::normalizeExportPost() |
$strategy, $post |
hyperfields/content_import/normalize_row |
Filter | ContentExportImport::importPosts() |
$row, $postType, $slug, $options |
hyperfields/content_import/normalize_row/profile_{profile} |
Filter | ContentExportImport::importPosts() |
$row, $postType, $slug, $options |
hyperfields/content_import/resolve_existing_post |
Filter | ContentExportImport::resolveExistingPost() |
$resolved, $row, $postType, $slug |
hyperfields/content_import/row_decision |
Filter | ContentExportImport::importPosts() |
$decision, $row, $postType, $slug, $options |
hyperfields/content_import/after |
Action | ContentExportImport::importPosts() |
$result, $decoded, $options |
hyperfields/transfer_manager/export/after |
Action | Transfer\Manager::export() |
$result, $selectedModuleKeys, $context |
hyperfields/transfer_manager/import/module_payload |
Filter | Transfer\Manager::import() |
$payload, $moduleKey, $context, $bundle |
hyperfields/transfer_manager/import/module_decision |
Filter | Transfer\Manager::import() |
$decision, $moduleKey, $payload, $context, $bundle |
hyperfields/transfer_manager/import/module_context |
Filter | Transfer\Manager::import() |
$context, $moduleKey, $payload, $bundle |
hyperfields/transfer_manager/import/after |
Action | Transfer\Manager::import() |
$result, $attemptedModuleKeys, $context |
hyperfields/import/ui_notice_type |
Filter | Admin\ExportImportUI |
$noticeType, $importResult, $importSuccess |
hyperfields/import/ui_notice_message |
Filter | Admin\ExportImportUI |
$message, $importResult, $importSuccess |
hyperfields/import/ui_notice_extra_html |
Filter | Admin\ExportImportUI |
$html, $importResult, $importSuccess |
hyperfields/import/ui_notice_after |
Action | Admin\ExportImportUI |
$importResult, $importSuccess |
hyperfields/transfer_logs/ui_enabled |
Filter | Admin\TransferLogsUI |
$enabled (default true) |
hyperfields/transfer_logs/retention_days |
Filter | Transfer\AuditLogStorage |
$days (default 180) |
hyperfields/transfer_logs/prune_interval_seconds |
Filter | Transfer\AuditLogStorage |
$seconds (default 86400) |
hyperfields/validation/format |
Filter | SchemaValidator |
$error, $fieldName, $value, $format — return string to reject, null to accept |
hyperfields/export/filename_prefix |
Filter | ExportImportUI |
$prefix — customise the download filename |
HyperFields now includes built-in transfer audit logging for options, content, and transfer-manager operations.
Storage behavior:
- Logs are written to a dedicated table:
{wp_prefix}hyperfields_transfer_logs. - Schema setup is lazy and idempotent. Migration is only attempted when the logger is first used.
- Runtime guardrails:
- request-local migration guard (runs once per request),
- schema-version option guard,
- transient lock to avoid repeated concurrent migration work.
- If DB logging is unavailable, HyperFields falls back to file logging via
HyperFields\Log.
Retention behavior:
- Default retention: 180 days.
- Expired rows are pruned lazily via
AuditLogStorage::maybePruneExpired(). - Pruning does not run on every request; it is interval-gated (default once per 24h).
Retention customization:
// Keep transfer logs for 90 days instead of 180.
add_filter('hyperfields/transfer_logs/retention_days', static fn (int $days): int => 90);
// Run lazy prune checks at most once per hour.
add_filter('hyperfields/transfer_logs/prune_interval_seconds', static fn (int $seconds): int => HOUR_IN_SECONDS);Notes:
- Audit rows are metadata-only (operation, status, source, counts, hash, summary). Raw import/export payloads are not stored.
- Transfer-manager runs are de-duplicated so nested option/content events do not generate duplicate rows for the same operation.
Recommended pattern for downstream integrations:
- Keep HyperFields generic and reusable.
- Define your schema as a PHP constant or config array in your plugin.
- Pass the schema to
exportOptions()so it travels with the export file. - On import, HyperFields validates each value against its
_schemaautomatically. - For additional domain-specific validation, use
SchemaValidator::validate()orSchemaValidator::validateMap()in your module's import logic. - Register project-specific transfer modules in your application/plugin layer using
Transfer\Manager. - Use
diffOptions()/diffPosts()for safe dry-run previews before import. - Register custom format validators via the
hyperfields/validation/formatfilter.