The official starting point for building robust, secure, and high-performance applications with the Waffle Ecosystem.
Release:
0.1.0-beta7Β |Β PHP 8.5+ Β· FrankenPHP worker mode
Welcome to the Waffle Skeleton, the official starting point for building robust, secure, and high-performance applications with the Waffle Ecosystem.
This skeleton is not just a folder structure; it is a Production-Grade Boilerplate pre-configured with:
-
FrankenPHP (Caddy): Modern application server with native HTTP/3 and Early Hints support.
-
Docker Multi-Stage: Optimized images for Development (with Xdebug) and Production (Immutable, <100MB).
-
Hardened Security Defaults: Auto-generated secrets, fail-closed CORS (SEC-04), default-on SSRF protection on outbound calls (SEC-02), stateless HMAC CSRF (SEC-01), and the Universal Authentication Bridge (RFC-021).
-
Strict Standards: PHP 8.5+, Typed Properties, and Read-Only classes.
-
Docker & Docker Compose (Required)
-
PHP 8.5+ with the
redisandyamlextensions, & Composer (Optional: only to run Composer on your machine rather than in Docker)
Until 1.0.0, every Waffle release is a beta, and Composer offers only stable releases unless you name a version. Name the one you want:
composer create-project waffle-commons/skeleton:0.1.0-beta7 my-app
cd my-appThe setup scripts run on the way: .env is created from .env.example with its secrets generated, and var/ is initialised.
With Docker only (no PHP or Composer on your machine): download the script at the release you want, read it, then run it. It runs the same composer create-project in the official Composer image, pinned by digest, as your own user.
curl -fsSLO https://raw.githubusercontent.com/waffle-commons/skeleton/0.1.0-beta7/create-project.sh
less create-project.sh
bash create-project.sh my-appWithout the script, the same thing in one command (the Composer image lacks the redis and yaml extensions; your application's image installs them):
docker run --rm -it --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD":/app -w /app \
composer:2.10.3 create-project waffle-commons/skeleton:0.1.0-beta7 my-app \
--ignore-platform-req=ext-redis --ignore-platform-req=ext-yamlWaffle is Cloud-Native by design. We provide two distinct environments managed via a single Dockerfile.
Optimized for Developer Experience (DX).
-
Hot Reload: Code is mounted via volumes. Changes are reflected instantly.
-
Debugging: Xdebug is installed and configured.
-
Tooling: Composer is available inside the container.
Start the Dev Server:
docker compose up --build -dYour application is now available at: π https://localhost (Accept the self-signed certificate auto-generated by Caddy).
Optimized for Performance and Security.
-
Immutable: No source code volumes. The code is baked into the image.
-
Fast: Opcache validation is disabled, Preloading is enabled.
-
Secure: Dev tools (Composer, Xdebug) are removed. Rootless execution.
Test the Production Build locally:
docker compose -f docker-compose.prod.yml up --build -dProduction needs APP_SECRET and WAFFLE_AUTH_SECRET (32 bytes or more): without them, docker compose stops and names the missing variable, and the kernel would refuse to boot anyway. The CSRF signing key derives from APP_SECRET; set WAFFLE_CSRF_SECRET only to impose a key of your own. Locally, Compose reads them from your .env; in a real deployment, inject them from your CI/CD or secret store, never from a committed file.
A Waffle application follows a strict but simple structure:
.
βββ config/ # βοΈ Configuration
β βββ app.yaml # Main Waffle Configuration
β βββ preload.php # Opcache Preloading Script
βββ docker/ # π³ Infrastructure as Code (Dockerfile, Caddyfile, PHP config)
βββ migrations/ # ποΈ Versioned SQL migration scripts (bin/waffle db:migrate)
βββ public/ # π Web Entry Point (index.php)
βββ scripts/ # π οΈ Composer Lifecycle Scripts
βββ src/ # π§ Your Application Logic (Namespace: App\)
β βββ Controller/ # HTTP Entry points
β βββ Factory/ # Application Factory
β β βββ AppKernelFactory.php # The Kernel Factory (Dependencies)
β βββ Service/ # Business Logic
β βββ Kernel.php # The Application Core (Configuration & Boot)
βββ tests/ # π§ͺ PHPUnit Test Suite
βββ var/ # π¦ Temporary files (Cache, Logs, Exports, etc) - Ignored by Git
Waffle ships a native DotEnv parser (no third-party dependency). When you run create-project, a .env file is automatically created from .env.example with generated secrets: APP_SECRET and WAFFLE_AUTH_SECRET (32 random bytes each, in hex). WAFFLE_CSRF_SECRET stays empty, since the CSRF key derives from APP_SECRET. composer install does the same when .env is missing, so a fresh clone of your project gets its own.
| Variable | Description |
|---|---|
APP_ENV |
dev (debug enabled) or prod (optimized). |
APP_DEBUG |
true displays detailed stack traces. false renders JSON errors. |
APP_SECRET |
Root secret of the application, 32 bytes or more, generated by create-project. The CSRF signing key derives from it (HKDF-SHA256, App\Factory\SecretFactory::csrf()) unless WAFFLE_CSRF_SECRET is set, so every worker and replica that shares it signs with the same key. Required in prod. |
WAFFLE_CSRF_SECRET |
Optional 32+ byte signing key for stateless HMAC CSRF tokens (Beta-1 / SEC-01), used instead of the key derived from APP_SECRET. In prod, a value shorter than 32 bytes aborts boot, as does the absence of both; non-prod falls back to the derived key, then to a per-process random one. |
WAFFLE_AUTH_SECRET |
32+ byte shared HMAC-SHA256 secret for the Universal Authentication Bridge (RFC-021). Signs X-Wfl-Assert-User identity assertions and validates the demo HS256 JWTs. In prod, a missing or short value aborts boot (fail-closed). Never derived from APP_SECRET: the services of the bridge share it. |
SERVER_NAME |
The domain name used by Caddy (e.g., example.com or localhost). |
DB_HOST / DB_PORT |
Database host and port consumed by waffle.database.* (RFC-022). |
DB_NAME |
Database / schema name. |
DB_USER / DB_PASSWORD |
Database credentials. Override from your orchestrator in production. |
β Precedence: OS env wins over
.env.AppKernelFactorymerges your.envwith the live process environment (Dockerenvironment:, Kubernetesenv:, shell exports, etc.) viaarray_merge((new DotEnv($root))->load(), getenv()). Becausearray_mergeis rightmost-wins on string keys, the OS value beats.envon collision. If you edit.envand the change doesn't take effect, check whether the same variable is exported by your shell ordocker-compose.ymlβ that export will silently override.env. This matches the Twelve-Factor convention. Seedocumentation/how-to/configuration.mdfor the merge rules and the type-normalization foot-gun aroundAPP_DEBUG/DEBUG.
Waffle uses native YAML parsing (via PECL extension) for blazing-fast configuration loading.
# Main application configuration for the Waffle Framework
waffle:
env: '%env(APP_ENV)%'
# Root secret (32+ bytes). The CSRF key derives from it when security.csrf.secret is empty.
secret: '%env(APP_SECRET)%'
# Host-header allowlist (anti-poisoning). REQUIRED in production.
trusted_hosts:
- localhost
security:
level: 10
csrf:
# SEC-01: stateless HMAC CSRF key, optional. Prod refuses a value under 32 bytes.
secret: '%env(WAFFLE_CSRF_SECRET)%'
# SEC-04: fail-closed CORS. Empty β every cross-origin request is rejected.
# Add exact origins (scheme://host[:port]); never '*' with credentials.
cors:
allowed_origins: []
# SEC-02: the outbound HTTP client resolves β validates β pins every host,
# refusing private/loopback/reserved IPs. allowed_hosts whitelists trusted
# internal backends (exact host or CIDR) β keep it tight.
ssrf:
allowed_hosts: []
# Universal Authentication Bridge (RFC-021). The secret aborts boot in prod if absent.
auth:
secret: '%env(WAFFLE_AUTH_SECRET)%'
tenant: 'skeleton'
jwt:
issuer: 'https://waffle-dev.local'
audience: 'waffle-skeleton'
paths:
controllers: 'src/Controller'
services: 'src/Service'
# Database & migrations (RFC-022). Credentials resolved from DB_* env vars.
database:
driver: 'mysql'
host: '%env(DB_HOST)%'
port: '%env(DB_PORT)%'
database: '%env(DB_NAME)%'
username: '%env(DB_USER)%'
password: '%env(DB_PASSWORD)%'
charset: 'utf8mb4'
migrations_path: 'migrations'The shipped
config/app.yamlalso wireslog(PSR-3 channel) andcache(PSR-16 adapter) β see the file for the full, commented set.
The skeleton ships the canonical Beta-4 middleware pipeline out of the box:
ErrorHandler β TrustedHost β CORS β AnonymousSession β Authentication β Routing β CSRF β Security β SecureHeaders β Dispatcher
This means every controller action you write is, by default, subject to:
- Fail-closed ABAC β an action without
#[Voter]returns HTTP403. Tag explicitly public actions with#[\Waffle\Commons\Contracts\Security\Attribute\PublicAccess]. - Stateless HMAC CSRF on mutating routes (SEC-01) β opt actions in with
#[RequiresCsrfToken]. Tokens are HMAC-bound to the per-browserWAFFLE_SIDcookie issued byAnonymousSessionMiddleware, which rotates the SID on privilege change. - Fail-closed CORS (SEC-04) β cross-origin requests are rejected unless their origin is listed in
waffle.security.cors.allowed_origins; wildcard-with-credentials is refused at construction. - Universal Authentication (RFC-021) β the
authbridge verifies inbound credentials (JWT / OAuth2-OIDC / HMAC assertion / API key / Basic) fail-closed and publishes the identity for ABAC.
The outbound HTTP client is hardened too: default-on SSRF protection (SEC-02) resolves β validates β pins every target host and refuses private/reserved addresses, with waffle.security.ssrf.allowed_hosts whitelisting trusted internal backends.
See the framework docs at waffle-commons/documentation for the full design rationale.
Waffle ships a lightweight SQL migration runner (RFC-022, via waffle-commons/data). Database access is configured under waffle.database in config/app.yaml (credentials from the DB_* env vars), and the connection pool + runner are wired in src/Factory/AppKernelFactory.php and registered in bin/waffle as db:migrate, db:status and db:rollback, next to the make:migration scaffolder.
From the project root, create a version, write its SQL, then apply the pending ones:
php bin/waffle make:migration CreateOrdersTable # migrations/Version<YYYYMMDDNN>_CreateOrdersTable.sql + .down.sql
php bin/waffle db:migrate # applies every pending version
php bin/waffle db:status # applied, pending or missing, version by version
php bin/waffle db:rollback --steps=1 # reverts the most recent version through its .down.sqlEach migration runs in its own transaction and is recorded in a waffle_migrations table, so re-runs skip already-applied scripts; a script that holds only comments is refused, never recorded. The skeleton ships a sample migrations/Version2026053101_CreateUsersTable.sql and its down script to get you started. See the Database Migrations how-to for the full workflow and the transactional-DDL caveat on MySQL, MariaDB and Oracle.
waffle-commons/openapi (a development dependency) describes your routes and their #[Dto] bodies as an OpenAPI 3.1 document, with no hand-written YAML:
php bin/waffle openapi:generate --output=public/openapi.json # or --output=- for the standard outputIn debug mode only, the application also serves /_docs (Swagger UI) and /_docs/openapi.json (the document generated from the routes at that moment); ApiDocsFactory mounts them, and production, which refuses debug mode, never does. The #[OA\Operation], #[OA\Response] and #[OA\Property] attributes from Waffle\Commons\Contracts\OpenApi\Attribute add summaries, tags, extra responses and field descriptions; see the openapi README.
Create src/Service/Greeter.php:
<?php
namespace App\Service;
readonly class Greeter
{
public function sayHello(string $name): string
{
return "Welcome to Waffle, {$name}!";
}
}Create src/Controller/HelloController.php. Waffle uses Attributes for routing.
<?php
namespace App\Controller;
use App\Service\Greeter;
use Psr\Http\Message\ResponseInterface;
use Waffle\Commons\Contracts\Routing\Attribute\Route;
use Waffle\Commons\Contracts\Security\Attribute\PublicAccess;
use Waffle\Core\BaseController;
class HelloController extends BaseController
{
// Services are automatically injected (Autowiring)
public function __construct(
private Greeter $greeter
) {}
// `methods` is a LIST, and it is optional (defaults to ['GET']).
// `#[PublicAccess]` is required: authorization is fail-closed, so an action
// carrying no #[Voter] is denied with a 403 unless it opts out explicitly.
#[Route(path: '/greet/{name}', methods: ['GET'])]
#[PublicAccess]
public function index(string $name): ResponseInterface
{
$message = $this->greeter->sayHello($name);
return $this->jsonResponse(data: [
'message' => $message,
'status' => 'success'
]);
}
}Go to https://localhost/greet/Developer. You should see your JSON response!
The skeleton comes with PHPUnit pre-configured.
# Run tests inside the Docker container
vendor/bin/phpunitWe welcome contributions! Please see CONTRIBUTING.md for details.
This project is licensed under the MIT License - see the LICENSE.md file for details.
