Configuration 

The extension itself has nothing to configure. What is configured is the data: a seed set, written in YAML, shipped by an extension, and read by the two console commands.

A set is made of two kinds of file, and keeping them apart is the whole layout of the format:

  • config.yml describes the set - what it is called, which scenario files it is written from, which files it brings, which of those files are attached to which records, and which site configurations it writes.
  • One or more scenario files describe the records. They are written in the YAML scenario format of typo3/testing-framework, the format the TYPO3 Core writes its own functional test fixtures in.

This chapter is the reference for both, and for the commands. It is written to be read in order; if you would rather see a whole set first and look the keys up afterwards, start at A complete seed set.

Where a seed set lives 

A seed set is a directory Configuration/DataFactory/<name>/ inside any active extension, with config.yml as its entry file:

packages/my_extension/Configuration/DataFactory/demo/
├── config.yml               entry file, and the only mandatory one
├── Scenario/
│   ├── Pages.yaml           the records, named by "scenarios"
│   └── Content.yaml
├── Files.yaml               optional, pulled in through "imports"
├── Files/
│   └── placeholder.svg      resources named by "files"
└── Sites/
    └── main/
        ├── config.yaml      site configuration template
        └── settings.yaml    optional site settings
Copied!

Every relative path inside a set - a scenarios entry, a file source, a site template - is resolved against the directory holding the entry file, not against the file declaring it. A set can therefore be moved or renamed without touching a single path inside it. EXT: paths are accepted everywhere a path is, and an absolute path is taken as it stands.

An imports resource is the one exception: it never reaches this extension, it is resolved by the TYPO3 YAML loader relative to the file that declares it - see Splitting a set over several files.

A set is found because the extension providing it is installed and activated. There is no path to configure and nothing to register. A directory below Configuration/DataFactory/ without a config.yml is not a set and is passed over, which is what lets a set keep partials next to itself.

The set descriptor 

packages/my_extension/Configuration/DataFactory/demo/config.yml
identifier: demo
title: 'Demo page tree'
description: 'Pages, content elements, a file and the site they are reachable through.'

imports:
  - { resource: Files.yaml }

scenarios:
  - Scenario/Pages.yaml
  - Scenario/Content.yaml

files:
  - identifier: placeholder
    source: 'Files/placeholder.svg'
    folder: 'demo'

references:
  - file: placeholder
    table: tt_content
    uid: 2000
    field: assets

sites:
  - identifier: main
    rootPage: 1000
    template: 'Sites/main'
Copied!
Key Required Meaning
identifier yes Globally unique across all active extensions. It is declared, never derived from the directory name - otherwise a collision between two extensions would be silent.
title yes Shown by data-factory:list.
description no Free text describing the set.
imports no Further YAML files merged into this descriptor.
scenarios yes The scenario files the records of the set are written from, in the order they are applied. A non-empty list of paths.
files no Files copied into a file storage before any record is written.
references no File references attached to seeded records, written after the records.
sites no Site configurations written after the records.

This list is closed: any other key at this level is refused, naming the known ones. That is deliberate - scenario: instead of scenarios: would otherwise be an import that reports success and writes nothing.

The descriptor carries no entitySettings and no entities. One rule, no ambiguity: config.yml describes the set, a scenario file describes records. Nothing this extension invents is mixed into a scenario file, and nothing a scenario file declares has to be understood by the descriptor.

The three metadata keys have to be declared in config.yml itself. They cannot be pulled in through imports, because listing the sets of an installation reads them without following imports.

The scenario format 

A scenario file is a map of at most three keys. Anything else at the top level is refused, naming the file:

Key Meaning
entitySettings How a table is written: its name, its pointer columns, its column aliases, its default values.
entities The records themselves, per entity.
__variables A place to hang YAML anchors. It is read and dropped - anchors are resolved by the YAML parser and never cross a file.

entitySettings 

An entity is a name a scenario writes records under. It is not a table: the table is named by tableName, which is what lets one table be written under two names with different defaults - page and folder both writing pages.

