diff --git a/README.md b/README.md
index 625f625..b33be47 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,24 @@ This makes the app useful for internal directories, support operations, partner
The public API contract for this app is published as [openapi-full.json](https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json).
+## Data backup and import
+
+Run the app commands from the Nextcloud stack root, not from the host PHP environment.
+
+| Command | Description |
+| --- | --- |
+| `occ profile_fields:data:export --output=/tmp/profile_fields-export.json` | Export the current Profile Fields catalog and stored values. |
+| `occ profile_fields:data:import --input=/tmp/profile_fields-export.json --dry-run` | Validate an import payload without writing anything. |
+| `occ profile_fields:data:import --input=/tmp/profile_fields-export.json` | Apply the non-destructive `upsert` import. |
+| `occ profile_fields:data:clear --definitions --force` | Clear app definitions explicitly before reimporting into the same environment. |
+
+Notes:
+
+- The import contract is versioned with `schema_version` and reconciles definitions by `field_key` and values by `field_key + user_uid`.
+- The first delivery is non-destructive: missing items in the payload do not delete existing definitions or values.
+- Validation is all-or-nothing. If the payload contains an incompatible definition or a missing destination user, no database write is performed.
+- For a restore in the same environment, clear app data explicitly before reimporting.
+
## Screenshots

