---
title: "ModifyProjectDemandEvent"
manual: "Academic Projects"
version: "main"
permalink: "https://docs.typo3.org/permalink/fgtclb/academic-projects:developers-event-modify-project-demand-event@main"
source: "Developers/Events/ModifyProjectDemandEvent.rst"
rendered: "2026-10-26T19:20:38+00:00"
---

# ModifyProjectDemandEvent {#developers-event-modify-project-demand-event}

`\FGTCLB\AcademicProjects\Event\ModifyProjectDemandEvent` hands a
listener the demand a project list is about to be queried with. The demand a
listener hands back is the one that is queried and assigned to the view as
`{demand}`.

## When it is dispatched {#when-it-is-dispatched}

`ProjectController::listAction()` dispatches the event once per rendering
of the **Projects** and the **Selected Projects** content
elements, after the demand is built from the settings of the content element
and the selection of the visitor, and before the projects are queried.
[ModifyProjectListEvent](https://docs.typo3.org/permalink/fgtclb/academic-projects:developers-event-modify-project-list-event@main) follows once the projects
are queried.

The event does not fire for a submission of the filter and sorting form: the
plugin answers the POST with a redirect to a URL carrying the selection before
the event, and the event fires on the request that follows, with the selection
in the query string (or the route), not in the parsed body. See
[Feature: Project filter selections have a URL](https://docs.typo3.org/permalink/fgtclb/academic-projects:feature-1790226101@main).

## What a listener reads and changes {#what-a-listener-reads-and-changes}

| Method | What it does |
| --- | --- |
| `getDemand()` | The demand, `\FGTCLB\AcademicProjects\Domain\Model\Dto\ProjectDemand`. It is an object, so a change to it takes effect without `setDemand()`. |
| `setDemand()` | Replaces the demand that is queried. |
| `getPluginControllerActionContext()` | The context of the rendering, `\FGTCLB\AcademicBase\Domain\Model\Dto\PluginControllerActionContextInterface`: the request, the site and its language, the content object of the element, the settings of the content element and the plugin name. |

What the query reads from the demand:

| Getter and setter | Meaning |
| --- | --- |
| `getPages()`, `setPages()` | The uids of the field below the options: the pages the projects sit directly in, or the picked project pages. Empty means every project page of the installation. |
| `getShowSelected()`, `setShowSelected()` | How the pages are read: `true` for **Selected Projects**, where they are the project pages themselves, matched across languages, `false` for **Projects**, where they are the parent pages. |
| `getActiveState()`, `setActiveState()` | `all`, `active` or `completed`. The setter throws an `\InvalidArgumentException` for any other value. |
| `getFilterCollection()`, `setFilterCollection()` | The selected categories, those of the visitor or the **Categories** of the element, as a `\FGTCLB\CategoryTypes\Collection\FilterCollection`, or `null` for none. A project has to carry every one of them. |
| `getSorting()`, `setSorting()`, and the field and the direction on their own | The ordering, one of the values of `\FGTCLB\AcademicProjects\Enumeration\SortingOptions`, for example `tx_academicprojects_start_date desc`. |
| `getShowHiddenRecords()`, `setShowHiddenRecords()` | Whether hidden project pages are listed, the field **Show hidden records** of the element. |

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

## Example {#example}

A site keeps its main project list to the running projects, whatever state the
editor or the visitor chose, and leaves the selections made by hand alone:

**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 state select of the form then still offers **All** and
**Completed**, which change nothing. Switch off
**Show active state options** in the content elements the listener
acts on, or check the content element in the listener, through the record on
`getContentObjectRenderer()` of the context.

## Rules and pitfalls {#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, and 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 category a listener adds is shown as an active filter.** The active filter
tags of [Active filters, reset link and result count](https://docs.typo3.org/permalink/fgtclb/academic-projects:configuration-active-filters@main) read the demand the list was
queried with. A category a listener adds to every request is a tag the visitor
cannot remove, since the listener adds it again on the page the tag leads to.
The same holds for a state other than `all`.

**A field the project adds to the filter form does not reach the event.** The
redirect after a submission carries the selection the plugin accepted and
nothing else, so a listener cannot read a field of its own from the request
that follows.

## Introduced in {#introduced-in}

-   [Feature: Change the project lists by event](https://docs.typo3.org/permalink/fgtclb/academic-projects:feature-list-plugin-events@main) added the event in 3.0.
-   [Breaking: The project controller is final](https://docs.typo3.org/permalink/fgtclb/academic-projects:breaking-1791043407@main) made the controller final and names the event
    as the replacement of a subclass that changed the filter, the state or the
    sorting.
-   [Feature: Project filter selections have a URL](https://docs.typo3.org/permalink/fgtclb/academic-projects:feature-1790226101@main) answers a filter submission with a redirect, so
    the event fires on the request that follows it.