entitySettings:
  '*':
    nodeColumnName: 'pid'
    columnNames: {id: 'uid', language: 'sys_language_uid'}
    defaultValues: {pid: 0}
  page:
    isNode: true
    tableName: 'pages'
    parentColumnName: 'pid'
    languageColumnNames: ['l10n_parent', 'l10n_source']
    columnNames: {type: 'doktype', root: 'is_siteroot'}
    defaultValues: {hidden: 0, doktype: 1}
    valueInstructions:
      shortcut:
        first: {shortcut: 0, shortcut_mode: 1}
  content:
    tableName: 'tt_content'
    languageColumnNames: ['l18n_parent', 'l10n_source']
    columnNames: {title: 'header', type: 'CType'}
    defaultValues: {hidden: 0, CType: 'text', colPos: 0}
  category:
    tableName: 'sys_category'
Copied!
Key Meaning
isNode The records of this entity can carry nested entities, and become their node. In practice: the entity writing pages.
tableName The table the entity writes into. Defaults to the entity name, so an entity called sys_category needs no setting at all.
nodeColumnName The column that receives the uid of the node a record sits on - pid for everything that lives on a page.
parentColumnName The column that receives the uid of the parent a children entry hangs under - pid for a sub page.
columnNames Aliases: the key a record writes on the left, the real column on the right. {id: 'uid'} is what makes id the declared uid.
languageColumnNames The columns a languageVariants entry gets its ancestor uids written into, in order - ['l10n_parent', 'l10n_source'].
defaultValues Written to every record of the entity unless the record declares the value itself.
valueInstructions A declared value expanded into several columns, see below.

Those eight keys are the whole vocabulary of an entity. The entry '*' is not an entity but the defaults for the declared entities, merged into each of them.

defaultValues are written with the column names they are declared with. Aliases from columnNames are not applied to them, so a default belongs under the real column name - {CType: 'text'}, not {type: 'text'}.

A valueInstructions block turns one declared value into several columns. It is keyed by the name the record declares, then by the value it declares, and its own keys are real column names:

page:
  valueInstructions:
    shortcut:
      first: {shortcut: 0, shortcut_mode: 1}
Copied!

A page declaring shortcut: 'first' is then written with shortcut: 0 and shortcut_mode: 1 - "shortcut to the first sub page" spelled once instead of on every page that wants it.

entities 

packages/my_extension/Configuration/DataFactory/demo/Scenario/Pages.yaml
entities:
  page:
    - self: {id: 1000, title: 'Demo', root: true, slug: '/'}
      entities:
        content:
          - self: {id: 2000, title: 'A frontend to look at', type: 'header'}
          - self: {id: 2001, bodytext: '<p>Seeded, not clicked together.</p>'}
      children:
        - self: {id: 1100, title: 'About', slug: '/about'}
          entities:
            content:
              - self: {id: 2100, title: 'About us'}
        - self: {id: 1200, title: 'Contact', slug: '/contact'}
    - self: {id: 1900, title: 'Storage', type: 254, slug: '/storage'}
      entities:
        category:
          - self: {id: 3000, title: 'News'}
Copied!

entities is a map of entity name to a list of items. Every item is a map, and the keys it may carry are:

Key Meaning
self The record itself, as a map of declared names to values. Required, unless version takes its place.
version The record itself, written into a workspace instead of live. It requires a workspace, and it cannot be combined with self.
children Further items of the same entity, hung under this one through parentColumnName.
entities Records of other entities placed on this one through nodeColumnName. Only an entity with isNode: true processes them.
languageVariants Translations of this record, see below.
versionVariants Workspace versions of this record, see below.
actions Commands run on this record after every record was written, see below.

Everything inside self that is not id is a field: it is resolved through columnNames and written as it stands. A table therefore needs no support in this extension to be seedable - declare an entity for it, and if the column exists and TYPO3 accepts the value, the seed writes it.

Records come out in the order they are declared, whatever table they belong to, and records of different tables on one page do not disturb each other's sorting. Where the order has to be something other than the declared one, declare an actions entry with {action: 'move', type: 'afterRecord', target: <uid>}.

Translations 

languageVariants is the first-class translation construct of the format. A variant is an ordinary item of the same entity, and the seeder fills the columns named by languageColumnNames with the uids of its ancestors:

entitySettings:
  '*':
    nodeColumnName: 'pid'
    columnNames: {id: 'uid', language: 'sys_language_uid'}
    defaultValues: {pid: 0}
  page:
    isNode: true
    tableName: 'pages'
    parentColumnName: 'pid'
    languageColumnNames: ['l10n_parent', 'l10n_source']
    columnNames: {type: 'doktype', root: 'is_siteroot'}
    defaultValues: {hidden: 0, doktype: 1}
  content:
    tableName: 'tt_content'
    languageColumnNames: ['l18n_parent', 'l10n_source']
    columnNames: {title: 'header', type: 'CType'}
    defaultValues: {hidden: 0, CType: 'text', colPos: 0}

