debuss-a / piccolo
A PSR-15 application skeleton built on Laminas Stratigility and Mezzio components, with League Container, attribute-based routing, and a DDD-layered structure.
Requires
- php: ^8.4
- borschphp/config: ^1.4
- debuss-a/attribute-routing: ^3.0
- debuss-a/awareness: ^1.2
- debuss-a/server-request-factory: ^1.0
- laminas/laminas-diactoros: ^3.8
- laminas/laminas-httphandlerrunner: ^2.13
- laminas/laminas-stratigility: ^4.3
- league/container: ^5.2
- league/plates: ^3.6
- mezzio/mezzio-cors: ^1.16
- mezzio/mezzio-fastroute: ^3.14
- mezzio/mezzio-helpers: ^5.20
- mezzio/mezzio-platesrenderer: ^2.14
- mezzio/mezzio-problem-details: ^1.19
- mezzio/mezzio-router: ^4.2
- mezzio/mezzio-template: ^3.0
- monolog/monolog: ^3.10
- php-http/curl-client: ^2.4
- psr/container: ^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- symfony/console: ^8.1
Requires (Dev)
- pestphp/pest: ^5.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-18 21:24:20 UTC
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-stratigilitylaminas/laminas-httphandlerrunnerlaminas/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-fastroutedebuss-a/attribute-routing(attribute route collector)
- Container
league/container+ Service Providers + ReflectionContainer
- Configuration
borschphp/config(with.envsupport)
- Templating
mezzio/mezzio-platesrenderer(Plates)
- Error handling / API errors
laminas/laminas-stratigilityerror 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:
RequestIdMiddleware(identifies the request, see Error Handling and Logging)ErrorHandlerOriginalMessages(keeps the original URI for middleware piped on a path)CorsMiddleware, only when CORS is enabled (see CORS)/apiscoped middlewares (ApiAcceptHeaderMiddleware,ProblemDetailsMiddleware,BodyParamsMiddleware)RouteMiddlewareImplicitHeadMiddlewareImplicitOptionsMiddlewareMethodNotAllowedMiddlewareDispatchMiddlewareNotFoundHandler
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 endpointGET /api/v1/posts— posts example list (JSONPlaceholder)GET /api/v1/posts/{id}— post by idGET /api/v1/openapi(or/api/v1/openapi.yaml,/api/v1/openapi.yml) — OpenAPI documentGET /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:
- the
.envfile, optional (for local development) - the real environment variables, which override the
.envfile
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) orproductionLOGGER_NAMELOG_STREAM— where logs are written:php://stderr(default) or a file path relative to the app rootLOG_LEVEL— minimum level to log:debugby default,infoin productionTIMEZONE— applied to the whole application (defaultUTC)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 a403. - The allowed HTTP methods are those of the requested route. The allowed request headers are
Content-Typeby default (CORS_ALLOWED_HEADERS), and no response header is exposed besides the CORS-safelisted ones (CORS_EXPOSED_HEADERS, e.g.ETag). - Credentials (cookies,
Authorizationheader) 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.PostHandlerreturns a 404 Problem Details response onNotFoundException. - 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:
RequestIdMiddlewarekeeps theX-Request-Idheader 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 therequest_idattribute of the request, and sent back in theX-Request-Idheader of the response, errors included. With CORS, addX-Request-IdtoCORS_EXPOSED_HEADERSfor 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.phpor dedicated service providers - Add console commands in
src/Application/Console, and map them inconfig/commands.php - Keep business logic in
Domain, and external IO inInfrastructure
Piccolo is intentionally pragmatic: small enough to start quickly, structured enough to scale cleanly.