diff --git a/appinfo/info.xml b/appinfo/info.xml
index f1dd01d..c01cae8 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -28,7 +28,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
LibreCode
ProfileFields
- https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json
+ https://github.com/LibreCodeCoop/profile_fields/
organization
tools
diff --git a/tests/php/ControllerIntegration/Command/Data/ImportTest.php b/tests/php/ControllerIntegration/Command/Data/ImportTest.php
new file mode 100644
index 0000000..b99df45
--- /dev/null
+++ b/tests/php/ControllerIntegration/Command/Data/ImportTest.php
@@ -0,0 +1,256 @@
+ */
+ private array $createdUserIds = [];
+ /** @var list */
+ private array $createdFieldKeys = [];
+ /** @var list */
+ private array $payloadFiles = [];
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $container = (new App(Application::APP_ID))->getContainer();
+ $this->fieldDefinitionMapper = $container->get(FieldDefinitionMapper::class);
+ $this->fieldValueMapper = $container->get(FieldValueMapper::class);
+ $this->dataImportService = $container->get(DataImportService::class);
+ $this->userManager = $container->get(IUserManager::class);
+
+ self::ensureSchemaExists($container->get(IDBConnection::class));
+ }
+
+ protected function tearDown(): void {
+ foreach ($this->payloadFiles as $payloadFile) {
+ @unlink($payloadFile);
+ }
+
+ foreach ($this->createdUserIds as $userId) {
+ foreach ($this->fieldValueMapper->findByUserUid($userId) as $value) {
+ $this->fieldValueMapper->delete($value);
+ }
+ }
+
+ foreach ($this->createdFieldKeys as $fieldKey) {
+ $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey);
+ if ($definition instanceof FieldDefinition) {
+ $this->fieldDefinitionMapper->delete($definition);
+ }
+ }
+
+ foreach ($this->createdUserIds as $userId) {
+ $user = $this->userManager->get($userId);
+ if ($user instanceof IUser) {
+ $user->delete();
+ }
+ }
+
+ parent::tearDown();
+ }
+
+ public function testExecuteImportsValidPayloadIntoDatabase(): void {
+ $userId = $this->createUser('pf_import_cli_valid_user');
+ $fieldKey = 'pf_import_cli_valid_region';
+ $payloadFile = $this->createPayloadFile([
+ 'schema_version' => 1,
+ 'definitions' => [[
+ 'field_key' => $fieldKey,
+ 'label' => 'Region',
+ 'type' => 'text',
+ 'admin_only' => false,
+ 'user_editable' => true,
+ 'user_visible' => true,
+ 'initial_visibility' => 'users',
+ 'sort_order' => 10,
+ 'active' => true,
+ 'created_at' => '2026-03-16T10:00:00+00:00',
+ 'updated_at' => '2026-03-16T10:00:00+00:00',
+ ]],
+ 'values' => [[
+ 'field_key' => $fieldKey,
+ 'user_uid' => $userId,
+ 'value' => ['value' => 'LATAM'],
+ 'current_visibility' => 'users',
+ 'updated_by_uid' => $userId,
+ 'updated_at' => '2026-03-16T10:30:00+00:00',
+ ]],
+ ]);
+
+ $tester = new CommandTester(new Import($this->dataImportService));
+ $exitCode = $tester->execute([
+ '--input' => $payloadFile,
+ ]);
+
+ self::assertSame(0, $exitCode);
+ self::assertStringContainsString('Profile Fields data imported.', $tester->getDisplay());
+ self::assertStringContainsString('Definitions: 1 created, 0 updated, 0 skipped.', $tester->getDisplay());
+ self::assertStringContainsString('Values: 1 created, 0 updated, 0 skipped.', $tester->getDisplay());
+
+ $definition = $this->fieldDefinitionMapper->findByFieldKey($fieldKey);
+ self::assertInstanceOf(FieldDefinition::class, $definition);
+ self::assertSame('Region', $definition->getLabel());
+
+ $value = $this->fieldValueMapper->findByFieldDefinitionIdAndUserUid($definition->getId(), $userId);
+ self::assertInstanceOf(FieldValue::class, $value);
+ self::assertSame('{"value":"LATAM"}', $value->getValueJson());
+ }
+
+ public function testExecuteDryRunDoesNotPersistValidatedPayload(): void {
+ $userId = $this->createUser('pf_import_cli_dry_run_user');
+ $fieldKey = 'pf_import_cli_dry_run_alias';
+ $payloadFile = $this->createPayloadFile([
+ 'schema_version' => 1,
+ 'definitions' => [[
+ 'field_key' => $fieldKey,
+ 'label' => 'Alias',
+ 'type' => 'text',
+ 'admin_only' => false,
+ 'user_editable' => true,
+ 'user_visible' => true,
+ 'initial_visibility' => 'private',
+ 'sort_order' => 20,
+ 'active' => true,
+ ]],
+ 'values' => [[
+ 'field_key' => $fieldKey,
+ 'user_uid' => $userId,
+ 'value' => ['value' => 'ops-latam'],
+ 'current_visibility' => 'private',
+ 'updated_by_uid' => $userId,
+ 'updated_at' => '2026-03-16T11:00:00+00:00',
+ ]],
+ ]);
+
+ $tester = new CommandTester(new Import($this->dataImportService));
+ $exitCode = $tester->execute([
+ '--input' => $payloadFile,
+ '--dry-run' => true,
+ ]);
+
+ self::assertSame(0, $exitCode);
+ self::assertStringContainsString('Profile Fields data import dry-run completed.', $tester->getDisplay());
+ self::assertNull($this->fieldDefinitionMapper->findByFieldKey($fieldKey));
+ }
+
+ public function testExecuteFailsValidationWithoutPersistingData(): void {
+ $fieldKey = 'pf_import_cli_invalid_user';
+ $payloadFile = $this->createPayloadFile([
+ 'schema_version' => 1,
+ 'definitions' => [[
+ 'field_key' => $fieldKey,
+ 'label' => 'Specialty',
+ 'type' => 'text',
+ 'admin_only' => false,
+ 'user_editable' => true,
+ 'user_visible' => true,
+ 'initial_visibility' => 'users',
+ 'sort_order' => 30,
+ 'active' => true,
+ ]],
+ 'values' => [[
+ 'field_key' => $fieldKey,
+ 'user_uid' => 'pf_import_cli_missing_user',
+ 'value' => ['value' => 'support'],
+ 'current_visibility' => 'users',
+ 'updated_by_uid' => 'pf_import_cli_missing_user',
+ 'updated_at' => '2026-03-16T11:30:00+00:00',
+ ]],
+ ]);
+
+ $tester = new CommandTester(new Import($this->dataImportService));
+ $exitCode = $tester->execute([
+ '--input' => $payloadFile,
+ ]);
+
+ self::assertSame(1, $exitCode);
+ self::assertStringContainsString('Import validation failed.', $tester->getDisplay());
+ self::assertStringContainsString('values[0].user_uid does not exist in destination instance', $tester->getDisplay());
+ self::assertNull($this->fieldDefinitionMapper->findByFieldKey($fieldKey));
+ }
+
+ private function createUser(string $userId): string {
+ if ($this->userManager->get($userId) === null) {
+ $this->userManager->createUser($userId, $userId);
+ $this->createdUserIds[$userId] = $userId;
+ }
+
+ return $userId;
+ }
+
+ /**
+ * @param array $payload
+ */
+ private function createPayloadFile(array $payload): string {
+ $payloadFile = tempnam(sys_get_temp_dir(), 'profile-fields-cli-import-');
+ if ($payloadFile === false) {
+ throw new \RuntimeException('Failed to create temporary payload file');
+ }
+
+ file_put_contents($payloadFile, json_encode($payload, JSON_THROW_ON_ERROR));
+ $this->payloadFiles[] = $payloadFile;
+
+ foreach (($payload['definitions'] ?? []) as $definition) {
+ if (is_array($definition) && is_string($definition['field_key'] ?? null)) {
+ $this->createdFieldKeys[] = $definition['field_key'];
+ }
+ }
+
+ return $payloadFile;
+ }
+
+ private static function ensureSchemaExists(IDBConnection $connection): void {
+ if ($connection->tableExists('profile_fields_definitions') && $connection->tableExists('profile_fields_values')) {
+ return;
+ }
+
+ $nullOutputClass = '\\OC\\Migration\\NullOutput';
+ $schemaWrapperClass = '\\OC\\DB\\SchemaWrapper';
+
+ if (!class_exists($nullOutputClass) || !class_exists($schemaWrapperClass) || !method_exists($connection, 'getInner')) {
+ throw new \RuntimeException('Expected ConnectionAdapter in integration test setup');
+ }
+
+ $innerConnection = call_user_func([$connection, 'getInner']);
+ /** @var ISchemaWrapper $schema */
+ $schema = new $schemaWrapperClass($innerConnection->createSchema());
+ $migration = new Version1000Date20260309120000();
+ $schema = $migration->changeSchema(new $nullOutputClass(), static fn () => $schema, ['appVersion' => '0.0.1']);
+ if (!$schema instanceof ISchemaWrapper) {
+ throw new \RuntimeException('Expected schema wrapper after migration');
+ }
+
+ call_user_func([$connection, 'migrateToSchema'], $schema);
+ }
+}