entities:
  page:
    - self: {id: 1000, title: 'EN: Demo', root: true, slug: '/'}
      children:
        - self: {id: 1100, title: 'EN: About', slug: '/about'}
          languageVariants:
            - self: {id: 1101, title: 'DE: Über uns', language: 1, slug: '/ueber-uns'}
            - self: {id: 1102, title: 'FR: À propos', language: 2, slug: '/a-propos'}
          entities:
            content:
              - self: {id: 2100, title: 'EN: About us'}
                languageVariants:
                  - self: {id: 2101, title: 'DE: Über uns', language: 1}
Copied!

Three things follow from how the variants are written:

  • The language columns are structure. With languageColumnNames: ['l10n_parent', 'l10n_source'], the first variant of a record gets both columns pointing at the original. A variant nested inside a variant - a translation of a translation - is built with l10n_parent from the first ancestor and l10n_source from the one directly above it, which is the chain TYPO3 expects.

  • A declared value still wins. A variant declaring l10n_source itself overrides what the seeder computed.
  • The language itself is an ordinary field. language is only a column alias for sys_language_uid, declared in columnNames. It is not built into the format.
  • A translated page follows its original through nodeColumnName , not through parentColumnName. A language variant is written without the parent pointer of the item it translates - what it is given instead is the node pointer, set to "directly after the original". A node entity declaring nodeColumnName: 'pid', as every scenario file of TYPO3 Core does on its '*' entry, therefore puts the translated page on the page its original sits on and right behind it. A node entity that declares only parentColumnName has nothing to be positioned by, and the pid falls back to the defaultValues of the entity - usually 0, which is out of the tree. A nested record such as a content element is not affected either way: it keeps the node it was declared under.

Workspace records 

There are two ways into a workspace, and they mean different things.

version: in place of self: writes the record itself into a workspace. There is no live record - it is a record that was created in a workspace and never published:

entitySettings:
  workspace:
    tableName: 'sys_workspace'

entities:
  workspace:
    - self: {id: 1, title: 'Draft'}
  page:
    - self: {id: 1000, title: 'Demo', root: true, slug: '/'}
      children:
        - version: {id: 1300, title: 'EN: Coming soon', slug: '/soon', workspace: 1}
Copied!

versionVariants: writes a workspace overlay of a live record: the record exists live as its self declares, and the variant is the changed version an editor would see in that workspace:

entities:
  page:
    - self: {id: 1100, title: 'EN: About', slug: '/about'}
      versionVariants:
        - version: {title: 'EN: About us, revised', workspace: 1}
Copied!

Rules for both:

  • version requires workspace, and the workspace has to be a sys_workspace record - seed it in the same scenario, as above.
  • A versionVariants entry may not declare self, and may not declare an id. The overlay is a row of its own with a uid the database assigns, and it is found through t3ver_oid, which holds the uid of the live record - the one the item above it declared. A uid that could be declared for it would therefore be a uid nothing honours: the overlay is created by DataHandler while it versions the live record, not inserted by the seed.
  • languageVariants and versionVariants combine. A language variant may carry version variants of its own, and a languageVariants entry may itself use version: instead of self: - a translation that only exists in a workspace.

The workspaces are written one after another, in the order they first appear in the scenario, and a versionVariants entry is reached after the live record it hangs under. A workspace overlay therefore always finds the live record it belongs to.

actions 

An actions list is not written with the record: it becomes a DataHandler command, and the commands run after every record of every workspace exists. That is what lets an action name a record the same scenario creates.

entities:
  page:
    - self: {id: 1000, title: 'Demo', root: true, slug: '/'}
      children:
        - self: {id: 1100, title: 'About', slug: '/about'}
        - self: {id: 1200, title: 'Archive', slug: '/archive'}
          versionVariants:
            # In workspace 1 the page was moved below "About".
            - version: {workspace: 1}
              actions:
                - {action: 'move', type: 'toPage', target: 1100}
        - self: {id: 1300, title: 'Draft only', slug: '/draft'}
          versionVariants:
            # In workspace 1 the page was deleted.
            - version: {workspace: 1}
              actions:
                - {action: 'delete'}
