Skip to content

Optional Hooks

Plugins implement additional interfaces only for the capabilities they need. Each interface is auto-registered via #[AutoconfigureTag] — no manual service config required.


Capabilities at a glance

Interface When to use it Key method
Plugin (base interface) Serve CSS/JS assets from your plugin getStylesheets(), getJavascripts()
AdminNavigationInterface Add sections and links to the admin sidebar getAdminNavigation()
EventFilterInterface Control which events are visible getEventIdFilter()
MenuFilterInterface Filter or modify navigation links filterMenuLinks()
CmsFilterInterface Control which CMS pages are visible getCmsPageSlugs()
MemberFilterInterface Filter which members appear in lists getUserIds()
EventFilterFormContributorInterface Add fields to the event filter form addFields()
NotificationProviderInterface Add informational items to the notification bell getNotifications()
ReviewNotificationProviderInterface Add approve/deny items to the review page getReviewItems(), approveItem(), denyItem()
EntityActionInterface React to core entity lifecycle events handleEntityAction()
ActivityMetaEnricherInterface Enrich metadata on all activity types enrich()
MessageInterface Define a new activity type with display rendering getType(), validate(), render()
SitemapPublisherInterface Contribute URLs to /sitemap.xml getPriority(), getSitemapUrls()
SitemapEventVisibilityFilterInterface Suppress event URLs on specific tenants shouldEmitEvents()
FollowerEventNotificationFilterInterface Drop follower-RSVP email recipients per event isFollowerAllowed()
DataHotfixInterface Ship a one-off data repair that runs once per DB getIdentifier(), execute()
SecurityProviderInterface Participate in live security event detection observe(), scanRetrospective()

Plugin Assets

Plugins can serve their own CSS and JavaScript through Symfony AssetMapper by implementing two methods on the base Plugin interface.

Directory structure

Place assets inside your plugin's assets/ directory:

plugins/your-plugin/
└── assets/
    ├── styles/        ← CSS/SCSS files
    ├── js/            ← JavaScript files
    ├── images/        ← Plugin-specific static images
    └── fonts/         ← Plugin-specific fonts (rare)

This directory is auto-discovered by the AssetMapper configuration — no core changes needed.

Returning asset paths from Kernel.php

public function getStylesheets(): array
{
    return ['styles/myplugin.css'];  // relative to plugins/your-plugin/assets/
}

public function getJavascripts(): array
{
    return ['js/myplugin.js'];  // relative to plugins/your-plugin/assets/
}

How paths resolve

What you return File on disk Logical asset path asset() output
styles/myplugin.css plugins/your-plugin/assets/styles/myplugin.css plugins/your-plugin/styles/myplugin.css /assets/plugins/your-plugin/styles/myplugin-{hash}.css
js/myplugin.js plugins/your-plugin/assets/js/myplugin.js plugins/your-plugin/js/myplugin.js /assets/plugins/your-plugin/js/myplugin-{hash}.js

The PluginExtension Twig service prefixes your paths with plugins/your-plugin/ automatically. The base template wraps each path in {{ asset(...) }} — you never call asset() yourself from Kernel.php.

Referencing plugin images in CSS

From plugins/your-plugin/assets/styles/myplugin.css, images are at ../images/:

.my-icon {
    background-image: url('../images/icon.svg');
}

Referencing plugin images in Twig

<img src="{{ asset('plugins/your-plugin/images/icon.svg') }}" alt="">

AdminNavigationInterface

Purpose: Add sections and links to the admin sidebar (the modern approach — replaces the deprecated getAdminSystemLinks()).

File: src/Controller/Admin/AdminNavigationInterface.php

When called: When rendering any admin page.

namespace Plugin\YourPlugin\Controller\Admin;

use App\Controller\Admin\AbstractAdminController;
use App\Controller\Admin\AdminNavigationConfig;
use App\Controller\Admin\AdminNavigationInterface;
use App\Entity\AdminLink;
use App\Enum\CoreRole;

