---
title: "For developers"
manual: "Academic Bite Jobs"
version: "main"
permalink: "https://docs.typo3.org/permalink/fgtclb/academic-bite-jobs:developers@main"
source: "Developers/Index.rst"
rendered: "2026-10-09T15:00:56+00:00"
---

# For developers {#developers}

The job list asks one service for its postings, and that service dispatches two
PSR-14 events: one before the request is sent to the B-ITE API, one after the
response is decoded. They are the supported way to filter by a B-ITE custom
field, to ask for another locale, or to remove, enrich and group the postings,
without copying the service or the controller.

Which classes of this extension are public API, and what that promises, is
stated for all academic extensions on the [extension points page of
academic_base](https://docs.typo3.org/p/fgtclb/academic-base/main/en-us/Developers/ExtensionPoints/Index.html).
The job list also dispatches `ModifyPluginViewEvent` of
**academic_base** when it renders, after both events, and that page
describes it.

## The request event {#developers-request-event}

`\FGTCLB\AcademicBiteJobs\Event\ModifyBiteJobPostingsRequestEvent` is dispatched
each time a job list renders, after the request is built from the plugin
settings and before it is sent. The payload a listener hands back is sent as it
is, encoded as JSON.

| Method | Returns |
| --- | --- |
| `getPayload()`, `setPayload()` | The request sent to the B-ITE search API, see the keys below. |
| `getSettings()` | The values stored below `settings.jobs` in the plugin settings of the content element, which the payload is built from. They are not merged with TypoScript and not normalised, see below. |
| `getRequest()` | The request of the page being rendered. |
| `getPluginControllerActionContext()` | The context of the job list that asked, with all its settings, TypoScript included, or `null` when the service was called outside of a plugin. |

### The keys of the payload {#developers-request-payload}

The extension sends these keys. A listener changes any of them and adds every
other key the B-ITE search API accepts. Renaming or removing one of them in a
later version is a breaking change.

| Key | Sent by the extension |
| --- | --- |
| `key` | The job listing key of the content element. |
| `channel` | `0`. |
| `locale` | `de`. |
| `page` | `offset` `0`: the postings from the first one on. |
| `filter` | An empty filter: every posting of the job listing. |
| `sort` | `order` and `by`, the sort direction and the sort field of the content element. |

## The result event {#developers-result-event}

`\FGTCLB\AcademicBiteJobs\Event\ModifyBiteJobPostingsEvent` is dispatched
each time a job list renders, after the response is decoded. It is dispatched
after a failed request as well, then with no postings and no response data, so a
listener does not need to know how the request went.

| Method | Returns |
| --- | --- |
| `getJobPostings()`, `setJobPostings()` | The postings the job list renders, a list with one array per posting as B-ITE answers it. The setter refuses anything else. |
| `getResponseData()` | The decoded response of the B-ITE API, or an empty array when the request failed or the response was not JSON. |
| `getSettings()` | The values stored below `settings.jobs` in the plugin settings of the content element, as in the request event. |
| `getRequest()` | The request of the page being rendered. |
| `getPluginControllerActionContext()` | The context of the job list that asked, as in the request event. |

The limit of the content element is applied to the postings the listeners hand
back, so a listener sees every posting of the response and the job list never
renders more than the limit.

## Example: a custom field filter and a grouping {#developers-example}

Up to version 2.0 the extension filtered by the B-ITE custom field `zuordnung`
of one installation and grouped the postings by its value. Version 2.1 removed
that code, see [Breaking: Remove project specific custom fields](https://docs.typo3.org/permalink/fgtclb/academic-bite-jobs:breaking-1758798000@main). Two listeners bring it back for the
installation that needs it, and only there.

The first one adds the filter to the request. The value comes from a plugin
setting of the project's own FlexForm, here `settings.jobs.relation`:

**EXT:my_extension/Classes/EventListener/FilterJobsByRelation.php**

```php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use FGTCLB\AcademicBiteJobs\Event\ModifyBiteJobPostingsRequestEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;

final class FilterJobsByRelation
{
    #[AsEventListener(identifier: 'my-extension/filter-jobs-by-relation')]
    public function __invoke(ModifyBiteJobPostingsRequestEvent $event): void
    {
        $relation = (string)($event->getSettings()['relation'] ?? '');
        if ($relation === '' || $relation === 'all') {
            return;
        }
        $payload = $event->getPayload();
        $payload['filter'] = ['custom.zuordnung' => ['in' => [$relation]]];
        $event->setPayload($payload);
    }
}
```

The second one writes the name of the relation into every posting, so the job
list can group by it:

**EXT:my_extension/Classes/EventListener/NameTheRelationOfEveryJob.php**

```php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use FGTCLB\AcademicBiteJobs\Event\ModifyBiteJobPostingsEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;

final class NameTheRelationOfEveryJob
{
    private const RELATION_NAMES = [
        '01' => 'Appointment procedures',
        '02' => 'Academic staff',
        '03' => 'Non-scientific staff',
        '04' => 'Training positions',
    ];

    #[AsEventListener(identifier: 'my-extension/name-the-relation-of-every-job')]
    public function __invoke(ModifyBiteJobPostingsEvent $event): void
    {
        $jobs = $event->getJobPostings();
        foreach ($jobs as $index => $job) {
            $relation = (string)($job['custom']['zuordnung'] ?? '');
            $jobs[$index]['relationName'] = self::RELATION_NAMES[$relation] ?? '';
        }
        $event->setJobPostings($jobs);
    }
}
```

The grouping itself is TypoScript, see [Grouping the jobs](https://docs.typo3.org/permalink/fgtclb/academic-bite-jobs:configuration-general-group-by@main):

**TypoScript setup**

```typoscript
plugin.tx_academicbitejobs.settings.jobs.groupBy = relationName
```

Where the custom field sits in a posting, and which values it has, depends on
how it is set up in B-ITE, so read one response of your job listing before
relying on the path above. A listener that needs the labels of the values can
ask the options API of B-ITE for them.

The upgrade wizard `academicBiteJobs_listViewFlexFormUpgradeWizard` removes the
setting `settings.jobs.custom.zuordnung` of version 2.0 from every job list, so
a project FlexForm that brings the field back gives it a name of its own, as
`settings.jobs.relation` above.

## Rules worth knowing {#developers-rules}

**Two kinds of settings.** `getSettings()` returns what the content element
stores below `settings.jobs`, exactly as it is stored: without the
TypoScript of the plugin, and with the view value of a content element saved
with version 2.0 as it was saved. The settings the job list works with, the
TypoScript `settings.jobs.groupBy` included, are those of the plugin
action context, `$event->getPluginControllerActionContext()?->getSettings()`.

**The events run when the page is rendered, not per visitor.** A page is cached
with the postings the listeners handed back, so a listener cannot show a
different list to different visitors of a cached page.

**A listener is not guarded.** An exception a listener throws reaches the
visitor, as any error of project code does. A payload that cannot be encoded as
JSON is logged like a failed request, and the job list renders the postings the
result listeners hand back.

**Two job lists on a page are two requests.** Each job list sends its own
request and dispatches both events once. A job list whose request fails renders
no postings, whatever another job list on the page received.