Copied!
Action Effect
{action: 'move', type: 'toPage', target: <uid>} Move the record onto the page target.
{action: 'move', type: 'toTop'} Move the record to the top of the node it sits on. It needs a node, so it applies to a nested record rather than to a top level page.
{action: 'move', type: 'afterRecord', target: <uid>} Move the record directly behind target.
{action: 'delete'} Delete the record.
{action: 'discard'} Discard the workspace version, leaving the live record as it is. The version row is deleted outright rather than soft deleted, so it cannot be recovered. Only inside a workspace: on a record of the live workspace the action is dropped.

An unknown action is ignored rather than refused, in line with the rest of the scenario format.

Declared uids, and what makes a seed reproducible 

Every record of a scenario has a uid before it is written. Either the item declares one as id, or the seeder hands out a dynamic one from 10000 upwards. Both are suggested to DataHandler, so a seeded page tree is the same page tree in every installation - a site configuration, a TypoScript condition or a test may refer to a page by its number.

Declare the uid of everything that is referred to from outside the set: a site root, a shortcut target, a storage folder. Let the rest be dynamic.

TYPO3 treats a suggested uid as a suggestion. If the uid is already used in its table, the record is not written elsewhere - the insert fails. The import therefore checks every suggested uid up front and refuses when one is taken, naming the records in the way:

[ERROR] The seed set "demo" suggests 2 uids this installation already uses.

 ---------- ----- -------------------
  Table      Uid   Occupied by
 ---------- ----- -------------------
  pages      1000  Company site
  pages      1100  Products
 ---------- ----- -------------------
Copied!

Nothing is written in that case. --force imports anyway: every record of a table something collides in is written with a free uid instead of the suggested one, a table nothing collides in keeps its uids, and nothing that is in the way is deleted or changed.

A deleted record occupies its uid as much as any other one - the row is still there.

Suggested uids are honoured for an administrator backend user only. DataHandler ignores them silently for anybody else, so the import refuses to run as a non-admin rather than write a set whose uids are not the ones it declares.

Relations between records 

A relation needs no key of its own, in config.yml or anywhere else. Because every record has a uid before it is written, the record holding the relation can name the records on the other end of it by writing their uids into its relation field, exactly as a backend form submits them:

an inline relation, expressed with what the scenario format already has
entitySettings:
  '*':
    nodeColumnName: 'pid'
    columnNames: {id: 'uid'}
    defaultValues: {pid: 0, hidden: 0}
  page:
    isNode: true
    tableName: 'pages'
    parentColumnName: 'pid'
    defaultValues: {doktype: 1}
  content:
    tableName: 'tt_content'
    columnNames: {title: 'header', type: 'CType'}
  item:
    tableName: 'tx_myext_item'

entities:
  page:
    - self: {id: 1, title: 'Root', slug: '/', is_siteroot: 1}
      entities:
        content:
          - self: {id: 21, title: 'List', type: 'my_itemlist', tx_myext_items: '32,31'}
        item:
          - self: {id: 31, title: 'One'}
          - self: {id: 32, title: 'Two'}
Copied!

That is the whole declaration. The relation itself is described where it is always described, in the TCA of the field:

the TCA of tt_content.tx_myext_items
'tx_myext_items' => [
    'config' => [
        'type' => 'inline',
        'foreign_table' => 'tx_myext_item',
        'foreign_field' => 'parentid',
        'foreign_table_field' => 'parenttable',
        'foreign_sortby' => 'sorting_foreign',
    ],
],
Copied!

The children are an entity of their own in the same scenario, and the parent declares its relation field as the comma separated list of the ids they declare. Nothing else is needed: DataHandler resolves that list like any other relation and writes the columns tying a child to its parent by itself. Which columns those are is read from the TCA of the parent field - its foreign_field, foreign_table_field and foreign_sortby, which are parentid, parenttable and sorting_foreign above. A scenario therefore never names them, and a relation whose TCA calls them something else needs nothing different here. The relation field of the parent ends up holding the number of children, the way TYPO3 stores an inline relation.

Two consequences are worth stating, because they are what an integrator will actually rely on:

  • The order of the relation is the order of the declared list, not the order of the uids. The example writes '32,31', and item 32 comes first.
  • It works more than one level deep. A child that is itself the parent of a relation declares its own list the same way, and the second level is resolved from the TCA of the child's field. Nothing about it is special cased.

