Building a Running Events Site with PHP and React, Part 1: A Dockerized Laminas API
Start a five-part build-along series: set up a Mezzio (Laminas) API with PHP 8.4, nginx and Docker Compose, and serve the first JSON endpoints for a Slovenian running events site.
Building a Running Events Site with PHP and React, Part 1: A Dockerized Laminas API
Slovenia has a race almost every weekend, from the Ljubljana Marathon to small village trail runs, but the information is spread across dozens of websites. In this five-part series we'll build a site that collects those races in one place: a PHP API, a MySQL database, a scraper that gathers events automatically, an OpenAPI contract, and a React frontend.
Each part is short and ends with something you can run. There's no repository to clone: every file you need is listed in full, with its path above it.
The Series
| Part | Topic |
|---|---|
| 1 | A Dockerized Laminas API (this article) |
| 2 | Modeling events with Doctrine ORM |
| 3 | Scraping running events from the web |
| 4 | Documenting the API with OpenAPI |
| 5 | A React frontend with type-safe API calls |
By the end, the whole application will run from a single docker compose up:
browser
|
React (Vite) ------> nginx ------> PHP 8.4 (Mezzio API) ------> MySQL
^
scraper worker ---------------/
Today we build the middle of that picture: nginx and PHP serving JSON.
What You Need
- Docker with Compose (Docker Desktop on Windows and macOS, or Docker Engine on Linux)
- curl, or any HTTP client, to test the endpoints
You don't need PHP or Composer installed locally. Both run inside a container, so every reader gets the same versions.
Why Laminas and Mezzio?
Laminas is the open-source continuation of Zend Framework, one of the longest-running PHP frameworks. It offers two ways to build applications: laminas-mvc, a full MVC framework aimed at server-rendered sites, and Mezzio, a lightweight framework built for APIs.
We'll use Mezzio. It is built on the PHP community's shared standards for HTTP: PSR-7 for request and response objects, and PSR-15 for request handlers and middleware. An endpoint is a small class that receives a request and returns a response, with nothing hidden in between. Laravel and Symfony are excellent choices too, but they make more decisions for you. For a series whose goal is understanding how the pieces fit together, Mezzio's explicitness is an advantage.
Step 1: The Docker Setup
Create the project folder and this structure:
running-events/
├── docker-compose.yml
├── api/ (empty for now; Mezzio goes here)
└── docker/
├── nginx/
│ └── default.conf
└── php/
├── Dockerfile
└── php.ini
The PHP image is the official PHP 8.4 FPM image with the extensions we'll need across the series: intl for Slovenian date and number formatting, pdo_mysql for the database in Part 2, opcache for performance, and zip for Composer. Composer itself is copied in from its official image.
docker/php/Dockerfile
FROM php:8.4-fpm-alpine
RUN apk add --no-cache icu-dev libzip-dev unzip \
&& docker-php-ext-install intl pdo_mysql opcache zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY php.ini /usr/local/etc/php/conf.d/app.ini
WORKDIR /var/www/api
docker/php/php.ini
date.timezone = Europe/Ljubljana
memory_limit = 256M
; Development: check files for changes on every request.
opcache.enable = 1
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
nginx serves requests and passes anything that isn't a static file to PHP-FPM. Mezzio's single entry point is public/index.php.
docker/nginx/default.conf
server {
listen 80;
server_name _;
root /var/www/api/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
docker-compose.yml
services:
php:
build: ./docker/php
volumes:
- ./api:/var/www/api
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
Both containers mount the same api/ folder, so code you edit on your machine is live immediately. nginx gets it read-only because it only serves files.
Step 2: Create the Mezzio Project
Build the PHP image, then use the Composer inside it to generate the project into the empty api/ folder:
docker compose build php
docker compose run --rm php composer create-project --no-interaction mezzio/mezzio-skeleton .
With --no-interaction, the skeleton picks the defaults, which are exactly what an API needs: the laminas-servicemanager dependency injection container, FastRoute for routing, and no template engine. It also enables development mode, which turns on detailed error output.
On Linux? Files created inside a container belong to root by default. Add
--user "$(id -u):$(id -g)"afterrunin everycomposercommand in this series, so the files belong to you. Docker Desktop on Windows and macOS handles this automatically.
Start the stack and call the sample endpoint the skeleton includes:
docker compose up -d
curl http://localhost:8080/api/ping
{"ack":1791320742}
PHP is running behind nginx. Now let's replace the sample with our own API.
Step 3: An Events Module
Mezzio groups related code into modules. The Mezzio command-line tooling creates one and registers it for you, including the autoloading rules and the configuration hookup that are easy to get wrong by hand:
docker compose run --rm php composer mezzio mezzio:module:create Events
This creates api/src/Events/src/ConfigProvider.php. The files below all go inside api/src/Events/src/.
A repository interface
The handler shouldn't care where events come from. In this part they're hard-coded; in Part 2 they'll come from MySQL. An interface lets us swap the implementation without touching the handler.
api/src/Events/src/Repository/EventRepository.php
<?php
declare(strict_types=1);
namespace Events\Repository;
interface EventRepository
{
/**
* @return list<array<string, mixed>>
*/
public function findUpcoming(): array;
}
api/src/Events/src/Repository/InMemoryEventRepository.php
<?php
declare(strict_types=1);
namespace Events\Repository;
final class InMemoryEventRepository implements EventRepository
{
public function findUpcoming(): array
{
return [
[
'id' => 1,
'name' => 'Ljubljanski maraton',
'date' => '2026-10-25',
'place' => 'Ljubljana',
'region' => 'Osrednjeslovenska',
'distances' => [42195, 21097, 10000],
],
[
'id' => 2,
'name' => 'Martinov tek Sečovlje',
'date' => '2026-11-14',
'place' => 'Sečovlje',
'region' => 'Obalno-kraška',
'distances' => [21097, 10000],
],
[
'id' => 3,
'name' => 'Novo mesto 1/2 maraton',
'date' => '2026-11-28',
'place' => 'Novo mesto',
'region' => 'Jugovzhodna Slovenija',
'distances' => [21097, 10000],
],
];
}
}
The race names are real, but the dates and distances are illustrative sample data. Check the organizers' websites for actual details. In Part 3, a scraper replaces this sample data with real race information.
Distances are stored in meters. Race websites write the same distance as "polmaraton", "21 km" or "21,1 km", and a single number makes filtering and sorting simple. That decision will pay off in Part 3, when the scraper has to clean up those labels.
The handler
A handler is the class that answers one route. This one takes the repository through its constructor and returns JSON.
api/src/Events/src/Handler/ListEventsHandler.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Repository\EventRepository;
use Laminas\Diactoros\Response\JsonResponse;
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)
{
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$events = $this->events->findUpcoming();
return new JsonResponse(
[
'data' => $events,
'meta' => ['count' => count($events)],
],
200,
[],
JsonResponse::DEFAULT_JSON_FLAGS | JSON_UNESCAPED_UNICODE,
);
}
}
The JSON_UNESCAPED_UNICODE flag matters for Slovenian text. Without it, "Sečovlje" is sent as Sečovlje. That's valid JSON, but painful to read while debugging.
The container needs to know how to build the handler. A factory is a small class that does exactly that:
api/src/Events/src/Handler/ListEventsHandlerFactory.php
<?php
declare(strict_types=1);
namespace Events\Handler;
use Events\Repository\EventRepository;
use Psr\Container\ContainerInterface;
final class ListEventsHandlerFactory
{
public function __invoke(ContainerInterface $container): ListEventsHandler
{
return new ListEventsHandler($container->get(EventRepository::class));
}
}
Registering the services
Replace the generated config provider with this version. It tells the container three things: the in-memory repository can be created directly (invokables), anyone asking for the EventRepository interface gets the in-memory implementation (aliases), and the handler is built by its factory (factories).
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 [
'invokables' => [
Repository\InMemoryEventRepository::class => Repository\InMemoryEventRepository::class,
],
'aliases' => [
Repository\EventRepository::class => Repository\InMemoryEventRepository::class,
],
'factories' => [
Handler\ListEventsHandler::class => Handler\ListEventsHandlerFactory::class,
],
];
}
}
In Part 2 we'll change one line, the alias, to point at a Doctrine repository, and the handler won't notice.
Step 4: A Health Endpoint
A health endpoint lets Docker, a load balancer, or you check that the API is alive. It belongs in the general App module the skeleton created.
api/src/App/Handler/HealthHandler.php
<?php
declare(strict_types=1);
namespace App\Handler;
use DateTimeImmutable;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class HealthHandler implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse([
'status' => 'ok',
'time' => (new DateTimeImmutable())->format(DATE_ATOM),
]);
}
}
It has no dependencies, so register it as an invokable. In api/src/App/ConfigProvider.php, add one line to the invokables array in getDependencies():
'invokables' => [
Handler\PingHandler::class => Handler\PingHandler::class,
Handler\HealthHandler::class => Handler\HealthHandler::class,
],
Step 5: Routes
Replace the skeleton's routes with ours:
api/config/routes.php
<?php
declare(strict_types=1);
use App\Handler\HealthHandler;
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');
};
The skeleton's home page and ping handlers are no longer routed. You can leave their files in place for now; we'll remove them, along with their tests, in Part 2.
Step 6: Errors as JSON
Out of the box, an unknown URL returns a plain-text "Cannot GET" message. An API should answer errors in a format its clients can parse. Mezzio's problem-details package returns errors in the standard JSON format defined by RFC 9457:
docker compose run --rm php composer require --no-interaction mezzio/mezzio-problem-details
The package registers itself in config/config.php. Now add it to the middleware pipeline, the ordered list of steps every request passes through. Replace the pipeline file with this version, which keeps the skeleton's routing steps and adds two problem-details pieces: one near the top to catch exceptions, and one at the end to handle unknown URLs.
api/config/pipeline.php
<?php
declare(strict_types=1);
use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\Application;
use Mezzio\Helper\ServerUrlMiddleware;
use Mezzio\Helper\UrlHelperMiddleware;
use Mezzio\MiddlewareFactory;
use Mezzio\ProblemDetails\ProblemDetailsMiddleware;
use Mezzio\ProblemDetails\ProblemDetailsNotFoundHandler;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Mezzio\Router\Middleware\MethodNotAllowedMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Psr\Container\ContainerInterface;
return static function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void {
$app->pipe(ErrorHandler::class);
$app->pipe(ProblemDetailsMiddleware::class);
$app->pipe(ServerUrlMiddleware::class);
$app->pipe(RouteMiddleware::class);
$app->pipe(ImplicitHeadMiddleware::class);
$app->pipe(ImplicitOptionsMiddleware::class);
$app->pipe(MethodNotAllowedMiddleware::class);
$app->pipe(UrlHelperMiddleware::class);
$app->pipe(DispatchMiddleware::class);
$app->pipe(ProblemDetailsNotFoundHandler::class);
};
Try It
No restart is needed, because the code is mounted into the containers and opcache checks for changes on every request.
curl http://localhost:8080/health
{"status":"ok","time":"2026-10-09T10:15:42+02:00"}
curl http://localhost:8080/events
{"data":[{"id":1,"name":"Ljubljanski maraton","date":"2026-10-25","place":"Ljubljana","region":"Osrednjeslovenska","distances":[42195,21097,10000]},{"id":2,"name":"Martinov tek Sečovlje", ...}],"meta":{"count":3}}
curl http://localhost:8080/nope
{
"title": "Not Found",
"type": "https://httpwg.org/specs/rfc9110.html#status.404",
"status": 404,
"detail": "Cannot GET http://localhost:8080/nope!"
}
The 404 is sent with the application/problem+json content type, which clients can detect. If a handler throws an exception, you get the same JSON format. In development mode it includes the exception message and stack trace. After composer development-disable those details are hidden, which is what you want in production.
Common Pitfalls
Every request takes seconds on Windows. Docker Desktop reads files on a Windows drive (C:\...) very slowly, and a PHP project reads thousands of files from vendor/. In our tests, starting the application took about 1.3 seconds from a Windows folder and 24 milliseconds from a Linux filesystem. Keep the project inside WSL 2 instead, for example in ~/running-events in your Ubuntu distribution, and run the docker compose commands from there. Editors like VS Code and PhpStorm can open WSL folders directly.
Port 8080 is already in use. Change the left side of "8080:80" in docker-compose.yml, for example to "8081:80", and use that port in your requests.
Root-owned files on Linux. If you forgot the --user flag and can't edit files the container created, fix the ownership with sudo chown -R $USER: api/, then use the flag from then on.
Changes not showing up. The php.ini above makes opcache check files on every request. If you copied a production php.ini with opcache.validate_timestamps = 0, PHP keeps serving the old code until the container restarts.
A "service not found" error. Every handler must be registered in a ConfigProvider, either as an invokable or with a factory. A typo in a class name there is the most common cause.
Want step-through debugging? Add this line to the Dockerfile to install Xdebug. The Alpine image needs build tools ($PHPIZE_DEPS and linux-headers) to compile it:
RUN apk add --no-cache $PHPIZE_DEPS linux-headers \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug
Keep it in development images only, because it slows every request down.
What You Have Now
A PHP 8.4 API running in Docker behind nginx, with:
GET /healthreporting that the service is aliveGET /eventsreturning three Slovenian races as JSON- errors returned in the standard problem-details JSON format
- a repository interface ready to be connected to a real database
running-events/
├── docker-compose.yml
├── docker/
│ ├── nginx/default.conf
│ └── php/
│ ├── Dockerfile
│ └── php.ini
└── api/
├── config/
│ ├── pipeline.php
│ └── routes.php
└── src/
├── App/
│ ├── ConfigProvider.php
│ └── Handler/HealthHandler.php
└── Events/src/
├── ConfigProvider.php
├── Handler/
│ ├── ListEventsHandler.php
│ └── ListEventsHandlerFactory.php
└── Repository/
├── EventRepository.php
└── InMemoryEventRepository.php
(The tree shows only the files we created or changed; the skeleton adds more.)
Next, in Part 2: we add MySQL to the Compose file, model races as Doctrine entities, generate the database schema from them, and replace the in-memory repository with real queries, including filters by region, distance and date.