Visitar URL original
[6.x] Importing by i-just · Pull Request #19612 · craftcms/cms · GitHub
Skip to content

[6.x] Importing - #19612

Open
i-just wants to merge 376 commits into
6.xfrom
feature/import
Open

i-just wants to merge 376 commits into
6.xfrom
feature/import

Conversation

@i-just

@i-just i-just commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

It imports data from JSON, CSV or XML sources (local files or URLs) into Craft CMS elements or Eloquent models.

Core concepts:

  • Data Types => the data sources this feature can work with
  • Transformers => normalise and manipulate incoming data, and sometimes map it to existing “slots”
  • Importers => do the heavy lifting. Each importer knows how to write data into one specific target (e.g. Entries, Assets, Users, System Messages)
  • Field import handlers => optional, import-specific handling for a field type (e.g. creating assets from files referenced in an Assets field)
  • Import Plans => a named, ordered list of steps. Each step pairs an importer with its own source, transformer, batch size, settings and mapping

Extensibility:

  • you can register extra data types, importer types and field import handlers
  • you can register non-editable import plans in code from a service provider
  • you can manipulate your data any way you want via transformers
  • you can add a CLI command for your own importer by extending CraftCms\Cms\Import\Commands\ImportCommand

Data Types:

  • there are 3 built-in data types: JSON, XML and CSV
  • extra types can be registered from a service provider’s boot() via CraftCms\Cms\Import\DataTypes\DataTypes::register(). To replace a built-in one, remove() it first

Transformers:

  • there are 3 built-in transformers, all related to element importing:
    • ElementTransformer => the base element transformer, and the default for element importers without their own (in core, Users)
    • EntryTransformer => default when importing Entries
    • AssetTransformer => default when importing Assets
  • ElementTransformer auto-matches incoming keys to attributes and field handles even when the names aren’t exact (e.g. Plain Text / plain_text → plainText), and supports convention-based normalize{PropName}()
    methods
  • you can create your own transformers and use them to manipulate the data however you wish. For example, you can uppercase every value imported into myPlainTextField, or run complex checks to decide what value
    myOtherField gets. A transformer can be given as an instance or a class name

Importers:

  • each importable target has its own concrete importer class. Being importable means having an importer registered:
    • ElementImporter (abstract) => EntryImporter, AssetImporter, UserImporter. Each implements targetClass(). Common settings: site (required) and fieldLayout
      • EntryImporter adds section and entryType, and can set an entry’s parent (parentId) in Structure sections
      • AssetImporter adds volume
      • UserImporter won’t import sensitive attributes like admin or password; the only sensitive ones it allows are affiliatedSiteId, email, passwordResetRequired and photoId. photoId can also be a URL or a
        local path to the photo file
    • ModelImporter (abstract) => SystemMessageImporter is the only built-in. Its mappable destinations are the model table’s columns
  • Address has no importer, so it’s never a standalone target. It can only be imported as nested content (Addresses field / User addresses)
  • each importer defines its own settings form and validation rules
  • every step has a batch size (default 100, max 1000, 0 disables batching)
  • extra importer types (e.g. for plugin element types or models) can be registered from a service provider’s boot() via CraftCms\Cms\Import\ImporterTypes::register()

Field import handlers:

  • import-specific work for a field type can live in a class implementing CraftCms\Cms\Import\FieldHandlers\FieldImportHandlerInterface, so the field itself needs no import code
  • a handler normalizes the incoming value, can offer extra per-field settings in the mapping UI, and can queue side effects that should only happen once the item has been imported ($importer->afterItemImported())
  • register one from a service provider’s boot() via CraftCms\Cms\Import\FieldHandlers\FieldImportHandlers::register(). A field uses the handler for its own class or its nearest parent class
  • the built-in AssetsFieldImportHandler lets an Assets field value mix asset IDs with URLs and local file paths, creating assets from the files in the field’s upload location
    • the fileConflict setting decides what happens when a file with the same name already exists: use the existing asset (default), replace its file, or create a new asset
    • local paths must be within Craft’s temp folder, or within the project root but outside the storage folder. Dotfiles, dot-prefixed folders and restricted directories aren’t allowed
    • local paths are refused altogether when the step’s source is remote

