Skip to content

Architecture

The structural rules of the MeetAgain codebase — layers, dependencies, and the plugin system.


Layer diagram

┌─────────────────────────────────────────────────────────────┐
│                    Controllers & Commands                    │
│          (HTTP/CLI entry points, thin delegation)            │
└───────────────────────┬─────────────────────────────────────┘
                        │ depends on
┌─────────────────────────────────────────────────────────────┐
│                         Services                             │
│              (Business logic, readonly classes)              │
└───────────────────────┬─────────────────────────────────────┘
                        │ depends on
┌─────────────────────────────────────────────────────────────┐
│                       Repositories                           │
│            (Data access, query builder methods)              │
└───────────────────────┬─────────────────────────────────────┘
                        │ depends on
┌─────────────────────────────────────────────────────────────┐
│                         Entities                             │
│            (Pure data objects, Doctrine attributes)          │
└─────────────────────────────────────────────────────────────┘

Dependencies only flow downward. Repositories never call services; services never call controllers. These rules are enforced automatically on every just test run.


Layer responsibility table

Layer Responsibility May use May NOT use
Controller Thin HTTP/CLI entry point; validates input; renders response Service, Entity, Form, Repository (sparingly) Other controllers
Service Business logic, orchestration Repository, Entity, other Services Controller, Form
Repository Database queries Entity Service, Controller
Entity Doctrine-mapped data object — (nothing) Everything else

Supporting layers:

Layer Responsibility
Form Form type classes for building and validating forms
Command CLI commands; same rules as controllers
EventSubscriber React to Symfony framework events (login, response, etc.)
Twig extension Presentation helpers for templates
DataFixtures Test and dev data; allowed extra flexibility

Layer dependency rules

Controllers delegate, never decide

Controllers are thin. They receive a request, call a service, and return a response. No business logic lives here.

// src/Controller/ManageController.php
class ManageController extends AbstractController
{
    public function __construct(
        private readonly EventRepository $repo,
    ) {}

    #[Route('/manage', name: 'app_manage')]
    public function index(): Response
    {
        return $this->render('manage/index.html.twig', [
            'events' => $this->repo->findUpcomingEventsWithinRange(),
        ]);
    }
}

Services own the logic

Services contain all business logic. They are readonly classes with constructor injection.

// src/Service/CleanupService.php
readonly class CleanupService
{
    public function __construct(
        private ImageRepository $imageRepo,
        private EntityManagerInterface $em,
    ) {}

    public function removeOrphanedImages(): int
    {
        $orphaned = $this->imageRepo->findOrphaned();
        foreach ($orphaned as $image) {
            $this->em->remove($image);
        }
        $this->em->flush();

        return count($orphaned);
    }
}

Repositories express intent

Repository method names describe what you want, not how the query works.

// src/Repository/EventRepository.php
public function findUpcomingEventsWithinRange(
    ?DateTimeInterface $start = null,
    ?DateTimeInterface $end = null,
): array {
    $qb = $this->createQueryBuilder('e')
        ->where('e.start >= :now')
        ->andWhere('e.canceled = false')
        ->setParameter('now', $start ?? new DateTimeImmutable())
        ->orderBy('e.start', 'ASC');

    if ($end !== null) {
        $qb->andWhere('e.start <= :end')
           ->setParameter('end', $end);
    }

    return $qb->getQuery()->getResult();
}

Entities are plain data objects

Entities hold data and Doctrine mappings only. No business logic.

// src/Entity/Event.php
#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(enumType: EventType::class)]
    private EventType $type;

    /** @var Collection<int, User> */
    #[ORM\ManyToMany(targetEntity: User::class)]
    private Collection $rsvps;

    public function __construct()
    {
        $this->rsvps = new ArrayCollection();
    }
}

The plugin system

The core application is designed to function without any plugins. Plugins extend behaviour by implementing interfaces defined in the core — the core never imports plugin namespaces.

How it works

Core defines filter interfaces with auto-tagging:

// src/Filter/Event/EventFilterInterface.php
#[AutoconfigureTag]
interface EventFilterInterface
{
    public function getPriority(): int;
    public function getEventIdFilter(): ?array;   // null = no filter, [] = block all
    public function isEventAccessible(int $eventId): ?bool;
}

Core composes all registered implementations via #[AutowireIterator]:

// src/Filter/Event/EventFilterService.php
readonly class EventFilterService
{
    public function __construct(
        #[AutowireIterator(EventFilterInterface::class)]
        private iterable $filters,
    ) {}
}

A plugin that wants to filter events simply implements EventFilterInterface — it auto-registers with no changes to core code.

Plugin interface contract

Every plugin must implement src/Plugin.php:

interface Plugin
{
    public function getPluginKey(): string;
    public function getMenuLinks(): array;
    public function getEventTile(int $eventId): ?string;
    public function getEventListItemTags(int $eventId): array;
    public function warmCache(WarmCacheType $type, array $ids): void;
    public function getFooterAbout(): ?string;
    public function getMemberPageTop(): ?string;
    public function getAdminSystemLinks(): ?AdminSection;
    public function loadPostExtendFixtures(OutputInterface $output): void;
    public function preFixtures(OutputInterface $output): void;
    public function postFixtures(OutputInterface $output): void;
}

Core calls plugins by iterating the registered list — it never references a specific plugin class directly.

The golden rule

Core must never import a plugin namespace. The filter interface + #[AutowireIterator] pattern is the only correct way for core to receive plugin contributions.


Symfony events and EntityAction

EventSubscribers for cross-cutting concerns

Use #[AsEventListener] when you need to react to Symfony lifecycle events (e.g. login success, kernel response) without coupling the listener to the trigger:

#[AsEventListener(event: LoginSuccessEvent::class)]
readonly class LoginSubscriber
{
    public function __invoke(LoginSuccessEvent $event): void
    {
        $response = $event->getResponse();
        if ($response === null) {
            return; // stateless API request — no cookie to set
        }
        // set locale cookie, etc.
    }
}

EntityActionDispatcher for entity lifecycle

When a core entity is created, updated, or deleted, EntityActionDispatcher notifies all registered plugins. This avoids Doctrine lifecycle callbacks leaking plugin logic into the entity layer.

Core controllers call the dispatcher after flush — the entity has an ID and the transaction is complete:

// In a controller or service, after $em->flush():
$this->entityActionDispatcher->dispatch(EntityAction::Created, $event);

Plugins implement EntityActionInterface to receive these notifications.


Directory tour

Directory What lives there
src/Controller/ HTTP controllers (frontend + admin)
src/Controller/Admin/ Admin-only controllers, grouped by submenu
src/Service/ Business logic services (all readonly)
src/Repository/ Doctrine repositories
src/Entity/ Doctrine-mapped entities and enums
src/Form/ Symfony form type classes
src/Command/ CLI commands
src/Security/ UserChecker, authenticators
src/EventSubscriber/ Symfony event listeners
src/Filter/ Filter interfaces and composite services
src/DataFixtures/ Dev and test data fixtures
src/Twig/ Twig extensions
templates/ Twig templates (mirrors controller structure)
translations/ YAML translation files (en, de, cn)
plugins/ Optional plugin modules
tests/Unit/ PHPUnit unit tests
tests/Functional/ PHPUnit functional (HTTP) tests