The children live on the page the scenario declares them under, like every other record: the relation ties them to the parent record, not to its page.

Files 

files:
  - identifier: placeholder             # required, unique among the files
    source: 'Files/placeholder.svg'     # required; relative to the set, or EXT:
    folder: 'demo'                      # optional, default the storage root
    name: 'placeholder.svg'             # optional, default the source basename
    storage: 1                          # optional, default the default storage
Copied!
Key Required Meaning
identifier yes Unique among the files of the set.
source yes Where the file comes from: a path relative to the directory holding the set, or an EXT: path.
folder no Target folder inside the storage. Defaults to the storage root.
name no The name the file is written under. Defaults to the base name of the source.
storage no The uid of the storage to write into. Defaults to the default storage of the installation.

This list is closed as well: any other key on a file is refused, naming the known ones - foldr: instead of folder: would otherwise put the file in the storage root and report success.

Files are copied before the records are written, through the file storage API, which is what indexes them - a file copied into fileadmin/ by hand exists on disk and does not exist for TYPO3. A missing target folder is created, an existing file of the same name is replaced, and the source file in the extension is left where it is.

Placing a file is one thing and attaching it to a record is another, and the second is what references below does.

File references 

references:
  - file: placeholder                 # required, an identifier declared under "files"
    table: tt_content                 # required, the table of the record it hangs on
    uid: 2000                         # required, the uid the scenario declares as "id"
    field: assets                     # required, the file relation column of that record
    values:                           # optional, the fields of the sys_file_reference row
      title: 'A placeholder'
      alternative: 'Nothing to see here'
Copied!
Key Required Meaning
file yes The identifier of a file the same set declares under files. A name no file of the set carries is refused.
table yes The table of the record the reference hangs on.
uid yes The uid of that record: the id an entity of the scenario declares for it. A positive integer.
field yes The column of that record the file is attached to - a TCA file relation, such as assets or image on tt_content and media on pages.
values no The fields written on the sys_file_reference row itself: the ones an editor fills in on a file relation, such as title, alternative, description, link or crop. Every value has to be a string, a number, a boolean or null.

This list is closed as well: any other key on a reference is refused, naming the known ones.

A scenario record carries no symbolic name, so a reference names its record by uid - the same rule rootPage follows, and for the same reason. That uid is resolved against what the run actually wrote rather than trusted. A reference naming a record no entity of the scenario declares is refused before anything is written:

[ERROR] The seed set "demo" declares a file reference to "placeholder" on the
        record tt_content:2001, which no entity of its scenario declares as
        its "id".
Copied!

Five columns of the sys_file_reference row are structural and are written by the seeder: uid_local, uid_foreign, tablenames, fieldname and pid. Declaring one of them under values does not change it - the seeder's value wins, because a definition may not detach a reference from the record it declares it on. pid follows the convention TYPO3 uses for a file relation: a reference belongs to the page its record is on, and for a record that is a page, to that page itself.

Several references on one field come out in the order they are declared, which is what sorting_foreign is written from - so a multi image field is a gallery in the declared order rather than in whatever order the database returns. The same file may be referenced from several records, or from none.

References are written in a pass of their own, through DataHandler, after every record of the set exists. That is what puts the relation into the reference index, and it is why a mistyped uid is caught up front rather than after the whole tree has been written.

A worked example 

packages/my_extension/Configuration/DataFactory/demo/config.yml
identifier: demo
title: 'A content element with two images'

scenarios:
  - Scenario.yaml

files:
  - identifier: landscape
    source: 'Files/landscape.jpg'
    folder: 'demo'
  - identifier: portrait
    source: 'Files/portrait.jpg'
    folder: 'demo'

references:
  - file: landscape
    table: tt_content
    uid: 2000
    field: assets
    values: {title: 'Landscape', alternative: 'The wide one'}
  - file: portrait
    table: tt_content
    uid: 2000
    field: assets
  - file: landscape
    table: pages
    uid: 1000
    field: media
Copied!
packages/my_extension/Configuration/DataFactory/demo/Scenario.yaml
entitySettings:
  '*':
    nodeColumnName: 'pid'
    columnNames: {id: 'uid'}
    defaultValues: {pid: 0, hidden: 0}
  page:
    isNode: true
    tableName: 'pages'
    parentColumnName: 'pid'
    defaultValues: {doktype: 1}
  content:
    tableName: 'tt_content'
    columnNames: {title: 'header', type: 'CType'}