Import plans:

  • there are 2 kinds of import plans:
    • editable => created via the Control Panel and stored in the database (import_plans)
    • non-editable => registered in code from a service provider’s boot() via CraftCms\Cms\Import\ImportPlan::register('handle', fn () => new ImportPlanData()...), and committed to your repository. The factory only
      runs when the plans are first loaded, so registering them adds no work at boot and doesn’t stop artisan config:cache from working. The registered handle becomes the plan’s handle. A plan that is invalid, or that sets a
      different handle, is skipped and logged. Registering the same handle twice throws; remove() unregisters a plan
  • for each step you:
    • choose the importer and fill in its settings. For example, for an Entry importer, you pick the site, section and entry type
    • specify the source that holds the data you want to import: a @root-relative or aliased path, or a URL
    • optionally choose a transformer and a batch size
    • define which importable attribute and field maps to which incoming value (map), or fill one from a template instead (templates)
    • choose which values should be used to match against existing elements (matchCriteria)
    • choose which values should be cleared out if they’re empty or missing from the incoming data (clearableItems)
    • choose whether you’d like to keep nested content that’s missing from the incoming data (keepMissingNestedElements)
  • URL sources are validated before they’re fetched. URLs resolving to private IP addresses are rejected unless their host is listed in the allowedPrivateHosts config setting; loopback, link-local and cloud metadata
    addresses are always rejected. The downloaded file’s contents must match its data type
  • steps run sequentially, and each step runs as its own batch, so one bad step doesn’t kill the whole plan. Per-item failures are logged and skipped, not fatal
  • when a step’s source has more items than its batch size, the first batch job reads it once and stores all its items on the importDisk (a local temp folder by default; use a shared disk if queue jobs can run on
    different
    servers), and each later job reads only its own items. They’re cleaned up when the step finishes, and stale ones are removed by garbage collection. Otherwise (batching disabled, or a job that stops early on a source that
    fits in one batch) a follow-up job reads the source again
  • batch jobs aren’t retried (that would import their items again), and a job hands the rest of its items to the next one if it gets close to the memory limit or timeout
  • editable plans can be duplicated and reordered

