---
title: "For developers"
manual: "Academic Projects"
version: "main"
source: "Developers/Index.rst"
rendered: "2026-09-22T20:39:25+00:00"
---

# For developers {#developers}

Both project list plugins - **Project list** and
**Project list (single selection)** \- dispatch two PSR-14 events. They
are the supported way to change what a plugin queries and what it renders, and
they replace the only alternative there used to be: subclassing
`ProjectController` and re-registering the plugin.

## The two events {#developers-project-events}

| Event | Dispatched | A listener may |
| --- | --- | --- |
| `\FGTCLB\AcademicProjects\Event\ModifyProjectDemandEvent` | in `listAction()`, after the demand is built from the content element settings and the request and before the projects are queried | replace the demand |
| `\FGTCLB\AcademicProjects\Event\ModifyProjectListEvent` | in the same action, after the query and before the view variables are assigned | replace the projects, replace the applicable categories, assign further view variables |

One action serves both plugins, so both events fire for both. Which one is
rendering is on the context,
`\FGTCLB\AcademicBase\Domain\Model\Dto\PluginControllerActionContextInterface`,
which `getPluginControllerActionContext()` returns: the request, the site
and its language, the content object of the element, the settings of the
content element and the plugin name.

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

```php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use FGTCLB\AcademicProjects\Event\ModifyProjectDemandEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;

final class ShowRunningProjectsOnly
{
    #[AsEventListener(identifier: 'my-extension/show-running-projects-only')]
    public function __invoke(ModifyProjectDemandEvent $event): void
    {
        if ($event->getPluginControllerActionContext()->getPluginName() !== 'ProjectList') {
            return;
        }
        $event->getDemand()->setActiveState('active');
    }
}
```

The plugin names are the names the plugins were **registered** with -
`ProjectList` and `ProjectListSingle` \- not the content element
types.

`ModifyProjectListEvent` carries the view as well, so a listener assigns
values a project template renders:

```php
$event->getView()->assign('projectCount', count($event->getProjects()->toArray()));
```

## Rules worth knowing {#developers-project-events-rules}

> [!WARNING]
> **A demand listener widens as easily as it narrows.** The demand *is* the
> query here, not a constraint added to one, and almost nothing guards it:
> `setShowHiddenRecords(true)` shows hidden project pages to every
> visitor, `setPages([])` drops the storage restriction the editor chose
> in the content element, and `setSorting()` overrides the editor's
> ordering for every element that renders - though only for a value that is
> one of the `SortingOptions` constants, since anything else is ignored
> without a word. Two listeners that disagree are resolved by the order they
> run in - the last one wins.
>
> What a listener cannot undo: the page type is pinned unconditionally,
> `showHiddenRecords` reaches the `disabled` flag alone (start- and
> endtime, `fe_group` and `deleted` stay in effect), and a
> `uid` tiebreaker is always appended to the ordering.

**A replaced demand starts from the defaults.** `setDemand()` is there for
a listener that builds its own demand, and such a demand carries none of what
the plugin put in the one it was handed: the `showHiddenRecords` choice of
the editor, and the three the visitor can set themselves through the filter
form - the `sorting`, the `activeState` and the
`filterCollection` \- so a replacement resets the ordering, the state
filter and the category filter under them. `showSelected` has to travel
with `pages` or it changes what they mean: the repository reads
`pages` as the selected project uids when `showSelected` is true and
as storage page uids when it is false, so a single-selection element whose
demand is replaced without it turns into a storage-folder restriction. Mutate
the demand where that is enough, and carry `getPages()`,
`getShowSelected()`, `getShowHiddenRecords()`,
`getActiveState()`, `getFilterCollection()` and `getSorting()`
over where it is not.

**A replaced result is rendered as it is.** `setProjects()` takes whatever
query result a listener hands back, and the ordering of the repository is not
reapplied to it. A listener that builds its own result gives it its own
ordering, or the list is in whatever order the database happens to return -
which is not the same list twice on PostgreSQL.

**The categories are not recomputed.** They are computed from the queried
projects, once, before the list event. A listener that replaces the projects
and wants the filter of the plugin to match them sets the categories too:

```php
$event->setProjects($narrowedResult);
$event->setCategories(
    $this->categoryRepository->findAllApplicable('projects', ...$narrowedResult->toArray()),
);
```

`findAllApplicable()` is what the controller itself calls: it keeps every
category of the group and marks the ones no record carries as disabled options.
`findByGroupAndUidList()` returns a bare list instead, so a listener that
reaches for that one drops the disabled options the filter otherwise shows.

Nothing changes in an installation that has no listener: both plugins query and
render exactly what they did before.