entities:
  page:
    - self: {id: 1000, title: 'Demo', slug: '/', is_siteroot: 1}
      entities:
        content:
          - self: {id: 2000, title: 'Teaser', type: 'textmedia'}
Copied!

The scenario file knows nothing about a file, and that is deliberate: it stays a file that could be lifted into a functional test unchanged. The content element 2000 ends up with two images in the declared order, and the page 1000 with the first of them in its media field.

Site configurations 

sites:
  - identifier: main                    # required, the directory in config/sites/
    rootPage: 1000                      # required, the uid of a seeded page
    template: 'Sites/main'              # optional, default Sites/<identifier>
    base: 'https://example.com/'        # optional, overrides the template
Copied!

A site configuration is written from a template: a directory holding a config.yaml and optionally a settings.yaml, which is exactly the shape of a site below config/sites/. A template is therefore produced by copying a working site out of an installation.

Key Required Meaning
identifier yes Becomes the directory name below config/sites/. Letters, digits, dashes and underscores, starting with a letter or a digit.
rootPage yes The uid of the page that becomes the site root. It has to be the id an entity of the pages table of this set declares.
template no The template directory, relative to the set or an EXT: path.
base no Replaces the base of the template.

This list is closed too: a site is configuration rather than a record, so nothing on it is written verbatim and an unknown key can only be a mistake.

A scenario record carries no symbolic name, so its stable handle is the uid it declares - which is why rootPage is a number here and not a name. A site naming a uid that no pages entity of the scenario declares is refused before anything is written, rather than after the whole tree exists.

Three rules apply to the result:

  • The root page always wins. rootPageId is taken from the uid the declared page was actually written with, whatever the template says.
  • An existing site identifier is refused. TYPO3 merges an incoming site configuration into an existing one, so seeding over it would produce neither the template nor the previous configuration but a mixture of both. Remove the site first if the seed is meant to replace it.
  • A minimal template needs almost nothing. base and languages are worth declaring, because their defaults are a site on / in "Default / en_US.UTF-8". dependencies - the site sets a site pulls in - is written unchanged and works on TYPO3 v13 and v14 alike.

Placeholders such as %env(...)% inside a template are not resolved while seeding. They are written as they stand, so the installation resolves them every time it reads the file - which is what they are for.

The site TYPO3 creates by itself 

TYPO3 writes an autogenerated-<uid> site configuration whenever a new page becomes a site root. An import suppresses that, always - whether the set declares sites or not - because such a configuration is never what a seed wanted.

The consequence is reported rather than left to be discovered: when a seeded site root ends up covered by no site configuration at all, the import warns and names the pages by uid. A page tree without a site is a frontend that cannot render, and nothing else would say so.

A complete seed set 

Everything above, in one set. It is the set this extension's own functional tests import, and a test compares the three files printed here against the files on disk - so what is shown is what runs.

packages/my_extension/Configuration/DataFactory/complete/
├── config.yml
├── Scenario.yaml
├── Files/
│   └── placeholder.svg
└── Sites/
    └── main/
        └── config.yaml
Copied!
packages/my_extension/Configuration/DataFactory/complete/config.yml
identifier: import-documented
title: 'A complete seed set'
description: 'Pages with content and translations, a file, a file reference and a site.'

scenarios:
  - Scenario.yaml

files:
  - identifier: placeholder
    source: 'Files/placeholder.svg'
    folder: 'documented'

references:
  - file: placeholder
    table: pages
    uid: 1000
    field: media
    values:
      title: 'The placeholder'
      alternative: 'A grey rectangle'

sites:
  - identifier: documented-main
    rootPage: 1000
    template: Sites/main
Copied!
packages/my_extension/Configuration/DataFactory/complete/Scenario.yaml
entitySettings:
  '*':
    nodeColumnName: 'pid'
    columnNames: {id: 'uid', language: 'sys_language_uid'}
    defaultValues: {pid: 0, hidden: 0}
  page:
    isNode: true
    tableName: 'pages'
    parentColumnName: 'pid'
    languageColumnNames: ['l10n_parent', 'l10n_source']
    defaultValues: {doktype: 1}
  content:
    tableName: 'tt_content'
    languageColumnNames: ['l18n_parent', 'l10n_source']
    columnNames: {title: 'header', type: 'CType'}

