Connect a job source to the import 

The job import reads jobs from XML sources. Each source is a PHP class that implements \JWeiland\Jobboard\ApiModel\ApiModelInterface and is tagged with api.model . The extension ships no source, add your classes to a site package.

Architecture 

Flow of the console command jobboard:import:jobs:mhm
ImportJobsMhm (console command, argument: storage page UID)
 ├── ImportService::import() returns an ImportResult
 │    ├── for each service tagged "api.model"
 │    │    ├── XmlClient: GET request to getApiEndpoint()
 │    │    │    (failed request or invalid XML: source failed,
 │    │    │     all its jobs are kept)
 │    │    ├── every child element of the XML root element = one job
 │    │    │    (jobs without vacancy_id or primary location are
 │    │    │     skipped with a warning)
 │    │    ├── TtAddressService: address of the primary location
 │    │    ├── JobService: job record built from getMapping(),
 │    │    │    job_areas/job_type resolved or created by title
 │    │    ├── ImportRecordValidator: remove invalid emails/URLs
 │    │    └── DataHandler: save address and job
 │    └── DataHandler: delete imported jobs that a successfully
 │                     fetched source no longer delivers
 └── exit code 0, or 1 if a source or job failed (skipped jobs are
     only shown as warning)
Copied!

Create a source class 

EXT:my_sitepackage/Classes/Import/ExampleJobBoardApiModel.php
<?php

declare(strict_types=1);

/*
 * This file is part of the package my-vendor/my-sitepackage.
 *
 * For the full copyright and license information, please read the
 * LICENSE file that was distributed with this source code.
 */

namespace MyVendor\MySitepackage\Import;

use JWeiland\Jobboard\ApiModel\ApiModelInterface;
use JWeiland\Jobboard\ApiModel\ApiModelTrait;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(tags: ['api.model'], shared: false)]
final class ExampleJobBoardApiModel implements ApiModelInterface
{
    use ApiModelTrait;

    private const NAME = 'example';

    private const API_ENDPOINT = 'https://example.org/api/jobs.xml';

    private const MAPPING = [
        'title' => [
            'apiPath' => 'title/de',
            'isDate' => false,
            'default' => '',
        ],
        'description' => [
            'apiPath' => 'description/de',
            'isDate' => false,
            'default' => '',
        ],
        'reference_number' => [
            'apiPath' => 'vacancy_id',
            'isDate' => false,
            'default' => '',
        ],
        'email' => [
            'apiPath' => 'application_email',
            'isDate' => false,
            'default' => '',
        ],
        'link' => [
            'apiPath' => 'publication_link',
            'isDate' => false,
            'default' => '',
        ],
        // Titles, resolved to (or created as) occupational field records
        'job_areas' => [
            'apiPath' => 'custom_select_4/de',
            'isDate' => false,
            'default' => '',
        ],
        // Title, resolved to (or created as) an employment scope record
        'job_type' => [
            'apiPath' => 'custom_select_2/de',
            'isDate' => false,
            'default' => '',
        ],
        'start_date' => [
            'apiPath' => 'start_date',
            'isDate' => true,
            'default' => 0,
        ],
        'application_deadline' => [
            'apiPath' => 'application_deadline',
            'isDate' => true,
            'default' => 0,
        ],
        'salary_mode' => [
            'apiPath' => 'salary_mode',
            'isDate' => false,
            'default' => 1,
        ],
        'salary_min' => [
            'apiPath' => 'salary/min',
            'isDate' => false,
            'default' => 0,
        ],
        'salary_max' => [
            'apiPath' => 'salary/max',
            'isDate' => false,
            'default' => 0,
        ],
        'vacancy_id' => [
            'apiPath' => 'vacancy_id',
            'isDate' => false,
            'default' => 0,
            'prefix' => self::NAME,
        ],
    ];

    private int $storagePid = 0;
}
Copied!

The trait \JWeiland\Jobboard\ApiModel\ApiModelTrait implements all methods of the interface. It needs the class constants NAME , API_ENDPOINT and MAPPING and the property private int $storagePid = 0; , which the trait does not declare.

NAME
Short, unique name of the source. It is used as prefix of the vacancy ID, for example example_4711.
API_ENDPOINT
URL of the XML file. It is requested with the TYPO3 RequestFactory , so the HTTP settings of $GLOBALS['TYPO3_CONF_VARS']['HTTP'] (timeout, proxy) apply.
MAPPING
Maps XML values to columns of the table tx_jobboard_domain_model_job , see The mapping.

Register the class 

