Building a Running Events Site with PHP and React, Part 2: Modeling Events with Doctrine ORM
Add MySQL to the stack, model Slovenian races as Doctrine entities with PHP 8.4 asymmetric visibility, generate migrations from them, and serve filtered, paginated events without N+1 queries.
Building a Running Events Site with PHP and React, Part 2: Modeling Events with Doctrine ORM
In Part 1 we set up a Mezzio API in Docker that returns three hard-coded races. Today those races move into MySQL. We'll describe them as PHP classes, let Doctrine ORM generate the database schema, and add filters by region, distance and date, plus pagination.
As before, every file is listed in full with its path above it.
The Series
| Part | Topic |
|---|---|
| 1 | A Dockerized Laminas API |
| 2 | Modeling events with Doctrine ORM (this article) |
| 3 | Scraping running events from the web |
| 4 | Documenting the API with OpenAPI |
| 5 | A React frontend with type-safe API calls |
Entity First or Database First?
There are two ways to connect PHP code to a database. Database first means you write SQL CREATE TABLE statements and then write PHP classes that match them. Entity first means you write the PHP classes, describe how they map to tables, and let a tool generate the SQL.
We'll go entity first with Doctrine ORM, the most widely used PHP object-relational mapper. The PHP classes, called entities, become the single source of truth. When we change an entity, Doctrine compares it with the current database and writes a migration: a versioned PHP file containing the SQL needed to bring the database up to date. Migrations are committed with the code, so every developer and every server applies the same changes in the same order.
Step 1: Add MySQL
Replace docker-compose.yml with this version. It adds a MySQL 8.4 service with a health check, passes the connection details to PHP as environment variables, and makes PHP wait until MySQL is ready.
docker-compose.yml
services:
php:
build: ./docker/php
volumes:
- ./api:/var/www/api
environment:
DB_HOST: mysql
DB_NAME: running_events
DB_USER: app
DB_PASSWORD: app
depends_on:
mysql:
condition: service_healthy
nginx:
image: nginx:stable-alpine
ports:
- "8080:80"
volumes:
- ./api:/var/www/api:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: running_events
MYSQL_USER: app
MYSQL_PASSWORD: app
MYSQL_ROOT_PASSWORD: root
ports:
- "3307:3306"
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uapp", "-papp"]
interval: 5s
timeout: 3s
retries: 20
volumes:
mysql-data:
MySQL is exposed on port 3307 rather than the default 3306, so it won't clash with a MySQL server already installed on your machine. You can connect a database tool to localhost:3307 with user app and password app. The named volume mysql-data keeps the data when containers are recreated.
docker compose up -d
The first start takes a little longer while MySQL initializes its data directory.
Step 2: Install Doctrine
docker compose run --rm php composer require --no-interaction doctrine/orm doctrine/migrations symfony/cache
That installs the ORM (version 3), the migrations tool, and a cache library Doctrine uses to store parsed entity metadata. As in Part 1, Linux users should add --user "$(id -u):$(id -g)" after run.
Step 3: Connect Doctrine to the Application
The connection settings go in a config file. Mezzio loads every file in config/autoload/ ending in .global.php automatically.
api/config/autoload/doctrine.global.php
<?php
declare(strict_types=1);
return [
'doctrine' => [
'connection' => [
'driver' => 'pdo_mysql',
'host' => getenv('DB_HOST') ?: 'mysql',
'dbname' => getenv('DB_NAME') ?: 'running_events',
'user' => getenv('DB_USER') ?: 'app',
'password' => getenv('DB_PASSWORD') ?: 'app',
'charset' => 'utf8mb4',
'serverVersion' => '8.4',
],
'entity_paths' => [
__DIR__ . '/../../src/Events/src/Entity',
],
],
];
Doctrine's main object is the entity manager: it loads entities from the database and saves changes back. This factory builds it for the service container.
api/src/App/Doctrine/EntityManagerFactory.php
<?php
declare(strict_types=1);
namespace App\Doctrine;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\UnderscoreNamingStrategy;
use Doctrine\ORM\ORMSetup;
use Psr\Container\ContainerInterface;
final class EntityManagerFactory
{
public function __invoke(ContainerInterface $container): EntityManagerInterface
{
$config = $container->get('config');
$settings = $config['doctrine'];
$ormConfig = ORMSetup::createAttributeMetadataConfiguration(
paths: $settings['entity_paths'],
isDevMode: (bool) ($config['debug'] ?? false),
);
$ormConfig->enableNativeLazyObjects(true);
$ormConfig->setNamingStrategy(new UnderscoreNamingStrategy(CASE_LOWER, true));
$connection = DriverManager::getConnection($settings['connection'], $ormConfig);
return new EntityManager($connection, $ormConfig);
}
}
Two settings are worth explaining:
enableNativeLazyObjects(true)uses PHP 8.4's built-in lazy objects. Doctrine sometimes needs a placeholder for an entity it hasn't loaded yet. Older versions generated proxy classes into a cache folder, a frequent source of deployment problems. On PHP 8.4, the language handles it.UnderscoreNamingStrategyturns property names likeexternalIdinto column names likeexternal_id, the usual MySQL convention. Without it, columns are named exactly like the properties.
Now register the factory. This is a good moment to clean up the App module: delete the skeleton's sample handlers if you still have them (PingHandler.php, HomePageHandler.php and HomePageHandlerFactory.php in api/src/App/Handler/) together with their tests (the whole api/test/AppTest/Handler/ folder), and replace the config provider with this complete version:
api/src/App/ConfigProvider.php
<?php
declare(strict_types=1);
namespace App;
use Doctrine\ORM\EntityManagerInterface;
final class ConfigProvider
{
public function __invoke(): array
{
return [
'dependencies' => $this->getDependencies(),
];
}
public function getDependencies(): array
{
return [
'invokables' => [
Handler\HealthHandler::class => Handler\HealthHandler::class,
],
'factories' => [
EntityManagerInterface::class => Doctrine\EntityManagerFactory::class,
],
];
}
}
Step 4: The Entities
We need three entities:
Source: where a race came from, such as a race calendar website. Part 3's scraper records each source and when it was last scraped.Event: a race, with its name, date, place and region.Distance: one distance offered at a race, in meters. A race usually has several, like a marathon, a half marathon and a 10 km run.
The entities use a PHP 8.4 feature called asymmetric visibility. A property declared public private(set) can be read from anywhere ($event->name) but changed only inside the class. Older code needed a getter method for every property to achieve the same thing; this way the entities stay short without anyone outside being able to change them by accident.
The #[ORM\...] lines are PHP attributes that tell Doctrine how each class and property maps to the database.
api/src/Events/src/Entity/Source.php
<?php
declare(strict_types=1);
namespace Events\Entity;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'sources')]
class Source
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public private(set) ?int $id = null;
#[ORM\Column(nullable: true)]
public private(set) ?DateTimeImmutable $lastScrapedAt = null;
public function __construct(
#[ORM\Column(length: 100, unique: true)]
public private(set) string $name,
#[ORM\Column(length: 255)]
public private(set) string $baseUrl,
) {
}
public function markScraped(DateTimeImmutable $at): void
{
$this->lastScrapedAt = $at;
}
}
api/src/Events/src/Entity/Event.php
<?php
declare(strict_types=1);
namespace Events\Entity;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'events')]
#[ORM\UniqueConstraint(columns: ['source_id', 'external_id'])]
#[ORM\Index(columns: ['date'])]
class Event
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public private(set) ?int $id = null;
#[ORM\Column(length: 60, nullable: true)]
public private(set) ?string $region = null;
#[ORM\Column(length: 500, nullable: true)]
public private(set) ?string $url = null;
/** @var Collection<int, Distance> */
#[ORM\OneToMany(targetEntity: Distance::class, mappedBy: 'event', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['meters' => 'DESC'])]
public private(set) Collection $distances;
public function __construct(
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
public private(set) Source $source,
#[ORM\Column(length: 191)]
public private(set) string $externalId,
#[ORM\Column(length: 255)]
public private(set) string $name,
#[ORM\Column(type: 'date_immutable')]
public private(set) DateTimeImmutable $date,
#[ORM\Column(length: 120)]
public private(set) string $place,
) {
$this->distances = new ArrayCollection();
}
public function update(
string $name,
DateTimeImmutable $date,
string $place,
?string $region,
?string $url,
): void {
$this->name = $name;
$this->date = $date;
$this->place = $place;
$this->region = $region;
$this->url = $url;
}
/**
* @param list<int> $meters
*/
public function replaceDistances(array $meters): void
{
$meters = array_values(array_unique($meters));
foreach ($this->distances as $distance) {
if (! in_array($distance->meters, $meters, true)) {
$this->distances->removeElement($distance);
}
}
$existing = $this->distances->map(static fn (Distance $d): int => $d->meters)->toArray();
foreach (array_diff($meters, $existing) as $value) {
$this->distances->add(new Distance($this, $value));
}
}
}
A few details in Event matter later:
externalIdis the race's identifier at its source, for example the URL slug on a race calendar. Together with the source, it must be unique (theUniqueConstraint). That's how Part 3's scraper recognizes a race it has already saved and updates it instead of creating a duplicate.- The index on
datekeeps "upcoming races" queries fast as the table grows. replaceDistances()looks more complicated than clearing the list and adding the new values. That simpler version fails. When saving, Doctrine runs inserts before deletes, so re-adding a distance that already exists would insert a second(event, 21097)row while the old one is still there, and the unique constraint below would reject it. Removing only the distances that disappeared and adding only the new ones avoids the problem.
api/src/Events/src/Entity/Distance.php
<?php
declare(strict_types=1);
namespace Events\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'event_distances')]
#[ORM\UniqueConstraint(columns: ['event_id', 'meters'])]
class Distance
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public private(set) ?int $id = null;
public function __construct(
#[ORM\ManyToOne(inversedBy: 'distances')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public private(set) Event $event,
#[ORM\Column]
public private(set) int $meters,
) {
}
}
Why a separate table instead of a list of numbers in one column? Because we want to filter by distance ("show me half marathons"), and a separate table lets MySQL do that with an ordinary indexed query.
Step 5: Migrations
The migrations tool needs two small files in the api/ folder: one with its settings, and one that hands it our entity manager.
api/migrations.php
<?php
declare(strict_types=1);
return [
'migrations_paths' => [
'Migrations' => __DIR__ . '/migrations',
],
'all_or_nothing' => true,
];
api/cli-config.php
<?php
declare(strict_types=1);
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
use Doctrine\Migrations\Configuration\Migration\PhpFile;
use Doctrine\Migrations\DependencyFactory;
use Doctrine\ORM\EntityManagerInterface;
require __DIR__ . '/vendor/autoload.php';
$container = require __DIR__ . '/config/container.php';
return DependencyFactory::fromEntityManager(
new PhpFile(__DIR__ . '/migrations.php'),
new ExistingEntityManager($container->get(EntityManagerInterface::class)),
);
Create the folder for migrations, then ask Doctrine to compare the entities with the (empty) database and generate a migration:
mkdir api/migrations
docker compose run --rm php vendor/bin/doctrine-migrations diff
Open the new file in api/migrations/. Its up() method contains the SQL Doctrine worked out from the entities, including:
CREATE TABLE events (id INT AUTO_INCREMENT NOT NULL, region VARCHAR(60) DEFAULT NULL,
url VARCHAR(500) DEFAULT NULL, external_id VARCHAR(191) NOT NULL, name VARCHAR(255) NOT NULL,
date DATE NOT NULL, place VARCHAR(120) NOT NULL, source_id INT NOT NULL,
INDEX IDX_5387574AAA9E377A (date),
UNIQUE INDEX UNIQ_5387574A953C1C619F75D7B0 (source_id, external_id), ...)
DEFAULT CHARACTER SET utf8mb4
Always read a generated migration before running it. Doctrine is reliable, but it can't know your intent: a renamed property, for example, shows up as "drop the old column, add a new one", which would lose that column's data. In that case you edit the migration by hand.
Apply it:
docker compose run --rm php vendor/bin/doctrine-migrations migrate --no-interaction
[OK] Successfully migrated to version: Migrations\Version20261014...
Doctrine records applied migrations in a doctrine_migration_versions table, so migrate only ever runs the new ones.
Step 6: Seed Some Races
A small script fills the database with sample races, so we have something to query before the scraper exists. As in Part 1, the race names are real but the dates and distances are illustrative; the scraper in Part 3 brings in real data. For each race, it looks for an existing row with the same source and external ID, and either updates it or creates a new one. That pattern, called an upsert, is exactly what the scraper will use in Part 3, and it means the script is safe to run as often as you like.
api/bin/seed.php
<?php
declare(strict_types=1);
use Doctrine\ORM\EntityManagerInterface;
use Events\Entity\Event;
use Events\Entity\Source;
require __DIR__ . '/../vendor/autoload.php';
$container = require __DIR__ . '/../config/container.php';
$em = $container->get(EntityManagerInterface::class);
$source = $em->getRepository(Source::class)->findOneBy(['name' => 'manual'])
?? new Source('manual', 'https://example.com');
$em->persist($source);
// Sample data for development. Check the organizers' websites for real details.
$races = [
['ljubljana-2026', 'Ljubljanski maraton', '2026-10-25', 'Ljubljana', 'Osrednjeslovenska', [42195, 21097, 10000]],
['vinotek-2026', 'Vinotek', '2026-10-31', 'Brda', 'Goriška', [42195, 21097]],
['lahinja-2026', 'Lahinja Trail', '2026-11-07', 'Dragatuš', 'Jugovzhodna Slovenija', [21000, 12000]],
['celje-2026', 'Celjski mali maraton', '2026-11-08', 'Celje', 'Savinjska', [21097, 10000]],
['secovlje-2026', 'Martinov tek Sečovlje', '2026-11-14', 'Sečovlje', 'Obalno-kraška', [21097, 10000]],
['novo-mesto-2026', 'Novo mesto 1/2 maraton', '2026-11-28', 'Novo mesto', 'Jugovzhodna Slovenija', [21097, 10000]],
];
$repository = $em->getRepository(Event::class);
foreach ($races as [$externalId, $name, $date, $place, $region, $distances]) {
$event = $repository->findOneBy(['source' => $source, 'externalId' => $externalId]);
if ($event === null) {
$event = new Event($source, $externalId, $name, new DateTimeImmutable($date), $place);
$em->persist($event);
}
$event->update($name, new DateTimeImmutable($date), $place, $region, null);
$event->replaceDistances($distances);
}
$em->flush();
echo sprintf("Seeded %d events.\n", count($races));
docker compose run --rm php php bin/seed.php
Seeded 6 events.
Nothing touches the database until $em->flush(). Until then, Doctrine only tracks what changed. flush() writes all the changes in a single transaction, so either every race is saved or none are.
Step 7: The Repository
In Part 1, the repository interface returned plain arrays. Now it returns entities, and it gains filtering and a lookup by ID. First, a small class that reads the filters from the URL's query string and validates them:
api/src/Events/src/Repository/EventFilter.php
<?php
declare(strict_types=1);
namespace Events\Repository;
use DateTimeImmutable;
use InvalidArgumentException;
final readonly class EventFilter
{
public function __construct(
public DateTimeImmutable $from,
public ?DateTimeImmutable $to = null,
public ?string $region = null,
public ?int $minDistance = null,
public ?int $maxDistance = null,
public int $page = 1,
public int $perPage = 20,
) {
}
/**
* @param array<string, mixed> $query
*/
public static function fromQuery(array $query): self
{
return new self(
from: self::date($query, 'from') ?? new DateTimeImmutable('today'),
to: self::date($query, 'to'),
region: isset($query['region']) && $query['region'] !== '' ? (string) $query['region'] : null,
minDistance: self::int($query, 'minDistance', 1, 500_000),
maxDistance: self::int($query, 'maxDistance', 1, 500_000),
page: self::int($query, 'page', 1, 10_000) ?? 1,
perPage: self::int($query, 'perPage', 1, 100) ?? 20,
);
}
private static function date(array $query, string $key): ?DateTimeImmutable
{
if (! isset($query[$key]) || $query[$key] === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', (string) $query[$key]);
if ($date === false || $date->format('Y-m-d') !== $query[$key]) {
throw new InvalidArgumentException(sprintf('"%s" must be a date in YYYY-MM-DD format.', $key));
}
return $date;
}
private static function int(array $query, string $key, int $min, int $max): ?int
{
if (! isset($query[$key]) || $query[$key] === '') {
return null;
}
$value = filter_var($query[$key], FILTER_VALIDATE_INT, [
'options' => ['min_range' => $min, 'max_range' => $max],
]);
if ($value === false) {
throw new InvalidArgumentException(sprintf('"%s" must be a whole number between %d and %d.', $key, $min, $max));
}
return $value;
}
}
Without a from date, the filter defaults to today, so the API lists upcoming races. perPage is capped at 100, so nobody can ask for the whole table in one request.
Replace the interface:
api/src/Events/src/Repository/EventRepository.php
<?php
declare(strict_types=1);
namespace Events\Repository;
use Events\Entity\Event;
interface EventRepository
{
/**
* @return array{items: list<Event>, total: int}
*/
public function search(EventFilter $filter): array;
public function get(int $id): ?Event;
}
Delete api/src/Events/src/Repository/InMemoryEventRepository.php; the Doctrine version replaces it.
api/src/Events/src/Repository/DoctrineEventRepository.php
<?php
declare(strict_types=1);
namespace Events\Repository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Events\Entity\Distance;
use Events\Entity\Event;
final class DoctrineEventRepository implements EventRepository
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function search(EventFilter $filter): array
{
$qb = $this->baseQuery()
->where('e.date >= :from')
->setParameter('from', $filter->from, Types::DATE_IMMUTABLE)
->orderBy('e.date', 'ASC')
->addOrderBy('e.id', 'ASC');
if ($filter->to !== null) {
$qb->andWhere('e.date <= :to')->setParameter('to', $filter->to, Types::DATE_IMMUTABLE);
}
if ($filter->region !== null) {
$qb->andWhere('e.region = :region')->setParameter('region', $filter->region);
}
if ($filter->minDistance !== null || $filter->maxDistance !== null) {
$sub = $this->em->createQueryBuilder()
->select('1')
->from(Distance::class, 'fd')
->where('fd.event = e')
->andWhere('fd.meters BETWEEN :minDistance AND :maxDistance');
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('minDistance', $filter->minDistance ?? 0)
->setParameter('maxDistance', $filter->maxDistance ?? PHP_INT_MAX);
}
$qb->setFirstResult(($filter->page - 1) * $filter->perPage)
->setMaxResults($filter->perPage);
$paginator = new Paginator($qb, fetchJoinCollection: true);
return [
'items' => iterator_to_array($paginator, false),
'total' => count($paginator),
];
}
public function get(int $id): ?Event
{
return $this->baseQuery()
->where('e.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
private function baseQuery(): QueryBuilder
{
return $this->em->createQueryBuilder()
->select('e', 's', 'd')
->from(Event::class, 'e')
->join('e.source', 's')
->leftJoin('e.distances', 'd');
}
}
This class is short, but it avoids three classic Doctrine mistakes.
1. The N+1 query problem. If we selected only events (select('e')), Doctrine would load each event's distances the first time the code touched them: one query for the list, then one more query per event. Twenty events would mean 21 queries. baseQuery() instead selects events, sources and distances together (select('e', 's', 'd') with joins), which is called a fetch join. One page of results costs three queries no matter how many events it contains.
2. Half-loaded distance lists. The obvious way to filter by distance is WHERE d.meters BETWEEN ... on the joined distances. But then Doctrine fills each event's distance list with only the matching rows: the Ljubljana Marathon would appear to offer just a half marathon. Filtering in a separate EXISTS subquery picks the right events while still loading all of their distances.
3. Pagination with joins. LIMIT 20 on a query that joins distances limits rows, not events. An event with three distances uses three rows, so a page would contain fewer than 20 events. Doctrine's Paginator fixes this by first selecting the IDs of the events on the page, then loading those events with all their joined data.
The repository needs a factory:
api/src/Events/src/Repository/DoctrineEventRepositoryFactory.php
<?php
declare(strict_types=1);
namespace Events\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Container\ContainerInterface;
final class DoctrineEventRepositoryFactory
{
public function __invoke(ContainerInterface $container): DoctrineEventRepository
{
return new DoctrineEventRepository($container->get(EntityManagerInterface::class));
}
}
Step 8: The Handlers
We don't send entities straight to json_encode(). Entities describe how data is stored; the API response is a public contract that should change only on purpose. A small class decides exactly which fields the API exposes and in what format:
api/src/Events/src/Handler/EventView.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Entity\Distance;
use Events\Entity\Event;
final class EventView
{
/**
* @return array<string, mixed>
*/
public static function fromEntity(Event $event): array
{
return [
'id' => $event->id,
'name' => $event->name,
'date' => $event->date->format('Y-m-d'),
'place' => $event->place,
'region' => $event->region,
'distances' => $event->distances->map(static fn (Distance $d): int => $d->meters)->getValues(),
'url' => $event->url,
'source' => $event->source->name,
];
}
}
The list handler validates the filters, returning a 400 problem-details response for bad input, runs the search, and adds pagination information to the response:
api/src/Events/src/Handler/ListEventsHandler.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Repository\EventFilter;
use Events\Repository\EventRepository;
use InvalidArgumentException;
use Laminas\Diactoros\Response\JsonResponse;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class ListEventsHandler implements RequestHandlerInterface
{
public function __construct(
private readonly EventRepository $events,
private readonly ProblemDetailsResponseFactory $problems,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
try {
$filter = EventFilter::fromQuery($request->getQueryParams());
} catch (InvalidArgumentException $e) {
return $this->problems->createResponse($request, 400, $e->getMessage());
}
$result = $this->events->search($filter);
return new JsonResponse(
[
'data' => array_map(EventView::fromEntity(...), $result['items']),
'meta' => [
'total' => $result['total'],
'page' => $filter->page,
'perPage' => $filter->perPage,
],
],
200,
[],
JsonResponse::DEFAULT_JSON_FLAGS | JSON_UNESCAPED_UNICODE,
);
}
}
api/src/Events/src/Handler/ListEventsHandlerFactory.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Repository\EventRepository;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Container\ContainerInterface;
final class ListEventsHandlerFactory
{
public function __invoke(ContainerInterface $container): ListEventsHandler
{
return new ListEventsHandler(
$container->get(EventRepository::class),
$container->get(ProblemDetailsResponseFactory::class),
);
}
}
A second handler returns one event by ID, or a 404 problem-details response:
api/src/Events/src/Handler/GetEventHandler.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Repository\EventRepository;
use Laminas\Diactoros\Response\JsonResponse;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class GetEventHandler implements RequestHandlerInterface
{
public function __construct(
private readonly EventRepository $events,
private readonly ProblemDetailsResponseFactory $problems,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$id = (int) $request->getAttribute('id');
$event = $this->events->get($id);
if ($event === null) {
return $this->problems->createResponse($request, 404, sprintf('Event %d was not found.', $id));
}
return new JsonResponse(
['data' => EventView::fromEntity($event)],
200,
[],
JsonResponse::DEFAULT_JSON_FLAGS | JSON_UNESCAPED_UNICODE,
);
}
}
api/src/Events/src/Handler/GetEventHandlerFactory.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Repository\EventRepository;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Container\ContainerInterface;
final class GetEventHandlerFactory
{
public function __invoke(ContainerInterface $container): GetEventHandler
{
return new GetEventHandler(
$container->get(EventRepository::class),
$container->get(ProblemDetailsResponseFactory::class),
);
}
}
Now the swap promised in Part 1: the EventRepository alias points at the Doctrine repository instead of the in-memory one. Replace the module's config provider:
api/src/Events/src/ConfigProvider.php
<?php
declare(strict_types=1);
namespace Events;
final class ConfigProvider
{
public function __invoke(): array
{
return [
'dependencies' => $this->getDependencies(),
];
}
public function getDependencies(): array
{
return [
'aliases' => [
Repository\EventRepository::class => Repository\DoctrineEventRepository::class,
],
'factories' => [
Repository\DoctrineEventRepository::class => Repository\DoctrineEventRepositoryFactory::class,
Handler\ListEventsHandler::class => Handler\ListEventsHandlerFactory::class,
Handler\GetEventHandler::class => Handler\GetEventHandlerFactory::class,
],
];
}
}
And add the new route. The {id:\d+} part only matches digits, so /events/abc is a plain 404 rather than a request for event zero.
api/config/routes.php
<?php
declare(strict_types=1);
use App\Handler\HealthHandler;
use Events\Handler\GetEventHandler;
use Events\Handler\ListEventsHandler;
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;
return static function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void {
$app->get('/health', HealthHandler::class, 'health');
$app->get('/events', ListEventsHandler::class, 'events.list');
$app->get('/events/{id:\d+}', GetEventHandler::class, 'events.get');
};
Try It
# Upcoming races
curl "http://localhost:8080/events"
# Races in one region
curl "http://localhost:8080/events?region=Jugovzhodna%20Slovenija"
# Marathons (any distance of 40 km or more)
curl "http://localhost:8080/events?minDistance=40000"
# Races between two dates, two per page
curl "http://localhost:8080/events?from=2026-11-01&to=2026-11-30&perPage=2&page=1"
# One race
curl "http://localhost:8080/events/1"
The marathon filter returns the Ljubljana Marathon and Vinotek, and each one still lists all of its distances:
{
"data": [
{"id": 1, "name": "Ljubljanski maraton", "date": "2026-10-25", "place": "Ljubljana",
"region": "Osrednjeslovenska", "distances": [42195, 21097, 10000], "url": null, "source": "manual"},
{"id": 2, "name": "Vinotek", "date": "2026-10-31", "place": "Brda",
"region": "Goriška", "distances": [42195, 21097], "url": null, "source": "manual"}
],
"meta": {"total": 2, "page": 1, "perPage": 20}
}
Bad input gets a clear 400 instead of a crash or an empty list:
curl "http://localhost:8080/events?perPage=500"
{
"title": "Bad Request",
"type": "https://httpwg.org/specs/rfc9110.html#status.400",
"status": 400,
"detail": "\"perPage\" must be a whole number between 1 and 100."
}
Common Pitfalls
"Upcoming" depends on the time zone. The default from date is new DateTimeImmutable('today'), which uses PHP's time zone. We set it to Europe/Ljubljana in Part 1's php.ini. Without that, the container uses UTC, and between midnight and 2 a.m. Slovenian time (1 a.m. in winter) the API would still show yesterday's races.
Mangled č, š and ž. The connection uses utf8mb4, and MySQL 8.4 creates tables with utf8mb4 by default. If you point the API at an older database created as latin1 or the 3-byte utf8, Slovenian characters can turn into question marks. A side benefit of MySQL's default collation (utf8mb4_0900_ai_ci, where "ai" means accent-insensitive): comparisons ignore accents, so region=Goriska also matches races in "Goriška".
A migration that drops your data. Renaming a property makes diff generate "drop column, add column". Read every generated migration before running migrate.
Forgetting flush(). persist() only tells Doctrine to track an object. Nothing is written until flush().
Connection refused at startup. If PHP starts before MySQL is ready, the first requests fail. The health check and the condition: service_healthy in the Compose file prevent that; keep both if you change the file.
What You Have Now
- MySQL 8.4 in Docker, with a schema generated from Doctrine entities and versioned in migrations
GET /eventswith filters by date, region and distance, and pagination, using three queries per pageGET /events/{id}with a proper 404- a seed script that inserts or updates races, the same pattern the scraper will use
api/
├── bin/seed.php
├── cli-config.php
├── migrations.php
├── migrations/Version2026....php
├── config/
│ ├── autoload/doctrine.global.php
│ └── routes.php
└── src/
├── App/
│ ├── ConfigProvider.php
│ ├── Doctrine/EntityManagerFactory.php
│ └── Handler/HealthHandler.php
└── Events/src/
├── ConfigProvider.php
├── Entity/
│ ├── Distance.php
│ ├── Event.php
│ └── Source.php
├── Handler/
│ ├── EventView.php
│ ├── GetEventHandler.php
│ ├── GetEventHandlerFactory.php
│ ├── ListEventsHandler.php
│ └── ListEventsHandlerFactory.php
└── Repository/
├── DoctrineEventRepository.php
├── DoctrineEventRepositoryFactory.php
├── EventFilter.php
└── EventRepository.php
Next, in Part 3: we stop typing races in by hand. A scraper running in its own container will collect Slovenian races from a race calendar website, read the structured data embedded in each race page, clean up Slovenian dates and distance labels, and save everything through the same insert-or-update logic as today's seed script.