---
title: "Frontend user synchronisation"
manual: "Academic Profiles"
version: "main"
source: "Configuration/FrontendUserSync/Index.rst"
rendered: "2026-10-02T20:47:49+00:00"
---

# Frontend user synchronisation {#configuration-frontend-user-sync}

The commands `academic:createprofiles` and
`academic:updateprofiles` copy `fe_users` columns onto the profile
and onto one contract of it, the imported contract. Which column feeds which
property is the `frontendUserSync` map of
`Configuration/AcademicPersons/Settings.yaml`.

Neither command changes whether a profile is shown. Hiding or deleting the
profiles of disabled and deleted frontend users is the job of
`academic:cleanupprofiles`, see [Profile cleanup](../ProfileCleanup/Index.html#configuration-profile-cleanup).

## The shipped map {#configuration-frontend-user-sync-shipped}

**EXT:academic_persons/Configuration/AcademicPersons/Settings.yaml**

```yaml
frontendUserSync:
  profile:
    title: title
    firstName: first_name
    middleName: middle_name
    lastName: last_name
    website: www
  contract:
    position: ''
    room: ''
    organisationalUnit:
      column: ''
      matchBy: uniqueName
      create: false
      storagePid: 0
    functionType:
      column: ''
      matchBy: functionName
      create: false
      storagePid: 0
  physicalAddresses:
    - street: address
      zip: zip
      city: city
      country: country
  emailAddresses:
    - column: email
  phoneNumbers:
    - column: telephone
      type: ''
    - column: fax
      type: ''
```

It is the mapping the synchronisation followed before the map existed, record
for record: an installation that does not ship a map of its own gets the same
profiles, contracts, contact records and import identifiers as before.

## The keys {#configuration-frontend-user-sync-keys}

Every value is the name of an `fe_users` column, or of a value a listener
adds to the frontend user data, see
[Taking part in the frontend user synchronisation](../../Developers/Index.html#developers-frontend-user-sync-events). A property mapped to
`''` or `~` is not synchronised: the synchronisation neither writes nor
clears it, and the value an editor entered stays. A mapped property is written
on every run, and an empty column clears it.

| Key | Maps |
| --- | --- |
| `profile` | Profile properties: `title`, `firstName`, `middleName`, `lastName`, `website`, `websiteTitle`, `publicationsLink`, `publicationsLinkTitle`, `coreCompetences`, `miscellaneous`, `supervisedThesis`, `supervisedDoctoralThesis` and `teachingArea`. |
| `contract` | Properties of the imported contract: `position` and `room`, and its relations `organisationalUnit` and `functionType`, each a map of its own, see [Organisational unit and function type](#configuration-frontend-user-sync-relations). |
| `physicalAddresses` | A list. Each entry is one address and maps `street`, `streetNumber`, `additional`, `zip`, `city`, `state` and `country`. |
| `emailAddresses` | A list. Each entry is one e-mail address, from its `column`. |
| `phoneNumbers` | A list. Each entry is one phone number, from its `column`, with a `type`. |

A phone number's `type` is one of
[types.phoneNumberTypes](../General/Index.html#confval-types-phonenumbertypes). `''` takes
[profile.feuser.faxNumberType](../General/Index.html#confval-profile-feuser-faxnumbertype) for the column `fax` and
[profile.feuser.telephoneNumberType](../General/Index.html#confval-profile-feuser-telephonenumbertype) for any other column. A type the
installation does not offer is stored as the undefined type `''`, like a
configured one.

The gender and the two letters the list navigation files a profile under are
not part of the map: the gender is a fixed selection, and the letters are
derived from the names.

## Organisational unit and function type {#configuration-frontend-user-sync-relations}

The organisational unit and the function type of the imported contract are
records of their own. A column names one of them by a value the record
carries, and the synchronisation assigns the record that matches:

| Key | Meaning |
| --- | --- |
| `column` | The `fe_users` column holding the value. `''` or `~`: the relation is not synchronised, and an editor's choice stays. |
| `matchBy` | The field of the record the value is compared with: `uniqueName` (the default) or `unitName` for an organisational unit, `functionName` for a function type. |
| `create` | `true` creates a missing record. `false`, the default, leaves the relation empty instead. |
| `storagePid` | The page a created record is stored on. Required with `create: true`, a record is never created on page 0. |

A record matches when its field holds exactly the value, after the blanks
around the value are removed. Case and accents count on every database, on
MySQL and MariaDB too, whose collation would ignore them. Hidden records match,
so a unit an editor hid is assigned rather than created a second time, and so
do records on any page, whatever `storagePid` says. Deleted records,
drafts of a workspace and translations never match: the value is compared with
the default language.
When several records match, the one with the lowest uid is assigned, the same
one on every database. Keep the values unique to avoid relying on that.

A created organisational unit takes the value as its name, and as its unique
name when it is matched by the unique name. A created function type takes it as
its name. Both get the URL segment a save would generate from that name.
Everything else is left for an editor, including the translations.
The record is saved right away, so the next frontend user with the same value,
and the next run, find it instead of creating another one. Runs of the two
commands in parallel can still create one record twice: run them one after the
other. The names hold 255 characters. On PostgreSQL, and on MySQL and MariaDB
in their default strict mode, creating a longer value stops the command with a
database error. Without strict mode it is cut to 255 characters, never matches
again and is created anew on every run. Keep the values shorter.

A mapped relation belongs to the synchronisation. An empty column clears it,
and so does a value that matches nothing when `create` is off. A relation
that is not mapped is never touched.

**EXT:my_sitepackage/Configuration/AcademicPersons/Settings.yaml**

```yaml
frontendUserSync:
  contract:
    organisationalUnit:
      column: company
      create: true
      storagePid: 42
    functionType:
      column: tx_project_function
```

The employee type is not synchronised, and an editor's choice stays. Its
categories carry no type in **EXT:academic_persons**, so a title can
match categories of any purpose. A project that takes the employee type from
the frontend user data sets it in a listener of
`AfterProfileMappedFromFrontendUserEvent`, see
[Taking part in the frontend user synchronisation](../../Developers/Index.html#developers-frontend-user-sync-events). The listener reads the value from
the frontend user data, looks the category up with a query of its own and sets
it on the imported contract. The query is ordered, and the title is compared in
PHP, because MySQL and MariaDB ignore case and accents when they compare it.
That way the same category wins on every database. With
**EXT:category_types** installed, the query can be restricted to one
category type:

**EXT:my_sitepackage/Classes/EventListener/SetEmployeeType.php**

```php
<?php

declare(strict_types=1);

namespace MyVendor\MySitepackage\EventListener;

use FGTCLB\AcademicPersons\Event\AfterProfileMappedFromFrontendUserEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Extbase\Domain\Model\Category;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;

final readonly class SetEmployeeType
{
    public function __construct(
        private ConnectionPool $connectionPool,
        private PersistenceManagerInterface $persistenceManager,
    ) {}

    #[AsEventListener(identifier: 'my-sitepackage/set-employee-type')]
    public function __invoke(AfterProfileMappedFromFrontendUserEvent $event): void
    {
        $frontendUserData = $event->getFrontendUserData();
        $title = trim((string)($frontendUserData['tx_project_employee_type'] ?? ''));
        $category = $title === '' ? null : $this->findCategory($title);
        $importIdentifier = 'fe_users:' . $frontendUserData['uid'];
        foreach ($event->getProfile()->getContracts() as $contract) {
            if ($contract->getImportIdentifier() === $importIdentifier) {
                $contract->setEmployeeType($category);
            }
        }
    }

    private function findCategory(string $title): ?Category
    {
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
        $result = $queryBuilder
            ->select('uid', 'title')
            ->from('sys_category')
            ->where(
                $queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($title)),
                // A category type the project registers, EXT:category_types only.
                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('employee_type')),
                $queryBuilder->expr()->in(
                    'sys_language_uid',
                    $queryBuilder->quoteArrayBasedValueListToIntegerList([0, -1]),
                ),
                $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
            )
            ->orderBy('uid')
            ->executeQuery();
        while ($row = $result->fetchAssociative()) {
            if ($row['title'] === $title) {
                $category = $this->persistenceManager->getObjectByIdentifier((int)$row['uid'], Category::class);
                return $category instanceof Category ? $category : null;
            }
        }
        return null;
    }
}
```

Like the relations above, the listener then owns the employee type of the
imported contract: it clears it where the column is empty or names no
category.

## The records of a list {#configuration-frontend-user-sync-records}

Every entry of a list is one record of the imported contract, identified by an
import identifier:

-   The first address and the first e-mail address: `fe_users:<uid>`, the
    identifier they had before the lists existed.
-   Every further address and e-mail address, and every phone number:
    `<first column>:fe_users:<uid>`, for instance
    `tx_project_mobile:fe_users:42`.

The synchronisation finds a record by that identifier, hidden ones included,
updates it and never changes its visibility. It creates the record when it is
missing and removes it when every column of its entry is empty. Records an
editor added carry no import identifier and are never touched.

The backend form of a record shows its identifier read-only in the palette
**Import** of the tab **Extended**. On a profile the palette
also holds **Disable profile sync**. A record without an identifier
shows neither. The list module and the backend search find a profile by its
identifier. How import code of a project finds a record by it, and how the
contact records become searchable, is described in [Finding what an
import wrote](../../Developers/Index.html#developers-import-identifier).

The identifier follows the map, not the data:

-   Moving another entry to the front of the address or e-mail list makes it
    write its data onto the record `fe_users:<uid>`. The moved entry imports
    a new record, and the one it wrote before is no longer synchronised: it
    stays as it is and is never removed by the synchronisation again.
-   Changing the first column of an entry identified by it imports a new
    record in the same way.

An entry has to map at least one column. To stop synchronising a whole list,
set it to `[]`.

The imported contract itself exists for as long as one of its mapped sources -
a contract property, a relation or a column of any entry - is set. When all of
them are empty, `academic:updateprofiles` removes the contract, together
with every address, e-mail address and phone number of it, the ones an editor
added included; `academic:createprofiles` creates it with every new
profile, as it did before the map. A map that names no source of the contract
at all - no contract property, no relation and three empty lists - does not
synchronise the contract: it is neither created nor removed nor written.

## Changing the map {#configuration-frontend-user-sync-override}

A site package ships its own `Configuration/AcademicPersons/Settings.yaml`
and states what it changes. Maps are merged key by key; a list is replaced as a
whole, so a package adding a phone number repeats the shipped ones:

**EXT:my_sitepackage/Configuration/AcademicPersons/Settings.yaml**

```yaml
frontendUserSync:
  profile:
    # Editors maintain the website, the synchronisation leaves it alone.
    website: ''
  contract:
    position: tx_project_position
  phoneNumbers:
    - column: telephone
      type: ''
    - column: fax
      type: ''
    - column: tx_project_mobile
      type: mobile
```

The columns are not created by this map; they are columns of `fe_users`
the installation already has, for instance filled by an LDAP import, or values
a listener adds to the frontend user data while the synchronisation runs, see
[Taking part in the frontend user synchronisation](../../Developers/Index.html#developers-frontend-user-sync-events). Flush the TYPO3 caches after
changing the map.

A mistake in the map - an unknown property, a value that is not a string, a
list where a map belongs, an entry that maps no column, two entries of one list
named by the same first column, a phone number read from the column
`phone`, whose identifier is the one of the telephone records written
before 2.4, a relation matched by a field it does not offer, or one that
creates records without a `storagePid` \- does not break the site. The
synchronisation refuses to run on it: the default profile factory, and every
factory using the mapper below, throws an exception with the code
`1790142324` before anything is written, and its message names every mistake
with its path.

The map cannot tell a column name from a typo. The default profile factory
therefore also compares it with the frontend user record it synchronises and
refuses a column the record does not have, with the code `1790142326`. A
misspelled column would otherwise read as empty on every run and remove the
records it imported.

## Custom profile factories {#configuration-frontend-user-sync-factories}

A profile factory that reads its data from another source - an LDAP directory,
an HR system - can apply the same map instead of copying it. It injects
`\FGTCLB\AcademicPersons\Profile\FrontendUserProfileMapper` and passes an
array keyed like an `fe_users` record, with its `uid`:

-   **`applyProfile(array $frontendUserData, Profile $profile)`**

    Writes the mapped profile properties.

-   **`assertColumnsExist(array $frontendUserData)`**

    Refuses data that lacks its `uid` or a column the map reads. A
    factory whose data may lack a column - an LDAP entry omits empty
    attributes - does not call it, and the column reads as empty.

-   **`mapsContract()`**

    Whether the map names any source of the imported contract. Without one,
    leave the contract alone.

-   **`hasContractData(array $frontendUserData)`**

    Whether one mapped contract source is set.

-   **`applyContract(array $frontendUserData, Contract $contract, int $pid)`**

    Writes the mapped contract properties and relations, and synchronises the
    addresses, e-mail addresses and phone numbers of the contract. When it
    creates an organisational unit or function type, it saves everything the
    persistence manager holds at that moment. A factory that adds its profile
    before calling it gets the profile saved half-written, and completed when
    it saves at the end.

Creating the profile and the imported contract, and removing that contract,
stays with the factory, as `\FGTCLB\AcademicPersons\Profile\ProfileFactory`
shows.

A factory extending `\FGTCLB\AcademicPersons\Profile\AbstractProfileFactory`
dispatches the events of [Taking part in the frontend user synchronisation](../../Developers/Index.html#developers-frontend-user-sync-events) without
any code of its own. Its `createProfileFromFrontendUser()` may return
`null` when it has no profile for a frontend user, for example when the
directory it reads does not know the user. `academic:createprofiles` then
saves nothing for that frontend user, announces nothing and goes on with the
next one. A factory that always creates a profile may keep `Profile` as
its return type.

Before a factory of its own, check whether a listener is enough: a factory
replaces the default one as a whole, and does not pick up later fixes to it.
