Skip to content

Patterns

Recurring code patterns in the MeetAgain codebase. Each section shows the canonical form with inline annotations.


Service

Services are readonly classes with constructor injection. They contain all business logic and have a single, focused responsibility.

<?php declare(strict_types=1);

namespace App\Service;

use App\Entity\Image;
use App\Repository\ImageRepository;
use Doctrine\ORM\EntityManagerInterface;

readonly class CleanupService          // ← always readonly
{
    public function __construct(
        private ImageRepository $imageRepo,        // ← inject repositories
        private EntityManagerInterface $em,        // ← inject EM when writing
    ) {}

    public function removeOrphanedImages(): int
    {
        $orphaned = $this->imageRepo->findOrphaned();   // ← delegate queries to repo
        foreach ($orphaned as $image) {
            $this->em->remove($image);
        }
        $this->em->flush();                             // ← one flush per operation

        return count($orphaned);
    }
}

Rules: - All services MUST be readonly (exception: services that hold a per-request memo field) - Constructor injection only — no setter injection - No static methods - Single Responsibility Principle — one focused purpose per service


Repository

Repositories extend ServiceEntityRepository and expose intent-revealing methods. Raw findBy() calls belong in the repository, not in controllers or services.

<?php declare(strict_types=1);

namespace App\Repository;

use App\Entity\Event;
use DateTimeInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Event>
 */
class EventRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Event::class);
    }

    /**
     * Intent-revealing name → caller knows what they'll get.
     */
    public function findUpcomingEventsWithinRange(
        ?DateTimeInterface $start = null,
        ?DateTimeInterface $end = null,
    ): array {
        $qb = $this->createQueryBuilder('e')
            ->leftJoin('e.location', 'l')
            ->addSelect('l')                          // ← eager-load to avoid N+1
            ->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();
    }

    /**
     * Return early on empty input — avoids a "WHERE id IN ()" query.
     *
     * @param int[] $ids
     * @return Event[]
     */
    public function findByIds(array $ids): array
    {
        if ($ids === []) {
            return [];
        }

        return $this->createQueryBuilder('e')
            ->where('e.id IN (:ids)')
            ->setParameter('ids', $ids)
            ->getQuery()
            ->getResult();
    }
}

Rules: - Use createQueryBuilder(), never raw SQL - Eager-load related entities with leftJoin + addSelect when they are always needed - Return early for empty arrays to avoid malformed IN () queries - Name methods after what the caller wants, not how the query works


Entity

Entities are plain Doctrine-mapped objects. They hold data and relationships — no logic.

<?php declare(strict_types=1);

namespace App\Entity;

use App\Repository\EventRepository;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: EventRepository::class)]
#[ORM\Table(name: 'event')]
#[ORM\Index(columns: ['start', 'canceled'], name: 'event_start_idx')]
class Event
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $title;

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

    #[ORM\Column(type: 'datetime_immutable')]
    private DateTimeImmutable $start;

    /** @var Collection<int, User> */             // ← docblock for PHPStan
    #[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'rsvpEvents')]
    private Collection $rsvps;

    public function __construct()
    {
        $this->rsvps = new ArrayCollection();     // ← init collections in constructor
    }

    // getters and setters below…
}

Rules: - Use Doctrine attributes (not legacy annotations) - Use backed enums for status/type fields with enumType - Use DateTimeImmutable for timestamps - Always add @var Collection<int, EntityClass> docblock on collections - Initialize collections in the constructor - No business logic — no calculate*, no process* methods


Migrations

After changing an entity, generate a migration:

just app doctrine:migrations:diff

When plugins are active (multiple migration namespaces registered), Doctrine prompts interactively. Select AppMigrations for core changes:

Which migrations configuration would you like to use?
 [0] AppMigrations
 [1] PluginDishesMigrations
 [2] PluginFilmclubMigrations
> 0

The migration file is placed in migrations/VersionXXX.php.

Always review the generated SQL before committing — Doctrine diffs occasionally include spurious changes (enum byte-length, collation mismatches).

Run pending migrations:

just appMigrate

Note

For plugin entity changes, generate a migration into the plugin's own namespace. See Plugin Development → Architecture.


Form type

Form types extend AbstractType and define field configuration and constraints.

<?php declare(strict_types=1);

namespace App\Form;

use App\Entity\Event;
use App\Enum\EventType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class EventType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\Length(['min' => 3, 'max' => 255]),
                ],
            ])
            ->add('type', EnumType::class, [
                'class' => EventType::class,
            ])
            ->add('start', DateTimeType::class, [
                'widget' => 'single_text',
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\GreaterThan('now'),
                ],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Event::class,
        ]);
    }
}

Command

Commands use #[AsCommand] and follow the same thin-delegation pattern as controllers: receive input, call a service, report progress.

<?php declare(strict_types=1);

namespace App\Command;

use App\Service\EventService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'app:event:extend',
    description: 'Generate future instances of recurring events',
)]
class EventExtentCommand extends Command
{
    public function __construct(
        private readonly EventService $eventService,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addOption('days', null, InputOption::VALUE_OPTIONAL, 'Days ahead', 90);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $days = (int) $input->getOption('days');
        $output->writeln(sprintf('<info>Extending events %d days ahead...</info>', $days));

        $count = $this->eventService->extendRecurring($days);

        $output->writeln(sprintf('<info>Created %d new instances.</info>', $count));

        return Command::SUCCESS;
    }
}

For scheduled (cron-like) commands, implement CronTaskInterface so the Symfony Scheduler picks them up automatically.


Event Subscriber

Use #[AsEventListener] to react to Symfony framework events without coupling the listener to the code that fires the event.

<?php declare(strict_types=1);

namespace App\EventSubscriber;

use App\Service\LocaleService;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;

#[AsEventListener(event: LoginSuccessEvent::class)]
readonly class LoginSubscriber
{
    public function __construct(
        private LocaleService $localeService,
    ) {}

    public function __invoke(LoginSuccessEvent $event): void
    {
        $response = $event->getResponse();
        if ($response === null) {
            return; // stateless Bearer token request — skip
        }

        $this->localeService->setLocaleCookie($event->getAuthenticatedToken()->getUser(), $response);
    }
}

When to use a subscriber vs a plain service call: - Use a subscriber when the trigger and the reaction should be decoupled — e.g. login triggers a cookie set, but the login code should not know about cookies. - Use a plain service call when the action is always required and the coupling is intentional — e.g. EventService::create() always calls ActivityService::log().