entities:
  page:
    - self: {id: 1000, title: 'EN: Demo', slug: '/', is_siteroot: 1}
      children:
        - self: {id: 1100, title: 'EN: About', slug: '/about'}
          languageVariants:
            - self: {id: 1101, title: 'DE: Über uns', language: 1, slug: '/ueber-uns'}
          entities:
            content:
              - self: {id: 2100, title: 'EN: About us', type: 'header'}
                languageVariants:
                  - self: {id: 2101, title: 'DE: Über uns', language: 1, type: 'header'}
Copied!
packages/my_extension/Configuration/DataFactory/complete/Sites/main/config.yaml
rootPageId: 0
base: 'https://documented.example.org/'
languages:
  -
    title: English
    enabled: true
    languageId: 0
    base: /
    locale: en_US.UTF-8
  -
    title: Deutsch
    enabled: true
    languageId: 1
    base: /de/
    locale: de_DE.UTF-8
errorHandling: []
routes: []
Copied!

Importing it with vendor/bin/typo3 data-factory:import import-documented writes, in this order:

  • the file placeholder.svg into fileadmin/documented/, indexed as a sys_file;
  • the pages 1000, 1100 and its translation 1101, and the content elements 2100 and its translation 2101, each with the uid it declares;
  • a sys_file_reference attaching the file to pages.media of page 1000, carrying the declared title and alternative;
  • the site config/sites/documented-main/config.yaml, from the template, with rootPageId replaced by 1000.

Two details of the example are deliberate and worth copying. The entities declare nodeColumnName, which is what puts the translated page onto the page its original sits on rather than out of the tree, and hidden sits on the '*' entry while doktype sits on page - a key declared on both sides is merged into a list, not overridden.

Workspaces are the one construct the example leaves out. They need a sys_workspace record and have a section of their own; a set that seeds a workspace is not what a first set looks like.

Splitting a set over several files 

A set splits in two independent ways, and they are not interchangeable.

The descriptor: imports 

imports:
  - { resource: Files.yaml }
  - { resource: 'EXT:my_extension/Configuration/DataFactory/shared/Sites.yaml' }
Copied!

imports is handled by the loader TYPO3 reads its own site configurations with. Imported lists are merged into the importing file rather than replacing it, and the imported entries come first: a config.yml declaring scenarios: [Content.yaml] and importing a file declaring scenarios: [Pages.yaml] composes Pages.yaml, Content.yaml. That is the order the scenarios are applied in, so the entry file wins a conflicting entitySettings value against anything it imports. Resources are resolved relative to the file declaring them - not against the entry file, which is the one path in a set that behaves this way - and EXT: paths are accepted. An imported file carries the same keys as the entry file - except the three metadata keys, which belong into config.yml.

A resource that cannot be read stops the import with an error. It is not skipped: a typo in a path would otherwise mean the files of that resource are silently not seeded while the import reports success.

Placeholders are not substituted in a set descriptor, unlike in a site configuration. A title or a description is content and has to arrive as it was written, and % occurs in pairs in perfectly ordinary texts.

The records: several scenarios 

scenarios:
  - Scenario/Pages.yaml
  - Scenario/Content.yaml
  - 'EXT:my_extension/Configuration/DataFactory/shared/Categories.yaml'
Copied!

The listed files are composed into one scenario before anything is built, in the order they are declared:

Key How it is merged
entitySettings Merged recursively. A later file wins a conflicting value, so a shared file can declare the entities and a set specific one can change a default.
entities Appended per entity name, in the order the files are declared. A later file adds records, it never replaces them.
__variables Dropped. YAML anchors are resolved per file and never cross one, so every file that uses anchors declares them itself.

One composed scenario rather than one per file, because the dynamic uids are handed out per scenario: two scenarios would each start at 10000 and the second insert would fail on the primary key. Composed, the same collision is reported by name before anything is written.

A scenario file that does not exist, cannot be read, is not valid YAML, is not a map, or declares an unknown top level key stops the import.

What this version does not do 

Two boundaries of the format, named here rather than left to be discovered:

  • A file reference reaches only the records of its own set. The uid of a references entry is resolved against what the run writes, so it cannot attach a file to a record that is already in the installation. Naming one is refused as an undeclared record, not answered with a database lookup.
  • A file reference cannot be declared in a scenario file. The scenario format has no concept of a file, and this extension does not give it one - that is the point of references living in config.yml. A scenario file of a seed set stays a file that could be lifted into a functional test unchanged.