class DashboardController extends AbstractAdminController implements AdminNavigationInterface
{
    public function getAdminNavigation(): ?AdminNavigationConfig
    {
        return new AdminNavigationConfig(
            section: 'Your Plugin',
            links: [
                new AdminLink(
                    label: 'menu_admin_dashboard',
                    route: 'app_admin_plugin_dashboard',
                    active: 'dashboard',
                ),
                new AdminLink(
                    label: 'menu_admin_settings',
                    route: 'app_admin_plugin_settings',
                    active: 'settings',
                    role: CoreRole::Admin, // Optional — restrict this link by role
                ),
            ],
            sectionRole: CoreRole::Organizer, // Optional — hide entire section by role
        );
    }
}

AdminLink parameters:

  • label — Translation key for link text
  • route — Symfony route name
  • active — State identifier (used to highlight the active link)
  • role — Optional RoleInterface value (hides this link if user lacks that role)

AdminNavigationConfig parameters:

  • section — Section heading in the sidebar
  • links — Array of AdminLink objects
  • sectionRole — Optional RoleInterface value (hides the entire section)

Filters

Filter interfaces use AND logic with a priority chain: a filter with getEventIdFilter() returning a non-empty array will restrict results to that set. Returning an empty array means "no filtering from this provider."

EventFilterInterface

Purpose: Restrict which events are visible to the current user.

File: src/Filter/Event/EventFilterInterface.php

Tag: #[AutoconfigureTag('app.event_filter')]

When called: Every event query — lists, searches, and detail pages.

namespace Plugin\YourPlugin\Filter;

use App\Filter\Event\EventFilterInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.event_filter')]
readonly class PrivateEventFilter implements EventFilterInterface
{
    public function getPriority(): int
    {
        return 100; // Higher runs first
    }

    public function getEventIdFilter(): array
    {
        // Return IDs of events that SHOULD be visible.
        // Empty array = no filtering from this provider.
        return $this->getVisibleEventIds();
    }

    public function isEventAccessible(int $eventId): bool
    {
        return $this->canUserAccessEvent($eventId);
    }
}

Use cases: Private/public event visibility, group-based access control.


Purpose: Filter or modify navigation links based on context (e.g. active domain, user role).

File: src/Filter/Menu/MenuFilterInterface.php

Tag: #[AutoconfigureTag('app.menu_filter')]

When called: When rendering the navigation menu.

namespace Plugin\YourPlugin\Filter;

use App\Filter\Menu\MenuFilterInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.menu_filter')]
readonly class ContextMenuFilter implements MenuFilterInterface
{
    public function getPriority(): int
    {
        return 100;
    }

    public function filterMenuLinks(array $links): array
    {
        return array_filter($links, fn($link) => $this->shouldShowLink($link));
    }
}

Use cases: Multi-tenant menu filtering, hiding links by domain or role.


CmsFilterInterface

Purpose: Restrict which CMS pages are visible.

File: src/Filter/Cms/CmsFilterInterface.php

Tag: #[AutoconfigureTag('app.cms_filter')]

When called: When querying CMS pages.

namespace Plugin\YourPlugin\Filter;

use App\Filter\Cms\CmsFilterInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.cms_filter')]
readonly class CmsContextFilter implements CmsFilterInterface
{
    public function getPriority(): int
    {
        return 100;
    }

    public function getCmsPageSlugs(): array
    {
        // Return slugs of pages that SHOULD be visible.
        // Empty array = no filtering.
        return $this->getVisiblePageSlugs();
    }
}

MemberFilterInterface

Purpose: Restrict which members appear in member lists.

File: src/Filter/Member/MemberFilterInterface.php

Tag: #[AutoconfigureTag('app.member_filter')]

When called: When rendering member lists.

namespace Plugin\YourPlugin\Filter;

use App\Filter\Member\MemberFilterInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.member_filter')]
readonly class GroupMemberFilter implements MemberFilterInterface
{
    public function getPriority(): int
    {
        return 100;
    }

    public function getUserIds(): array
    {
        // Return IDs of users that SHOULD be visible.
        // Empty array = no filtering.
        return $this->getVisibleUserIds();
    }
}

