For 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.

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. Both project list plugins also dispatch ModifyPluginViewEvent of academic_base when they render, after the list event; that page describes it.

The two 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.

Neither event fires 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 demand event, and both events fire 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.

EXT:my_extension/Classes/EventListener/ShowRunningProjectsOnly.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');
    }
}
Copied!

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:

$event->getView()->assign('projectCount', count($event->getProjects()->toArray()));
Copied!

Rules worth knowing 

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:

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

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.