The commands 

data-factory:list 

vendor/bin/typo3 data-factory:list
vendor/bin/typo3 data-factory:list -v
Copied!

Lists identifier, title and providing extension of every set found. -v adds the directory the set lives in. Sets appear in the order the installation loads their extensions in, and within one extension sorted by directory name.

The command exits non-zero when an identifier is provided by more than one extension - the sets cannot be told apart, so neither listing nor importing may pick one of them - and when a config.yml cannot be read or does not name itself. An installation without any seed set is not an error.

data-factory:import 

vendor/bin/typo3 data-factory:import demo
vendor/bin/typo3 data-factory:import demo --dry-run -v
vendor/bin/typo3 data-factory:import demo --base='https://example.com/'
Copied!

Without an identifier the command asks which set to import. Without a terminal to ask on - a deployment script, a pipeline, a hook - it lists the available sets and exits non-zero rather than guessing. An identifier that names nothing is answered with the sets that look like it.

Option Effect
--dry-run Parse the descriptor, compose the scenario, build every record and check its uid, report what an import would do, and write nothing.
--force Import although the set suggests uids this installation already uses. See Declared uids.
--root-page The page the set is written below. 0, the default, is the page tree root. The page has to exist.
--base Replaces the base of every site configuration the set writes. Use it to import one set into several installations.
--no-site-config Skip the site configurations the set declares. The warning about uncovered site roots still appears.
-v Add the table of uids: what a dry run would suggest, or what a real run wrote for each declared uid.

--root-page moves the top level items of every entity onto the given page, and only those that do not declare a non-zero pid of their own - a declared pid: 0 names the page tree root, which is exactly what the option replaces. Nested records, children, language variants and version variants are untouched - they take their page from their node or from their ancestor, and moving them would take them off the tree they were declared in.

Exit codes 

Code Meaning
0 The set was imported, or the dry run found nothing to complain about.
2 No identifier and no terminal to ask on, or an option value that cannot be used.
3 No active extension provides a set of that identifier.
4 The set cannot be told apart: the identifier is provided more than once, or a config.yml in this installation cannot be read.
5 The set is not a valid seed definition: an unknown key, a scenario that cannot be read or built, a scenario without a single record, a site whose rootPage the scenario does not declare, or a file reference whose record it does not declare.
6 The set suggests uids this installation already uses, or --force would give up the page uids a declared site needs.
7 There is no administrator backend user to write as.
8 Writing the set failed.

A dry run does everything a real run does except the writing: it resolves the set, parses the descriptor, composes and builds the scenario, and checks the uids. A set that a dry run accepts fails afterwards only for a reason that lives in the installation rather than in the set.

What the seeder writes by itself 

Field Rule
uid Suggested for every record: the declared id, or a dynamic one from 10000 upwards.
The node column Structure. The column named by nodeColumnName gets the uid of the record the item is nested under, and is never taken from the item.
The parent column Structure, likewise, for a children entry and the column named by parentColumnName.
The language columns The columns named by languageColumnNames get the ancestors of a languageVariants entry. A value the variant declares itself wins.
sorting Computed by TYPO3 so that records appear in the order they are declared.
slug, TCA defaults and evaluations Everything DataHandler derives from a record is derived, because the records go through it rather than through an INSERT.
The reference columns uid_local, uid_foreign, tablenames, fieldname and pid of a sys_file_reference written from references. A value declared for one of them under values does not win.
sorting_foreign Written by TYPO3 for a relation, from the order the children - or the references - are declared in.

Everything else in a record comes from its self or version, from the defaultValues of its entity, or from the TCA of the installation where neither declares anything. This extension adds no default of its own - in particular, a page is created hidden unless the scenario says otherwise, because pages is the one table whose TCA sets 'hidden' => ['config' => ['default' => 1]]. tt_content and the tables whose disable field TYPO3 enriches default to 0. That is why every example above declares defaultValues: {hidden: 0}: it costs nothing on the tables that already default to 0, and it is what keeps a seeded page tree visible.

The same holds for doktype, l10n_parent and sys_language_uid - they come from the TCA of the installation unless the scenario declares them, so a set that wants the same records in two installations declares them.

See also