SitemapPublisherInterface

Purpose: Contribute URL entries to the sitemap served at /sitemap.xml.

File: src/Publisher/Sitemap/SitemapPublisherInterface.php

Tag: Auto-tagged via #[AutoconfigureTag] on the interface.

When called: On each /sitemap.xml request. The core SitemapService collects all publishers in priority order (higher first) and writes their URLs into a single flat <urlset>.

namespace Plugin\YourPlugin\Publisher\Sitemap;

use App\Publisher\Sitemap\SitemapPublisherInterface;
use App\Publisher\Sitemap\SitemapUrl;
use DateTimeImmutable;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

readonly class YourPluginSitemapPublisher implements SitemapPublisherInterface
{
    public function __construct(
        private UrlGeneratorInterface $urlGenerator,
    ) {}

    public function getPriority(): int
    {
        return 10;
    }

    public function getSitemapUrls(): array
    {
        return [
            new SitemapUrl(
                loc: $this->urlGenerator->generate('your_plugin_page', [], UrlGeneratorInterface::ABSOLUTE_URL),
                lastmod: new DateTimeImmutable(),
                priority: 0.7,
            ),
        ];
    }
}

Return an empty array to skip on the current request (e.g. a marketing-route publisher that only emits on the platform host).

What belongs in a sitemap:

  • Public pages only - skip anything behind IsGranted stronger than PUBLIC_ACCESS. Crawlers cannot follow auth-gated routes; emitting them just bounces them off your login page.
  • Skip token/POST flows (/register/verify/{code}, password-reset confirms, redirect handlers).
  • Auth utility pages (login, register, reset, cookie) belong in core's publisher only - plugins should not duplicate them. Use a low priority (0.3) so they don't steal crawl budget from content pages.
  • Detail pages should carry a lastmod (use the entity's updatedAt or createdAt); index pages rarely need one.
  • Respect tenant filters: if your plugin has a multisite group filter (e.g. getApprovedList() already applies one), reuse that path so whitelabel sitemaps only list content the tenant can actually serve.

SitemapEventVisibilityFilterInterface

Purpose: Suppress event URLs from /sitemap.xml in specific contexts. Core emits events by default; any registered filter returning false vetoes that.

File: src/Filter/Sitemap/SitemapEventVisibilityFilterInterface.php

Tag: Auto-tagged via #[AutoconfigureTag] on the interface.

Example: The multisite plugin uses this to hide events on whitelabel hosts because events are platform-canonical.

namespace Plugin\YourPlugin\Filter\Sitemap;

use App\Filter\Sitemap\SitemapEventVisibilityFilterInterface;

readonly class HideEventsOnSomeTenants implements SitemapEventVisibilityFilterInterface
{
    public function shouldEmitEvents(): bool
    {
        return $this->currentContextAllowsEvents();
    }
}

Permissions and access control

Event-scoped action gating (RSVP, comments, uploads) and custom runtime permission checks are implemented as standard Symfony voters — no custom interfaces or tags required.

See Permissions for the full guide.


Content

EventFilterFormContributorInterface

Purpose: Add custom fields to the event filter form.

File: src/Form/EventFilterFormContributorInterface.php

Tag: #[AutoconfigureTag('app.event_filter_form_contributor')]

When called: When building the event filter form.

namespace Plugin\YourPlugin\Form;

use App\Form\EventFilterFormContributorInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;

#[AutoconfigureTag('app.event_filter_form_contributor')]
readonly class CategoryFilterContributor implements EventFilterFormContributorInterface
{
    public function addFields(FormBuilderInterface $builder): void
    {
        $builder->add('category', ChoiceType::class, [
            'label' => 'Category',
            'choices' => [
                'All Categories' => null,
                'Indoor' => 'indoor',
                'Outdoor' => 'outdoor',
            ],
            'required' => false,
        ]);
    }
}

NotificationProviderInterface

Purpose: Contribute informational items to the notification bell - items that link to a page but need no approve/deny action (e.g. open polls, unread messages).

File: src/Service/Notification/User/NotificationProviderInterface.php

When called: When rendering the notification menu.

namespace Plugin\YourPlugin\Notification;

use App\Entity\User;
use App\Service\Notification\User\NotificationItem;
use App\Service\Notification\User\NotificationProviderInterface;

readonly class MyNotificationProvider implements NotificationProviderInterface
{
    public function getNotifications(User $user): array
    {
        $activePoll = $this->pollService->getActivePoll();
        if ($activePoll === null) {
            return [];
        }

        return [
            new NotificationItem(
                label: 'Open poll - cast your vote!',
                icon: 'fa-vote-yea',
                route: 'app_plugin_myplugin_poll',
            ),
        ];
    }
}

ReviewNotificationProviderInterface

Purpose: Surface approve/deny items on the central review page at /profile/review. The navbar shows a single consolidated count entry for all pending review items; the review page renders each item with Approve and Deny buttons.

File: src/Service/Notification/User/ReviewNotificationProviderInterface.php

When called: When the navbar count is computed and when the review page renders.

namespace Plugin\YourPlugin\Notification;

use App\Entity\User;
use App\Service\Notification\User\ReviewNotificationItem;
use App\Service\Notification\User\ReviewNotificationProviderInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

readonly class MyReviewProvider implements ReviewNotificationProviderInterface
{
    public function getIdentifier(): string
    {
        return 'myplugin.pending_items'; // stable forever - never change after deployment
    }

    /** @return ReviewNotificationItem[] */
    public function getReviewItems(User $user): array
    {
        if (!$this->security->isGranted('ROLE_ORGANIZER')) {
            return [];
        }

        return array_map(
            static fn($item) => new ReviewNotificationItem(
                id: (string) $item->getId(),
                description: sprintf("Item '%s' pending approval", $item->getName()),
                canDeny: true,
                icon: 'check',
            ),
            $this->repository->findPending(),
        );
    }

    public function approveItem(User $user, string $itemId): void
    {
        if (!$this->security->isGranted('ROLE_ORGANIZER')) {
            throw new AccessDeniedException();
        }
        $this->myService->approve((int) $itemId);
    }

    public function denyItem(User $user, string $itemId): void
    {
        if (!$this->security->isGranted('ROLE_ORGANIZER')) {
            throw new AccessDeniedException();
        }
        $this->myService->reject((int) $itemId);
    }
}

Key rules: - getIdentifier() is embedded in form action URLs - never change it once deployed - Both approveItem() and denyItem() must check authorisation themselves and throw AccessDeniedException if denied - itemId is always cast to/from string; use (int) $itemId to recover the DB id


Lifecycle

EntityActionInterface

Purpose: React to core entity lifecycle events — creation, update, deletion.

File: src/Entity/Action/EntityActionInterface.php

Tag: #[AutoconfigureTag('app.entity_action')]

When called: Whenever a core entity changes state.

namespace Plugin\YourPlugin\Action;

use App\Entity\Action\EntityActionInterface;
use App\Enum\EntityAction;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.entity_action')]
readonly class MembershipActionHandler implements EntityActionInterface
{
    public function handleEntityAction(EntityAction $action, int $entityId): void
    {
        match ($action) {
            EntityAction::MemberCreated => $this->onMemberCreated($entityId),
            EntityAction::MemberDeleted => $this->onMemberDeleted($entityId),
            default => null,
        };
    }
}

EntityAction enum values — the full list of actions you can react to:

Value Triggered when
EntityAction::MemberCreated A new member joins
EntityAction::MemberDeleted A member is removed
EntityAction::EventCreated A new event is created
EntityAction::EventUpdated An event is updated
EntityAction::EventDeleted An event is deleted
EntityAction::RsvpAdded A user RSVPs to an event
EntityAction::RsvpRemoved A user removes their RSVP

Tip

Return null from the default branch of your match — it signals "nothing to do" without throwing an error for unknown future actions.


Activity Logging

Adding activity message types

Plugins define their own activity types by creating message classes. MessageFactory auto-discovers all MessageInterface implementations — no service configuration needed.

File location: plugins/<name>/src/Activity/Messages/<ClassName>.php

Naming convention: Type keys follow <plugin_key>.<action> (e.g. bookclub.suggestion_created).

namespace Plugin\YourPlugin\Activity\Messages;

use App\Activity\MessageAbstract;

class ItemCreated extends MessageAbstract
{
    public const string TYPE = 'yourplugin.item_created';

    public function getType(): string
    {
        return self::TYPE;
    }

    public function validate(): MessageAbstract
    {
        $this->ensureHasKey('item_id');
        $this->ensureIsNumeric('item_id');

        return $this;
    }

    protected function renderText(): string
    {
        return sprintf('Created item #%d', $this->meta['item_id']);
    }

    protected function renderHtml(): string
    {
        return sprintf('Created item <strong>#%d</strong>', $this->meta['item_id']);
    }
}

Then call ActivityService::log() from your controller after the state-changing action:

$this->activityService->log(ItemCreated::TYPE, $user, ['item_id' => $item->getId()]);

Warning

When logging destructive actions (delete, reject), read the entity name/title before calling the service method, since the entity may be removed during the operation.


Enriching activity metadata

Implement ActivityMetaEnricherInterface to inject context into all activity types — for example, adding the current group or domain to every logged action.

namespace Plugin\YourPlugin\Activity;

use App\Activity\ActivityMetaEnricherInterface;
use App\Entity\User;

readonly class GroupContextEnricher implements ActivityMetaEnricherInterface
{
    public function enrich(string $type, User $user, array $meta): array
    {
        $groupId = $this->resolveCurrentGroupId();
        if ($groupId === null) {
            return [];
        }

        return ['_yourplugin_group_id_' => $groupId];
    }
}

Key rules:

  • Return only the keys to add. The original caller's keys always win.
  • Use a _<plugin_key>_ prefix to avoid collisions with other plugins.
  • Must not throw — enrichment is best-effort. Failures are logged as warnings.

Email Types

Adding a custom email type

Implement EmailInterface (or ScheduledEmailInterface for cron-driven emails) to add a new email type that automatically appears in the admin template preview and debugging pages.

File: src/Emails/EmailInterface.php / src/Emails/ScheduledEmailInterface.php

namespace Plugin\YourPlugin\Email;

use App\Emails\EmailInterface;
use App\Emails\EmailQueueInterface;
use App\Enum\EmailType;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;

readonly class MyPluginEmail implements EmailInterface
{
    public function __construct(
        private EmailQueueInterface $queue,
        private ConfigService $config,
    ) {}

    public function getIdentifier(): string
    {
        return 'myplugin_custom_email'; // must match EmailType::* value if using core enum
    }

    public function getDisplayMockData(): array
    {
        return [
            'subject' => 'My Plugin Notification',
            'context' => ['username' => 'John Doe', 'host' => 'https://localhost'],
        ];
    }

    public function guardCheck(array $context): bool
    {
        return true; // or implement recipient eligibility checks
    }

    public function send(array $context): void
    {
        $user = $context['user'];

        $email = new TemplatedEmail();
        $email->from($this->config->getMailerAddress());
        $email->to((string) $user->getEmail());
        $email->locale($user->getLocale());
        $email->context([/* template variables */]);

        $this->queue->enqueue($email, EmailType::MyPluginType);
    }
}

Call it by injecting the class directly wherever you need to send it:

$this->myPluginEmail->send(['user' => $user, ...]);

For scheduled emails (cron-driven), implement ScheduledEmailInterface additionally: - getDueContexts(DateTimeImmutable $now): DueContext[] — return what is due now; return [] to skip - markContextSent(DueContext $context): void — persist the "sent" state after processing - getPlannedItems(DateTimeImmutable $from, DateTimeImmutable $to): ScheduledMailItem[] — shown on /admin/email/planned

SendScheduledEmailsService picks up all ScheduledEmailInterface implementations automatically via #[AutowireIterator].

Note

Plugin email identifiers must not collide with core EmailType enum values. If your email type does not have a corresponding EmailType entry, you will need to add one or use a string identifier and implement the template system separately.


Follower Event Notification Filter

Implement FollowerEventNotificationFilterInterface to drop follower-RSVP email recipients per (recipient, attendee, event) triple. Returning false silently drops that recipient/attendee pair from the aggregated email and the in-app notification - no email is sent and no fallback runs. The chain is AND-combined: any filter returning false vetoes.

namespace Plugin\YourPlugin\Filter;

use App\Entity\Event;
use App\Entity\User;
use App\Filter\Event\FollowerEventNotificationFilterInterface;

readonly class MyFollowerFilter implements FollowerEventNotificationFilterInterface
{
    public function isFollowerAllowed(User $recipient, User $attendee, Event $event): bool
    {
        // Return true to allow, false to drop the pair.
        return true;
    }
}

The multisite plugin already supplies a same-group implementation (FollowerSameGroupFilter); a filter you add layers on top via AND-intersection with that one.


Data Hotfix

Implement DataHotfixInterface to ship a one-off data repair that runs once per database lifetime. The runner (DataHotfixRunner) discovers all implementations on every cron tick, checks the per-identifier AppState lock, runs the hotfix if not yet locked, and writes the lock on success. Throwing leaves the lock unwritten so the next tick retries.

namespace Plugin\YourPlugin\DataHotfix;

use App\DataHotfix\DataHotfixInterface;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;

readonly class FixOrphanedThings implements DataHotfixInterface
{
    public function __construct(
        private UserRepository $userRepository,
        private EntityManagerInterface $em,
    ) {}

    public function getIdentifier(): string
    {
        // Date-prefixed snake_case. Plugin authors should namespace with the plugin slug
        // when there is any risk of collision with core or other plugins.
        return 'yourplugin_2026_05_15_fix_orphaned_things';
    }

    public function execute(): void
    {
        $i = 0;
        foreach ($this->userRepository->iterateAll() as $user) {
            // ... mutate via entity API ...
            if (++$i % 200 === 0) {
                $this->em->flush();
                $this->em->clear();
            }
        }
        $this->em->flush();
    }
}

Identifiers must be stable - they are AppState row keys. Once a hotfix has shipped, never rename its identifier; treat it like a Doctrine migration version. To force a rerun on a single environment, delete the app_state row with key data_hotfix.{identifier}.


Security event detection

Implement SecurityProviderInterface to participate in the security pipeline that runs on every kernel-level 404, access-denied, and rate-limit event. The core dispatches events to every registered provider in priority order; each provider returns a ProviderReport with a threat level (0 - 100) and a recommendation.

When to implement

  • You want to detect a pattern across multiple events from the same session or IP and recommend a temporary block.
  • You want to feed a separate counter or signal into the existing live-detection pipeline.

The simplest path is to extend AbstractSecurityProvider, which handles Redis-backed state load/save, the read-only short-circuit path, and the report-building plumbing.

final class MyPluginProvider extends AbstractSecurityProvider
{
    public function getKey(): string
    {
        return 'my_plugin';
    }

    public function getPriority(): int
    {
        return 0;
    }

    protected function handlesType(SecurityEventType $type): bool
    {
        return $type === SecurityEventType::AccessDenied;
    }

    protected function processEvent(
        SecurityEventType $type,
        Request $request,
        array $context,
        string $ip,
        array $state,
    ): array {
        $hits = (int) ($state['hits'] ?? 0) + 1;
        $threatLevel = min(100, $hits * 10);

        return [
            'state' => ['hits' => $hits],
            'threatLevel' => $threatLevel,
            'summary' => sprintf('%d events observed', $hits),
            'details' => ['hits' => $hits],
        ];
    }

    protected function scanLogs(DateTimeImmutable $from, DateTimeImmutable $to): array
    {
        return ['threatLevel' => 0, 'summary' => '', 'details' => []];
    }
}

A threatLevel of 100 is interpreted as "block this session" and the core writes an Incident row plus blocks the session and IP in Redis with a 4h TTL. Lower threat levels are recorded but not acted on.