With the attribute #[Autoconfigure(tags: ['api.model'], shared: false)] , the class is registered automatically, if the Configuration/Services.yaml of your site package enables autoconfigure for this class. Without the attribute, tag the class in Services.yaml:

EXT:my_sitepackage/Configuration/Services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  MyVendor\MySitepackage\:
    resource: '../Classes/*'

  # Only needed without the #[Autoconfigure] attribute
  MyVendor\MySitepackage\Import\ExampleJobBoardApiModel:
    shared: false
    tags:
      - name: api.model
Copied!

Flush all caches after adding or removing a source, because the list of sources is compiled into the dependency injection container: vendor/bin/typo3 cache:flush .

The mapping 

Each key of MAPPING is a column of tx_jobboard_domain_model_job . Each value is an array with these keys:

apiPath (required)
XPath of the value, relative to the job element, for example title/de.
isDate (required)
true converts the value to a Unix timestamp. Supported formats are Y-m-d H:i:s O, Y-m-d H:i:s and Y-m-d. An empty value or 0 results in 0 .
default (required)
Used if the XML element is missing, or if the value cannot be converted, for example a date in an unknown format. The default is stored as it is, without date conversion and without prefix. An XML element that exists but is empty stays empty.
prefix (optional)
Stored as <prefix>_<value>. For vacancy_id , the prefix must be the NAME of the source, because the import finds existing jobs by <NAME>_<vacancy ID> and recognizes the jobs of a source by this prefix.

These columns are set by the import itself and need no mapping: pid (the storage page of the command), is_import and address . job_areas and job_type are read from the default paths below; map them only to read their titles from another element. Their mapping is never written as it is, its titles are always resolved to records.

The import sets no employer address. Name and logo of the employer are taken from the address, which editors select in the field Address of the employer (company and first image of the address). The structured data of a job without employer address uses the company of the workplace address as organization name.

Expected XML structure 

The services of the import expect some elements at fixed paths:

Example XML of a source
<?xml version="1.0" encoding="UTF-8"?>
<jobs>
    <job>
        <vacancy_id>4711</vacancy_id>
        <title><de>Web Developer (m/f/d)</de></title>
        <description><de>&lt;p&gt;We are looking for ...&lt;/p&gt;</de></description>
        <application_email>jobs@example.org</application_email>
        <publication_link>https://example.org/jobs/4711</publication_link>
        <start_date>2026-12-01</start_date>
        <application_deadline>2026-11-15 00:00:00 +0100</application_deadline>
        <salary_mode>1</salary_mode>
        <salary>
            <min>4200.00</min>
            <max>5600.00</max>
        </salary>
        <!-- Titles of occupational fields, one per line -->
        <custom_select_4><de>IT
Administration</de></custom_select_4>
        <!-- Title of the employment scope -->
        <custom_select_2><de>Full-time</de></custom_select_2>
        <manager_name>Jane Doe</manager_name>
        <manager_email>jobs@example.org</manager_email>
        <locations>
            <location primary="true">
                <technical_name>location-1</technical_name>
                <de>Sample Company Ltd.</de>
                <street>Example Street 1</street>
                <zipcode>12345</zipcode>
                <city>Sample Town</city>
            </location>
        </locations>
    </job>
</jobs>
Copied!
Root element
Any name. Each child element is one job.
vacancy_id
ID of the job in the source, any string. Surrounding whitespace is removed. Required to update existing jobs instead of creating new ones; jobs without vacancy ID are skipped.
custom_select_4/de
Titles of the occupational fields, one per line.
custom_select_2/de
Title of the employment scope.
locations/location[@primary="true"]
The primary location, which becomes the workplace address. Its technical_name is stored as import key of the address, de as company, plus street, zipcode and city. A job without primary location is skipped.
manager_name, manager_email
Name and email of the address.

Occupational fields and employment scopes are found by their exact title in any language, hidden records included, and the record of the default language is related. A title without a matching record is created as new record in the default language on the storage page of the command.

Jobs with invalid data are skipped with a warning in the log and do not let the command fail: jobs without vacancy ID or without primary location. Invalid email addresses and URLs are removed from the job or address with a warning, the job is imported without them. The command only fails, if a source could not be fetched or a job could not be saved.

Salary information of imported jobs 

Like all jobs, imported jobs are only shown with salary information, as long as the site setting jobboard.salary.required is enabled (the default). Map salary_mode to an XML element with the value 1 (free salary entry) and map salary_min and salary_max . Required fields of the backend form, like the reference number or the start date, are not checked by the import.