Mapping UI

  • a mapping table with Destination / Incoming data / Match / Clear columns. Container fields (Matrix, Addresses, Content Block) and container properties (User addresses) open a nested slideout, which can nest to any
    depth
  • The incoming-data select is a combobox that shows the value from the source’s first row next to each option, plus “Please select” (don’t import) and “Use template”.
  • “Use template” shows a Template field that takes plain text or an object template, like an entry type’s Title Format. Typing { lists the source’s columns as variables ({title}, or {{ object['First Name'] }} for names that aren’t identifiers). Templates work inside nested mappings too, where {blocks.heading} is the current block’s value. They’re rendered in Twig’s sandbox, since saving import plans doesn’t
    require admin, and checked when the mapping is applied and when the plan is saved
  • matchCriteria, clearableItems, and keepMissingNestedElements are reserved: incoming data using them, at any depth, is never offered as a column and can’t be read by a template
  • the most likely incoming column is pre-selected for each destination
  • attributes marked canBeSet: false (e.g., id, uid) can only be used for matching, not set

Match criteria — three tiers, last wins

  1. In the data — a matchCriteria key on the row, [systemHandle => incomingKey].
    Also looks inside $data['fields']. If the row has no value for that key, the value given is matched on as-is.
  2. Step-level — from the UI or a registered import plan, which is read once per step. It names the keys to match on; each row supplies the values, and a row with no value for a key isn’t matched on it.
  3. Transformer — additionalMatchCriteria(), already resolved to actual values.

Values from tiers 1 and 2 are matched literally (query syntax like * or not 5 isn’t applied); tier 3 values are used as given.

Clearable items

An array shaped like the match criteria. If a field or attribute is

  • marked clearable and its value is missing or empty in the incoming data, it’s forced to null and explicitly applied
  • not marked clearable and its value is empty in the incoming data, its key is unset() entirely, so the existing value is left untouched.

Importing:

  • you can trigger an import via the UI: CP > Import > Run, for editable and registered plans alike (requires triggerImportPlans)
  • or via the CLI, with one command per importer ({source} is a @root-relative or aliased path, or a URL):
    • artisan craft:import:entries {source} [--site=] [--section=] [--entry-type=] [--transformer=] [--match-criteria=]
    • artisan craft:import:assets {source} [--site=] [--volume=] …
    • artisan craft:import:users {source} …
    • artisan craft:import:system-messages {source} …
    • any options you leave out are prompted for (--site only on multisite), and --match-criteria takes JSON (invalid JSON is silently ignored)

Making things importable:

  • Elements & models: register a concrete ElementImporter / ModelImporter subclass via ImporterTypes::register()
  • CraftCms\Cms\Support\Attributes\Importable marks an element’s properties as importable. Its flags are excludeFromUiMapping, isContainer, canBeMatchCriteria, canBeCleared and canBeSet
  • CraftCms\Cms\Element\Contracts\ImportableContainerPropertiesInterface must be implemented by an element type with a container property (isContainer: true), like User’s addresses
  • CraftCms\Cms\FieldLayout\Contracts\ImportableFieldLayoutElementInterface makes field layout elements importable
  • CraftCms\Cms\Field\Contracts\ImportableElementContainerFieldInterface lets container fields be imported correctly
  • CraftCms\Cms\Import\FieldHandlers\FieldImportHandlerInterface adds import-specific handling for a field type

Events:

ItemImporting / ItemImported, ImportPlanSaving / ImportPlanSaved, ImportDispatching / ImportDispatched. The *ing events are cancellable; to cancel, a listener sets $event->isValid = false. ItemImported
also carries the element or model the data was imported into.

Each import run also fires lifecycle events, all sharing the run’s runId: ImportStarted, ImportStepStarted, ImportChunkStarted / ImportChunkFinished, ImportStepFinished and ImportFinished (the *Finished
events report whether anything failed). CLI imports fire these too, apart from the chunk events.

Config:

  • importDisk => the filesystem disk import runs store their parsed source data on (set it to a shared disk if queue jobs can run on different servers)
  • allowedPrivateHosts => hostnames a source URL may resolve to private IP addresses for

Permissions:

There’s a permissions group import with viewImportPlans and, nested under it, saveImportPlans, deleteImportPlans and triggerImportPlans.

  • running import plans can create and update elements and other data regardless of the user’s other permissions, which the permission’s warning points out
  • users who can view but not save import plans see the plans and their mapping read-only, and can only open the mapping for a saved step’s source, so they can’t make the server fetch a source of their choosing
Screenshot 2026-09-29 at 13 38 33 Screenshot 2026-09-29 at 13 39 05

Related issues

CMS-2390

brandonkelly and others added 3 commits October 26, 2026 09:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	resources/translations/en/app.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

There was one conflict, in resources/translations/en/app.php. Each branch had added a different translation string at the same spot. feature/import added 'Couldn’t copy “{file}” to a temp location.' and 6.x added 'Couldn’t copy to the clipboard.'. The two strings don't overlap, so I kept both and placed them in the file's existing sort order, where ASCII sorts before curly quotes. Nothing about it was ambiguous, so I didn't add any CONFLICT-REVIEW comments.

php -l passes on the file and no conflict markers are left in it. I staged it and finished the merge with git commit --no-edit (038f1fd). I haven't pushed anything.

Changed

  • resources/translations/en/app.php: kept both new translation strings.

@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the one conflict in this merge and committed it as 085df26. Nothing was pushed.

Conflict: src/Cms.php had a single conflict on SCHEMA_VERSION. The feature branch had it at 6.0.0.30; 6.x had it at 6.0.0.31. The feature branch's own schema bumps (896679e and 4c2f5dc) were copies of the 6.x bumps, which have the same commit messages. 6.x then raised the value to .31 in 06f7a40 when it filtered images by type. The feature branch has no schema bump of its own that needed keeping, so I took 6.0.0.31.

php -l reports no syntax errors and no conflict markers are left. Since the choice was clear, I didn't add a # CONFLICT-REVIEW: comment, and there are no ambiguous conflicts for a reviewer to check.

Blocked
Nothing.

Changed

  • src/Cms.php: set SCHEMA_VERSION to '6.0.0.31' and completed the merge commit.

Found
Nothing.

# Conflicts:
#	resources/translations/en/app.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

The merge is finished and committed as 88bb6ce. It hasn't been pushed. Only one file had a conflict, and none of it was ambiguous.

The conflict was in resources/translations/en/app.php. Each side added its own entries at the same spot in the alphabetical translation list. feature/import added two "Those import plans…" strings, and 6.x added 'Thumb Size'. Neither replaces the other, so I kept all three in alphabetical order. The file passes php -l and no conflict markers are left.

Changed

  • resources/translations/en/app.php: resolved the conflict by keeping both sides' translation entries.

Found

  • Nothing.

@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the merge and committed it as 54905ce. Nothing was pushed.

The only conflict was the content-hash line in composer.lock. Git had already merged the rest of the lock file cleanly. The hash couldn't come from either side, because the merged composer.json combines both branches' changes:

  • From 6.x: laravel/framework goes to ^13.38 and laravel/wayfinder to ^0.1.23.
  • From feature/import: craftcms/url-validator goes to ^1.2, and the Import, ImportLog and ImportPlans facade aliases are added.

I checked that the locked versions satisfy those constraints: framework v13.39.0, wayfinder v0.1.23 and url-validator 1.2.0. I then ran composer update --lock --no-install, which reported "Nothing to modify in lock file" and gave the new hash 804c7b48440f001d4a5fb479132630ed. That run also made two small unrelated edits (platform-dev [] → {} and plugin-api-version 2.3.0 → 2.9.0). I reverted those, so the only line that changes is the hash. composer validate passes.

Nothing was ambiguous, so I left no CONFLICT-REVIEW comments. I didn't run the test suite or composer ci.

# Conflicts:
#	src/FieldLayout/LayoutElements/Addresses/AddressField.php
#	src/FieldLayout/LayoutElements/Addresses/LatLongField.php
#	src/FieldLayout/LayoutElements/FullNameField.php
#	src/FieldLayout/LayoutElements/TitleField.php
#	src/FieldLayout/LayoutElements/Users/EmailField.php
#	src/FieldLayout/LayoutElements/Users/UsernameField.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved all six conflicts and committed the merge as 7c95634. I didn't push.

Changed

  • All six conflicted files are field layout elements in src/FieldLayout/LayoutElements/: Addresses/AddressField.php, Addresses/LatLongField.php, FullNameField.php, TitleField.php, Users/EmailField.php and Users/UsernameField.php.
  • Every conflict had the same cause. Both branches added a new method at the end of the class, right after the same #[Override] line.
    • feature/import added import-mapping methods: getFieldsForMapping(), or canBeMatchCriteria() / canBeCleared().
    • 6.x added the uiIcon() override (from a830bd4, the command palette work).
  • These are separate features that don't affect each other, so I kept both methods in every file and gave each its own #[Override]. Nothing was ambiguous, so I left no # CONFLICT-REVIEW: comments.

Found

  • There are no conflict markers left in src/, and all six files already import Override.
  • php -l passes on AddressField.php. The other five fail, but not because of the merge: the machine has PHP 8.3, and lines that both branches already had put #[Override] on properties, which only PHP 8.5 allows. The same files on HEAD alone fail the same way. CI on PHP 8.5 is the real syntax check.
  • I didn't run the test suite.

# Conflicts:
#	resources/translations/en/app.php
#	src/FieldLayout/LayoutElements/CustomField.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved both conflicts and committed the merge as 8c5ee6d. It hasn't been pushed. I checked that both files still parse with php -l, but I didn't run the test suite.

  • resources/translations/en/app.php: There were two conflict hunks. In each, the two sides had added different translation strings. I kept every string from both. The import strings from feature/import and the thumbnail strings from 6.x ("Choose whether image thumbnails…", "Thumb Alignment") stay in alphabetical order.
  • src/FieldLayout/LayoutElements/CustomField.php: 6.x added a use CraftCms\Cms\Element\Data\Thumb; import, and HEAD didn't have it. I kept the import because the thumb() method from 6.x uses that class.

Neither conflict was ambiguous, so I didn't add any CONFLICT-REVIEW comments.

# Conflicts:
#	resources/translations/en/app.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the merge's one conflict and committed it as 86796d7. Nothing was pushed. The only check I ran was php -l on the resolved file, which passed.

The conflict was in resources/translations/en/app.php. Each side had added a different translation string at the same spot: feature/import added 'Choose an importer' and 6.x added 'Choose at least one scope.'. Both are needed, so I kept both, in alphabetical order with the keys around them. There was nothing ambiguous, so I didn't add any # CONFLICT-REVIEW: comments.

# Conflicts:
#	resources/translations/en/app.php
#	src/User/UserPermissions.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

The merge is finished and committed locally as b4d9c8d, not pushed. There were two conflicted files. In both, each branch had added something new at the same spot, so I kept both sides. Nothing needed a CONFLICT-REVIEW comment. Both files pass php -l and no conflict markers are left; I didn't run tests or composer ci.

  • resources/translations/en/app.php: The import branch added the string "Each step imports one kind of data…" and 6.x added "Each AI feature, in Craft or a plugin, is a task…" at the same line. I kept both, in alphabetical order to match the rest of the file.
  • src/User/UserPermissions.php: The import branch added an importPermissions() group (view/save/delete/run import plans) and 6.x added an aiPermissions() group (use the AI assistant). Both were added to the same list of permission calls and at the same place in the class. I kept both calls, import first, and kept both methods in full. The Assistant import 6.x needs is already present.

One thing a reviewer may want to check: I put the Import permission group before the AI group. That is the order those two groups will appear in the user permissions screen.

Changed: Resolved the two files above and completed the merge with git commit --no-edit.

Found: Nothing else.

# Conflicts:
#	resources/translations/en/app.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the merge and committed it as e93d959. Nothing was pushed.

Conflicts found: Only one file conflicted: resources/translations/en/app.php, at the same spot in the alphabetical string list. Both sides had changed it:

  • feature/import added the new import string "Choose what this step imports into before mapping its data."
  • 6.x replaced the thumbnail-style string. The old wording ("…or cover the whole card.") became the new wording ("…cover the whole card, or cover it only when the image is a good fit."), to go with the new "auto" thumbnail style.

How I resolved it: I kept both changes: the import string from feature/import and 6.x's new thumbnail string. I dropped the old thumbnail wording because no code in src, the views, templates, JS or the Yii adapter uses it anymore.

Checks: The file passes php -l, and no conflict markers are left anywhere in the repo. All other files merged cleanly.

Needs attention: Nothing was ambiguous, so I didn't add any # CONFLICT-REVIEW: comments.

# Conflicts:
#	src/Asset/Elements/Asset.php
#	src/Element/Concerns/HasStatuses.php
#	src/Element/Element.php
#	src/Entry/Elements/Entry.php
#	src/User/Elements/User.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I've finished merging 6.x into feature/import and committed it as 9c18134. It isn't pushed. The merged files couldn't be fully syntax-checked here, so review the changed lines.

Conflicts found and how I resolved them

There were 5 conflicted files: src/Asset/Elements/Asset.php, src/Element/Concerns/HasStatuses.php, src/Element/Element.php, src/Entry/Elements/Entry.php and src/User/Elements/User.php. Every conflict had the same cause: both branches added a PHP attribute on the same line above the same property.

  • feature/import added an #[Importable(...)] attribute (or a comment saying that importing is handled elsewhere).
  • 6.x added an #[ElementProperty(...)] attribute.

These additions don't depend on each other, so I kept both on every property, with Importable first and ElementProperty after it. In HasStatuses.php, both sides' imports are kept and put in alphabetical order.

Ambiguous spots

None of the conflicts was truly ambiguous, so I didn't add any CONFLICT-REVIEW comments. There is one small layout change to check:

  • In User.php, feature/import had a trailing // imported via Field Layout Element comment on the $username and $email lines. I moved each comment onto its own line above the new #[ElementProperty] attribute, so it isn't attached to the attribute line.

Checks

  • No conflict markers remain.
  • I couldn't fully syntax-check four of the five files. The PHP installed here is 8.3, and php -l stops on newer syntax that the project already uses (property hooks and new X()->method()), before it reaches the merged lines. Both parent versions fail in the same places, so the merge didn't cause it. HasStatuses.php was the only file that passed php -l.
  • Pint isn't installed (vendor/ is missing), so code style wasn't checked.
  • CI on PHP 8.5 should do both checks properly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants