Search by

debuss-a / piccolo

debuss-a

A PSR-15 application skeleton built on Laminas Stratigility and Mezzio components, with League Container, attribute-based routing, and a DDD-layered structure.

Package info

github.com/debuss/piccolo

Type:project

pkg:composer/debuss-a/piccolo

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0


README

Piccolo is a lightweight PHP skeleton inspired by the Mezzio Skeleton App, designed to keep the same PSR-first architecture with less bootstrap complexity.

It uses Laminas and Mezzio components directly (without mezzio/mezzio) and provides a clean, editable Application layer, attribute-based routing, and service-provider-driven dependency injection.

Goals

  • Keep a simple starting point for modern PSR-7 / PSR-15 applications
  • Preserve strong architectural boundaries (Application / Domain / Infrastructure)
  • Reduce boilerplate around container wiring and route registration
  • Stay framework-agnostic enough to evolve with your project

Tech Stack

  • Runtime
    • PHP 8.4+
    • laminas/laminas-stratigility
    • laminas/laminas-httphandlerrunner
    • laminas/laminas-diactoros (default PSR-7 / PSR-17 implementation, swappable)
    • debuss-a/server-request-factory (server request from the globals, with any PSR-17 implementation)
  • Routing
    • mezzio/mezzio-fastroute
    • debuss-a/attribute-routing (attribute route collector)
  • Container
    • league/container + Service Providers + ReflectionContainer
  • Configuration
    • borschphp/config (with .env support)
  • Templating
    • mezzio/mezzio-platesrenderer (Plates)
  • Error handling / API errors
    • laminas/laminas-stratigility error handler (exception details outside production only)
    • mezzio/mezzio-problem-details (RFC 7807)
  • Logging
    • monolog/monolog
  • HTTP client example
    • php-http/curl-client

Getting Started

composer install
cp .env.example .env
composer serve

Application runs at: http://localhost:8080

Project Structure

.
├── bin/
│   └── console          # console entry point (Symfony Console)
├── bootstrap/
│   ├── app.php          # loads the configuration, applies PHP runtime settings, builds the container
│   ├── defines.php      # global defines
│   └── helpers.php      # path helper functions
├── config/
│   ├── commands.php     # console commands
│   ├── configuration.php # configuration sources (.env, environment variables)
│   ├── container.php    # League\Container wiring + service providers
│   ├── pipeline.php     # middleware pipeline
│   └── routes.php       # attribute route loader + manual routes
├── public/
│   └── index.php        # front controller
├── src/
│   ├── Application/     # delivery layer: handlers, middleware, console commands, service providers
│   ├── Domain/          # business contracts/models/shared concepts
│   └── Infrastructure/  # external integrations (HTTP client, persistence...)
└── storage/
    ├── cache/
    ├── logs/
    ├── openapi.yaml
    └── templates/

Core Architecture

1) Custom Application class

The skeleton includes a rewritten Application class in src/Application/Application.php.
It mirrors the familiar Mezzio app flow while keeping the entry point fully editable for teams.

2) Container with Service Providers

config/container.php uses league/container and registers focused service providers:

  • HTTP factories (PSR-17)
  • Request handler runner
  • Router (FastRoute)
  • Error handler
  • Logger
  • Template renderer
  • Problem Details middleware

The PSR-7 / PSR-17 implementation is only chosen in config/container.php, where the factories are given to the HTTP factories service provider. The rest of the skeleton (handlers, server request creation, error responses) only relies on the PSR-17 interfaces, so switching to another implementation is a matter of changing these factories.

The debuss-a/awareness package is used to inject common dependencies into Aware classes after resolution (e.g. logger, factories, container), which helps keep constructors small.

3) Routing strategy

Piccolo supports:

  • Attribute-based routing in handlers (default)
  • Optional manual route registration in config/routes.php

Attributes are collected via AttributeRouteLoader on Application\Handler.

Middlewares specific to some routes are declared on the handler too, with #[Middleware] (on the class for all its routes, or on the method): they are executed after the routing, only when the route is matched. For instance, the OpenAPI document is cached by the clients thanks to HttpCacheMiddleware (Cache-Control, ETag and 304 Not Modified):

#[Get(path: '/openapi[.{format:yml|yaml}]', name: 'api.v1.openapi')]
#[Middleware(HttpCacheMiddleware::class)]
public function handle(ServerRequestInterface $request): ResponseInterface

Middlewares that apply to a whole path, like /api, are piped in config/pipeline.php instead.

4) Middleware pipeline

config/pipeline.php keeps a Mezzio-style flow:

  1. RequestIdMiddleware (identifies the request, see Error Handling and Logging)
  2. ErrorHandler
  3. OriginalMessages (keeps the original URI for middleware piped on a path)
  4. CorsMiddleware, only when CORS is enabled (see CORS)
  5. /api scoped middlewares (ApiAcceptHeaderMiddleware, ProblemDetailsMiddleware, BodyParamsMiddleware)
  6. RouteMiddleware
  7. ImplicitHeadMiddleware
  8. ImplicitOptionsMiddleware
  9. MethodNotAllowedMiddleware
  10. DispatchMiddleware
  11. NotFoundHandler

5) Layer dependencies

Dependencies always point to the Domain: Application and Infrastructure may use Domain, but the Domain uses neither of them, and Application does not use Infrastructure. Infrastructure implementations are only wired to Domain interfaces in config/container.php.

These rules are enforced by architecture tests (tests/ArchitectureTest.php).

Built-in Endpoints

  • GET / — home page (or JSON fallback)
  • GET /api/ping — lightweight ping endpoint
  • GET /api/v1/posts — posts example list (JSONPlaceholder)
  • GET /api/v1/posts/{id} — post by id
  • GET /api/v1/openapi (or /api/v1/openapi.yaml, /api/v1/openapi.yml) — OpenAPI document
  • GET /api/v1/redoc (or /api/v1/swagger) — ReDoc UI

OpenAPI and ReDoc

The API contract is stored in:

  • storage/openapi.yaml

The ReDoc handler renders documentation directly from the route-generated OpenAPI URL, so docs stay aligned with your app URLs.

Configuration

The configuration is loaded once by bootstrap/app.php from config/configuration.php:

  1. the .env file, optional (for local development)
  2. the real environment variables, which override the .env file

In production, prefer real environment variables (Apache SetEnv, nginx fastcgi_param, Docker ENV, systemd Environment=...) over a .env file.

Available variables (see .env.example):

  • APP_ENV — development (default) or production
  • LOGGER_NAME
  • LOG_STREAM — where logs are written: php://stderr (default) or a file path relative to the app root
  • LOG_LEVEL — minimum level to log: debug by default, info in production
  • TIMEZONE — applied to the whole application (default UTC)
  • CORS_ALLOWED_ORIGINS, CORS_ALLOWED_HEADERS, CORS_EXPOSED_HEADERS, CORS_ALLOW_CREDENTIALS, CORS_MAX_AGE — see CORS

APP_ENV is read in a single place, through the Application\Environment enum, which services can depend on. With APP_ENV=production:

  • errors are not displayed, and error responses do not contain exception details
  • the route definitions read from the handler attributes are cached in storage/cache/route-definitions.cache.php
  • the FastRoute dispatch data is cached in storage/cache/routes.cache.php

These caches are never regenerated on their own: run bin/console cache:warmup (with APP_ENV=production) on each deployment, see Console. Once warmed up, storage/cache can be read-only.

CORS

CORS is handled by mezzio-cors, and disabled by default: allow the origins of your front-ends to enable it.

CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173
  • Origins accept * wildcards (https://*.example.com), and * allows any origin. The requests of the other origins are rejected with a 403.
  • The allowed HTTP methods are those of the requested route. The allowed request headers are Content-Type by default (CORS_ALLOWED_HEADERS), and no response header is exposed besides the CORS-safelisted ones (CORS_EXPOSED_HEADERS, e.g. ETag).
  • Credentials (cookies, Authorization header) are not allowed by default (CORS_ALLOW_CREDENTIALS), and cannot be allowed for any origin.
  • Preflight responses are not cached by the browser by default (CORS_MAX_AGE, in seconds).

The configuration applies to every route: CorsMiddleware is piped before the routing, as it matches the requested route on its own. Behind a reverse proxy, configure the trusted proxies (see RequestHandlerRunnerServiceProvider) so that the scheme and host of the request, compared to the origin, are the public ones.

Error Handling and Logging

  • HTML errors: Stratigility ErrorHandler; the exception (message and trace) is only displayed outside production, otherwise the reason phrase is returned.
  • API errors: ProblemDetailsMiddleware (scoped to /api) returns RFC 7807 responses. Unknown exceptions become a generic 500, with details only outside production. Handlers translate the Domain exceptions they know about, e.g. PostHandler returns a 404 Problem Details response on NotFoundException.
  • Logs: JSON lines written by Monolog to LOG_STREAM. Errors are logged with the request method, URI and response status only: headers, cookies and body are never logged, as they can contain credentials.
  • Request identifier: RequestIdMiddleware keeps the X-Request-Id header set by a reverse proxy or a load balancer (letters, digits, ., _, : and -, at most 128 characters), or generates one. It is added to every log record of the request (extra.request_id), set in the request_id attribute of the request, and sent back in the X-Request-Id header of the response, errors included. With CORS, add X-Request-Id to CORS_EXPOSED_HEADERS for a front-end to read it.

Console

bin/console is built on Symfony Console, with the same configuration and container as the web application:

bin/console                # lists the commands
bin/console routes:list    # lists the routes, with their middlewares and handler
bin/console cache:clear    # removes the content of storage/cache
bin/console cache:warmup   # regenerates the production caches (routes), to run on each deployment

Commands live in src/Application/Console and are mapped to their name in config/commands.php. They are resolved from the container only when they are run, so they can depend on any service.

Testing

Pest is included as a dev dependency:

./vendor/bin/pest

Extending the Skeleton

  • Add new handlers in src/Application/Handler
  • Declare routes via attributes (or manually in config/routes.php)
  • Register app services in config/container.php or dedicated service providers
  • Add console commands in src/Application/Console, and map them in config/commands.php
  • Keep business logic in Domain, and external IO in Infrastructure

Piccolo is intentionally pragmatic: small enough to start quickly, structured enough to scale cleanly.