melodicdev/framework

A modern PHP 8.2+ framework with CQRS, JWT authentication, and middleware pipeline

Maintainers

Package info

github.com/MelodicDevelopment/melodic-php

Homepage

Documentation

pkg:composer/melodicdev/framework

Statistics

Installs: 61

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.7.4 2026-03-25 19:03 UTC

This package is auto-updated.

Last update: 2026-03-25 19:03:11 UTC


README

A modern PHP 8.2+ framework with CQRS data patterns, JWT authentication, and a PSR-15-style middleware pipeline. Uses a layered architecture: Controller → Service → Query/Command.

Requirements

  • PHP 8.2+
  • Composer
  • PDO extension (SQLite, MySQL, PostgreSQL, or any PDO driver)

Installation

composer install

Quick Start

# Start the documentation website (runs on Melodic itself)
php -S localhost:8080 -t web/php.melodic.dev/public

Then visit:

  • http://localhost:8080/ — Landing page
  • http://localhost:8080/docs — Framework documentation
  • http://localhost:8080/tutorials — Step-by-step tutorials
  • http://localhost:8080/why-melodic — Philosophy & comparisons

Architecture

HTTP Request → Middleware Pipeline → Router → Controller → Service → Query/Command → Database

The framework enforces a clean separation of concerns across layers:

Layer Responsibility Framework Namespace
HTTP Request/Response objects, middleware pipeline Melodic\Http
Routing URL pattern matching, route groups, API resources Melodic\Routing
Controller Receives requests, returns responses, delegates to services Melodic\Controller
Service Business logic, orchestrates queries and commands Melodic\Service
Data Database access via DbContext, CQRS query/command objects Melodic\Data
Security JWT validation, authentication, authorization Melodic\Security
DI Dependency injection container with auto-wiring Melodic\DI
View Template engine with layouts and sections Melodic\View
Core Application bootstrap, configuration Melodic\Core

Project Structure

melodic-php/
├── composer.json                            # PSR-4: Melodic\ → src/
├── src/
│   ├── Core/
│   │   ├── Application.php                  # App builder: config, middleware, routes, run()
│   │   └── Configuration.php                # JSON config loader with dot-notation access
│   ├── Http/
│   │   ├── HttpMethod.php                   # Enum: GET, POST, PUT, DELETE, PATCH, OPTIONS
│   │   ├── Request.php                      # Wraps superglobals, immutable attributes
│   │   ├── Response.php                     # Status code, headers, body, send()
│   │   ├── JsonResponse.php                 # JSON-encoded response
│   │   ├── Exception/
│   │   │   ├── HttpException.php            # Base HTTP exception with status code
│   │   │   ├── BadRequestException.php      # 400
│   │   │   ├── NotFoundException.php        # 404
│   │   │   └── MethodNotAllowedException.php # 405
│   │   └── Middleware/
│   │       ├── MiddlewareInterface.php       # process(Request, RequestHandler): Response
│   │       ├── RequestHandlerInterface.php
│   │       ├── Pipeline.php                 # Chains middleware to a final handler
│   │       ├── CorsMiddleware.php           # Configurable CORS headers
│   │       └── JsonBodyParserMiddleware.php
│   ├── Routing/
│   │   ├── Route.php                        # Method + URI pattern + controller/action
│   │   ├── Router.php                       # Registration, groups, apiResource()
│   │   └── RoutingMiddleware.php            # Resolves route, invokes controller via DI
│   ├── Controller/
│   │   ├── Controller.php                   # Abstract base with json(), created(), noContent(), etc.
│   │   ├── ApiController.php                # JSON API controller with getUserContext()
│   │   └── MvcController.php                # View rendering with layout/section support
│   ├── DI/
│   │   ├── ContainerInterface.php           # get(), has(), bind(), singleton()
│   │   ├── Container.php                    # Auto-wiring, singletons, interface bindings, factories
│   │   └── ServiceProvider.php              # Modular registration base class
│   ├── Data/
│   │   ├── DbContextInterface.php           # query(), queryFirst(), command(), scalar(), transaction()
│   │   ├── DbContext.php                    # PDO wrapper with model hydration via Reflection
│   │   ├── QueryInterface.php               # CQRS query: getSql(), execute()
│   │   ├── CommandInterface.php             # CQRS command: getSql(), execute() returns int
│   │   └── Model.php                        # Base DTO with fromArray() and toArray()
│   ├── Security/
│   │   ├── JwtValidator.php                 # Firebase JWT validation with issuer/audience checks
│   │   ├── User.php                         # User with id, username, email, entitlements
│   │   ├── UserContextInterface.php         # isAuthenticated(), getUser(), hasEntitlement()
│   │   ├── UserContext.php                  # Built from JWT claims
│   │   ├── AuthenticationMiddleware.php     # Bearer token extraction and validation
│   │   ├── AuthorizationMiddleware.php      # Entitlement-based access control
│   │   └── SecurityException.php
│   ├── Validation/
│   │   ├── Validator.php                    # Validates objects and arrays against attribute rules
│   │   ├── ValidationResult.php             # Structured result with isValid and errors
│   │   ├── ValidationException.php          # Throwable validation failure (maps to 422)
│   │   └── Rules/                           # Attribute rules
│   │       ├── Required.php
│   │       ├── Email.php
│   │       ├── MinLength.php, MaxLength.php
│   │       ├── Min.php, Max.php
│   │       ├── Pattern.php
│   │       └── In.php
│   ├── Error/
│   │   └── ExceptionHandler.php             # JSON/HTML error responses, debug mode, logging
│   ├── Event/
│   │   ├── EventDispatcherInterface.php     # listen() and dispatch()
│   │   ├── EventDispatcher.php              # Priority-based listener execution
│   │   ├── Event.php                        # Base event with stopPropagation()
│   │   └── EventServiceProvider.php
│   ├── Cache/
│   │   ├── CacheInterface.php               # PSR-16-style get/set/delete/has/clear
│   │   ├── FileCache.php                    # File-based with TTL expiration
│   │   ├── ArrayCache.php                   # In-memory for testing
│   │   └── CacheServiceProvider.php
│   ├── Session/
│   │   ├── SessionInterface.php             # start, get, set, has, remove, destroy, regenerate
│   │   ├── NativeSession.php                # Wraps PHP session functions
│   │   ├── ArraySession.php                 # In-memory for testing
│   │   └── SessionServiceProvider.php
│   ├── Log/
│   │   ├── LoggerInterface.php              # Standard log level methods
│   │   ├── LogLevel.php                     # Enum: emergency through debug
│   │   ├── FileLogger.php                   # Daily rotating log files with interpolation
│   │   ├── NullLogger.php                   # No-op for testing
│   │   └── LoggingServiceProvider.php
│   ├── Console/
│   │   ├── CommandInterface.php             # getName(), getDescription(), execute()
│   │   ├── Command.php                      # Base class with output helpers
│   │   ├── Console.php                      # Command runner with help output
│   │   ├── RouteListCommand.php             # Lists registered routes
│   │   └── CacheClearCommand.php            # Clears application cache
│   ├── Service/
│   │   └── Service.php                      # Base service holding DbContext references
│   └── View/
│       ├── ViewEngine.php                   # Renders .phtml templates with layouts/sections/caching
│       └── ViewBag.php                      # Dynamic key-value store for view data
└── web/php.melodic.dev/                     # Documentation website (dogfoods the framework)
    ├── config/config.json
    ├── public/
    │   ├── index.php                    # Entry point
    │   ├── .htaccess                    # Apache rewrite rules
    │   ├── css/site.css                 # Extracted styles
    │   └── js/site.js                   # Copy-to-clipboard, mobile nav
    ├── src/
    │   ├── Controllers/
    │   │   ├── HomeController.php       # Landing page, Why Melodic
    │   │   ├── DocsController.php       # 18 documentation pages
    │   │   └── TutorialController.php   # 5 tutorial walkthroughs
    │   └── Middleware/
    │       └── RequestTimingMiddleware.php
    └── views/
        ├── layouts/
        │   ├── marketing.phtml          # Full-width layout
        │   └── docs.phtml               # Sidebar + content layout
        ├── partials/
        │   ├── nav.phtml                # Shared navigation
        │   ├── footer.phtml             # Shared footer
        │   └── doc-sidebar.phtml        # Grouped sidebar
        ├── pages/                       # Marketing pages
        ├── docs/                        # 18 documentation pages
        └── tutorials/                   # 5 tutorial pages

Application Bootstrap

Create an entry point (public/index.php) that configures and runs the app:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Melodic\Core\Application;
use Melodic\Data\DbContext;
use Melodic\Data\DbContextInterface;
use Melodic\Http\Middleware\CorsMiddleware;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Security\AuthenticationMiddleware;
use Melodic\Security\JwtValidator;

$app = new Application(__DIR__ . '/..');
$app->loadConfig('config/config.json');

// Register services
$app->services(function ($container) use ($app) {
    $container->singleton(DbContextInterface::class, function () use ($app) {
        $pdo = new PDO($app->config('database.dsn'));
        return new DbContext($pdo);
    });

    $container->singleton(JwtValidator::class, function () use ($app) {
        return new JwtValidator(
            secret: $app->config('jwt.secret'),
            algorithm: $app->config('jwt.algorithm', 'HS256'),
        );
    });
});

// Add middleware (executed in order)
$app->addMiddleware(new CorsMiddleware($app->config('cors') ?? []));
$app->addMiddleware(new JsonBodyParserMiddleware());
$app->addMiddleware(new AuthenticationMiddleware(
    $app->getContainer()->get(JwtValidator::class)
));

// Define routes
$app->routes(function ($router) {
    $router->apiResource('/api/users', UserApiController::class);
});

$app->run();

Application API

Method Description
loadConfig(string $path) Load a JSON config file (relative to base path or absolute)
config(?string $key, mixed $default) Read config value using dot-notation, e.g. $app->config('database.host')
addMiddleware(MiddlewareInterface $m) Add middleware to the pipeline (order matters)
services(callable $callback) Register services in the DI container; receives Container
register(ServiceProvider $provider) Register a service provider module
routes(callable $callback) Define routes; receives Router
getContainer() Access the DI container directly
getRouter() Access the router directly
run(?Request $request) Boot providers, build pipeline, dispatch request, send response

Configuration

Configuration is loaded from JSON files and supports dot-notation access:

{
    "database": {
        "dsn": "mysql:host=localhost;dbname=myapp",
        "username": "root",
        "password": "secret"
    },
    "jwt": {
        "secret": "your-secret-key",
        "algorithm": "HS256",
        "issuer": "my-app",
        "audience": "my-app"
    },
    "cors": {
        "allowedOrigins": ["*"],
        "allowedMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        "allowedHeaders": ["Content-Type", "Authorization"],
        "maxAge": 3600
    }
}
$app->config('database.dsn');          // "mysql:host=localhost;dbname=myapp"
$app->config('jwt.algorithm', 'HS256'); // with default fallback

You can also load and merge multiple config files:

$app->loadConfig('config/config.json');
$app->loadConfig('config/config.local.json'); // overrides/merges

Routing

Basic Routes

$app->routes(function (Router $router) {
    $router->get('/users', UserController::class, 'index');
    $router->post('/users', UserController::class, 'store');
    $router->get('/users/{id}', UserController::class, 'show');
    $router->put('/users/{id}', UserController::class, 'update');
    $router->delete('/users/{id}', UserController::class, 'destroy');
    $router->patch('/users/{id}', UserController::class, 'patch');
});

API Resource Routes

Register all five RESTful routes in one call:

$router->apiResource('/users', UserController::class);

This registers:

HTTP Method URI Controller Method
GET /users index()
GET /users/{id} show($id)
POST /users store()
PUT /users/{id} update($id)
DELETE /users/{id} destroy($id)

Route Groups

Group routes under a common prefix:

$router->group('/api', function (Router $router) {
    $router->apiResource('/users', UserController::class);
    $router->apiResource('/posts', PostController::class);
});
// Produces: /api/users, /api/users/{id}, /api/posts, etc.

Route Parameters

Parameters in {braces} are extracted and passed as arguments to the controller action:

$router->get('/users/{id}/posts/{postId}', UserPostController::class, 'show');

// Controller receives them as named arguments:
public function show(string $id, string $postId): JsonResponse
{
    // ...
}

Controllers

API Controllers

Extend ApiController for JSON API endpoints:

use Melodic\Controller\ApiController;

class UserApiController extends ApiController
{
    public function __construct(
        private readonly UserService $userService, // auto-injected by DI
    ) {}

    public function index(): JsonResponse
    {
        $users = $this->userService->getAll();
        return $this->json(array_map(fn($u) => $u->toArray(), $users));
    }

    public function show(string $id): JsonResponse
    {
        $user = $this->userService->getById((int) $id);
        return $user ? $this->json($user->toArray()) : $this->notFound();
    }

    public function store(): JsonResponse
    {
        $body = $this->request->body();
        $id = $this->userService->create($body['username'], $body['email']);
        $user = $this->userService->getById($id);
        return $this->created($user->toArray(), "/api/users/{$id}");
    }

    public function destroy(string $id): Response
    {
        $this->userService->delete((int) $id);
        return $this->noContent();
    }
}

Response Helpers

The base Controller class provides these helpers:

Method Status Description
json($data, $status) 200 JSON response
created($data, $location) 201 Created with optional Location header
noContent() 204 Empty response
badRequest($data) 400 Bad request error
unauthorized($data) 401 Authentication required
forbidden($data) 403 Access denied
notFound($data) 404 Resource not found

MVC Controllers

Extend MvcController for HTML views with layout support:

use Melodic\Controller\MvcController;

class HomeController extends MvcController
{
    public function index(): Response
    {
        $this->viewBag->title = 'Home';
        $this->setLayout('layouts/main');
        return $this->view('home/index', ['message' => 'Hello World']);
    }
}

Accessing the User Context

In ApiController, access the authenticated user from the JWT:

$userContext = $this->getUserContext();

if ($userContext->isAuthenticated()) {
    $username = $userContext->getUsername();

    if ($userContext->hasEntitlement('admin')) {
        // admin-only logic
    }
}

Dependency Injection

The DI container supports auto-wiring, singletons, interface bindings, and factory closures.

Registering Services

$app->services(function (Container $container) {
    // Singleton: same instance every time
    $container->singleton(DbContextInterface::class, fn() => new DbContext($pdo));

    // Transient: new instance each time (default)
    $container->bind(UserService::class, UserService::class);

    // Interface to implementation
    $container->bind(UserServiceInterface::class, UserService::class);

    // Factory with access to the container
    $container->singleton(JwtValidator::class, fn(Container $c) => new JwtValidator(
        secret: $c->get(Configuration::class)->get('jwt.secret'),
    ));

    // Register an existing instance
    $container->instance(Configuration::class, $config);
});

Auto-Wiring

The container automatically resolves constructor dependencies via Reflection. If a class type-hints its constructor parameters, the container resolves each one recursively:

class UserApiController extends ApiController
{
    public function __construct(
        private readonly UserService $userService, // resolved automatically
    ) {}
}

class UserService extends Service
{
    // DbContextInterface is resolved from the container binding
    public function __construct(
        protected readonly DbContextInterface $context,
    ) {}
}

Service Providers

For modular registration, extend ServiceProvider:

class AppServiceProvider extends ServiceProvider
{
    public function register(Container $container): void
    {
        $container->singleton(DbContextInterface::class, fn() => new DbContext($pdo));
        $container->bind(UserService::class, UserService::class);
    }

    public function boot(Container $container): void
    {
        // Post-registration logic (all providers registered)
    }
}

$app->register(new AppServiceProvider());

CQRS Data Pattern

The framework uses a Command/Query Responsibility Segregation pattern. Services instantiate query and command objects directly (no mediator).

Queries

Queries read data and return typed results:

use Melodic\Data\QueryInterface;
use Melodic\Data\DbContextInterface;

class GetUserByIdQuery implements QueryInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly int $id,
    ) {
        $this->sql = "SELECT id, username, email FROM users WHERE id = :id";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): ?UserModel
    {
        return $context->queryFirst(UserModel::class, $this->sql, ['id' => $this->id]);
    }
}

Commands

Commands write data and return the number of affected rows:

use Melodic\Data\CommandInterface;
use Melodic\Data\DbContextInterface;

class CreateUserCommand implements CommandInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly string $username,
        private readonly string $email,
    ) {
        $this->sql = "INSERT INTO users (username, email) VALUES (:username, :email)";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): int
    {
        return $context->command($this->sql, [
            'username' => $this->username,
            'email' => $this->email,
        ]);
    }
}

Services

Services orchestrate queries and commands. They hold a DbContext reference and expose domain-level methods:

use Melodic\Service\Service;

class UserService extends Service
{
    public function getAll(): array
    {
        return (new GetAllUsersQuery())->execute($this->context);
    }

    public function getById(int $id): ?UserModel
    {
        return (new GetUserByIdQuery($id))->execute($this->context);
    }

    public function create(string $username, string $email): int
    {
        (new CreateUserCommand($username, $email))->execute($this->context);
        return $this->context->lastInsertId();
    }
}

DbContext

The DbContext wraps PDO with typed query methods and automatic model hydration:

Method Description
query(string $class, string $sql, array $params): array Execute SELECT, return array of hydrated model objects
queryFirst(string $class, string $sql, array $params): ?object Execute SELECT, return first result or null
command(string $sql, array $params): int Execute INSERT/UPDATE/DELETE, return affected row count
scalar(string $sql, array $params): mixed Execute query, return single column value
transaction(callable $callback): mixed Wrap callback in BEGIN/COMMIT with automatic ROLLBACK on exception
lastInsertId(): int Return last auto-increment ID

Models

Extend Model for DTOs with automatic array conversion:

use Melodic\Data\Model;

class UserModel extends Model
{
    public int $id;
    public string $username;
    public string $email;
}

// Hydrated automatically by DbContext
$user = $context->queryFirst(UserModel::class, "SELECT * FROM users WHERE id = :id", ['id' => 1]);
$user->toArray(); // ['id' => 1, 'username' => 'alice', 'email' => 'alice@example.com']

Middleware

Built-in Middleware

CorsMiddleware — Adds CORS headers to all responses and handles preflight OPTIONS requests:

$app->addMiddleware(new CorsMiddleware([
    'allowedOrigins' => ['https://example.com'],
    'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE'],
    'allowedHeaders' => ['Content-Type', 'Authorization'],
    'maxAge' => 3600,
]));

JsonBodyParserMiddleware — Parses application/json request bodies so they are available via $request->body():

$app->addMiddleware(new JsonBodyParserMiddleware());

AuthenticationMiddleware — Extracts the Bearer token from the Authorization header, validates it via JwtValidator, and attaches a UserContext to the request:

$app->addMiddleware(new AuthenticationMiddleware($jwtValidator));

AuthorizationMiddleware — Enforces authentication and/or entitlement checks:

// Require authentication
$app->addMiddleware(new AuthorizationMiddleware());

// Require specific entitlements
$app->addMiddleware(new AuthorizationMiddleware(
    requiredEntitlements: ['admin', 'editor'],
    requireAuthentication: true,
));

Custom Middleware

Implement MiddlewareInterface:

use Melodic\Http\Middleware\MiddlewareInterface;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;

class TimingMiddleware implements MiddlewareInterface
{
    public function process(Request $request, RequestHandlerInterface $handler): Response
    {
        $start = microtime(true);
        $response = $handler->handle($request);
        $elapsed = microtime(true) - $start;

        return $response->withHeader('X-Response-Time', round($elapsed * 1000) . 'ms');
    }
}

Middleware is executed in the order it is added. Each middleware can:

  • Modify the request before passing it down
  • Short-circuit the pipeline by returning a response directly
  • Modify the response on the way back up

Security

JWT Authentication

The JwtValidator validates tokens using firebase/php-jwt:

$validator = new JwtValidator(
    secret: 'your-secret-key',
    algorithm: 'HS256',
    issuer: 'my-app',       // optional: validate iss claim
    audience: 'my-app',     // optional: validate aud claim
);

// Validate a token (throws SecurityException on failure)
$claims = $validator->validate($token);

// Create a token (useful for testing)
$token = $validator->encode([
    'sub' => 1,
    'username' => 'alice',
    'email' => 'alice@example.com',
    'entitlements' => ['admin', 'editor'],
    'iss' => 'my-app',
    'aud' => 'my-app',
    'exp' => time() + 3600,
]);

User Context

When AuthenticationMiddleware is active, controllers can access the authenticated user:

$userContext = $this->getUserContext(); // available in ApiController

$userContext->isAuthenticated();                  // bool
$userContext->getUser();                          // User object or null
$userContext->getUsername();                       // string or null
$userContext->hasEntitlement('admin');             // bool
$userContext->hasAnyEntitlement('admin', 'editor'); // bool

The UserContext is built from JWT claims with this mapping:

  • sub → user ID
  • username or preferred_username → username
  • email → email
  • entitlements → array of entitlement strings

Views

Template Rendering

Views are .phtml files rendered by ViewEngine. Data is extracted as local variables:

// Controller
$this->setLayout('layouts/main');
return $this->view('home/index', ['message' => 'Hello World', 'items' => $items]);
<!-- views/home/index.phtml -->
<h1><?= htmlspecialchars($message) ?></h1>
<ul>
    <?php foreach ($items as $item): ?>
        <li><?= htmlspecialchars($item) ?></li>
    <?php endforeach ?>
</ul>

Layouts

Layouts wrap view content. Use renderBody() to inject the view content:

<!-- views/layouts/main.phtml -->
<!DOCTYPE html>
<html>
<head>
    <title><?= htmlspecialchars($viewBag->title ?? 'App') ?></title>
    <?= $this->renderSection('head') ?>
</head>
<body>
    <nav><!-- navigation --></nav>
    <main><?= $this->renderBody() ?></main>
    <?= $this->renderSection('scripts') ?>
</body>
</html>

Sections

Views can define named sections that are injected into specific places in the layout:

<!-- views/home/index.phtml -->
<h1>Page Content</h1>

<?php $this->beginSection('head') ?>
<meta name="description" content="My page description">
<?php $this->endSection() ?>

<?php $this->beginSection('scripts') ?>
<script src="/app.js"></script>
<?php $this->endSection() ?>

ViewBag

ViewBag is a dynamic key-value store for passing data between controllers and layouts:

// Controller
$this->viewBag->title = 'My Page';
$this->viewBag->breadcrumbs = ['Home', 'Users'];

// Layout
<title><?= htmlspecialchars($viewBag->title) ?></title>

Validation

Attribute-based validation using PHP 8.2+ attributes on DTO properties:

use Melodic\Validation\Rules\{Required, Email, MinLength, Max, In};

class CreateUserDto
{
    #[Required]
    #[MinLength(3)]
    public string $username;

    #[Required]
    #[Email]
    public string $email;

    #[Required]
    #[In(['admin', 'editor', 'viewer'])]
    public string $role;
}

Validate objects or raw arrays:

$validator = new Validator();

// Validate an object
$result = $validator->validate($dto);

// Validate raw input against a DTO class
$result = $validator->validateArray($request->body(), CreateUserDto::class);

if (!$result->isValid) {
    return $this->json(['errors' => $result->errors], 422);
}

Available rules: #[Required], #[Email], #[MinLength], #[MaxLength], #[Min], #[Max], #[Pattern], #[In]. All accept a custom message parameter.

See docs/validation.md for full details.

Error Handling

The ExceptionHandler catches exceptions and returns structured JSON or HTML responses based on the client's Accept header:

// Throw typed exceptions from controllers or middleware
throw new NotFoundException('User not found');           // 404
throw new BadRequestException('Missing required field'); // 400
throw new HttpException(409, 'Resource conflict');       // any status code

Exception-to-status-code mapping: HttpException uses its status code, SecurityException maps to 401, JsonException to 400, everything else to 500. In debug mode, responses include stack traces; in production, 5xx errors return a generic message.

See docs/error-handling.md for full details.

Events

Priority-based event dispatcher for decoupling components:

use Melodic\Event\EventDispatcher;

$dispatcher = new EventDispatcher();

$dispatcher->listen(UserRegistered::class, function (UserRegistered $event) {
    sendWelcomeEmail($event->email);
}, priority: 10);

$dispatcher->dispatch(new UserRegistered(userId: 42, email: 'alice@example.com'));

Register via EventServiceProvider for DI container integration. Events extending the Event base class support stopPropagation().

See docs/events.md for full details.

Cache

PSR-16-style caching with FileCache and ArrayCache drivers:

use Melodic\Cache\FileCache;

$cache = new FileCache('/path/to/cache');
$cache->set('user:42', $userData, ttl: 3600);
$cache->get('user:42');

Register via CacheServiceProvider. The ViewEngine also supports cached rendering via renderCached().

See docs/cache.md for full details.

Session

Session abstraction with NativeSession (wraps PHP sessions) and ArraySession (for testing):

$session->set('user_id', 42);
$session->get('user_id');       // 42
$session->regenerate();         // new session ID
$session->destroy();            // clear everything

Register via SessionServiceProvider. Session IDs are automatically regenerated after authentication.

See docs/session.md for full details.

Logging

Daily rotating file logger with level filtering and message interpolation:

use Melodic\Log\FileLogger;
use Melodic\Log\LogLevel;

$logger = new FileLogger('/path/to/logs', LogLevel::WARNING);
$logger->info('User {username} logged in', ['username' => 'alice']);
$logger->error('Failed', ['exception' => $e]); // includes stack trace

Register via LoggingServiceProvider with config-driven path and level. NullLogger available for testing.

See docs/logging.md for full details.

Console

CLI command runner for terminal tasks:

$console = new Console();
$console->register(new RouteListCommand($router));
$console->register(new CacheClearCommand($cache));
exit($console->run($argv));
php bin/console route:list    # list all registered routes
php bin/console cache:clear   # clear the application cache

Extend Command to write custom commands with writeln(), error(), and table() output helpers.

See docs/console.md for full details.

Conventions

  • PHP 8.2+ features throughout: enums, readonly properties, constructor promotion, match expressions, named arguments
  • PascalCase for classes, camelCase for methods and properties
  • Controller → Service → Query/Command — controllers never access the database directly
  • CQRS data access — Query/Command objects executed via DbContext
  • No facades, no mediator — dependencies are explicit and directly instantiated
  • Immutable request/responsewithAttribute(), withHeader(), etc. return new instances

Architecture Decisions

Custom Request/Response vs PSR-7

Melodic uses lightweight, purpose-built Request and Response classes rather than implementing PSR-7 (psr/http-message). Both classes follow an immutable builder pattern (withAttribute(), withHeader(), withStatus(), etc.) and are designed specifically for the framework's middleware pipeline.

PSR-7 was not adopted because its full interface surface — StreamInterface, UriInterface, ServerRequestInterface with 20+ methods, and mandatory stream-wrapping of all message bodies — adds significant complexity for minimal benefit in a framework that controls the entire HTTP stack. Melodic's Request and Response expose only what the middleware pipeline, router, and controllers actually need, resulting in simpler, faster objects that are easier to understand and debug.

If you need to integrate third-party PSR-7 middleware, you can write thin adapter classes that map between Melodic\Http\Request/Response and the PSR-7 interfaces.

Recommended Application Structure

When building an application with Melodic, use this canonical directory layout. The default project type (full) includes both MVC views and API routing — most real-world apps need both.

my-app/
├── composer.json               # PSR-4: App\ → src/
├── config/
│   ├── config.json
│   └── config.local.json       # gitignored
├── public/
│   ├── index.php               # Entry point
│   └── .htaccess
├── bin/
│   └── console                 # CLI entry point
├── src/
│   ├── Controllers/            # ApiController and MvcController subclasses
│   ├── Services/               # Service subclasses (business logic)
│   ├── DTO/                    # Models extending Melodic\Data\Model
│   ├── Data/
│   │   └── {Entity}/
│   │       ├── Queries/        # QueryInterface implementations
│   │       └── Commands/       # CommandInterface implementations
│   ├── Middleware/              # Custom middleware
│   └── Providers/
│       └── AppServiceProvider.php
├── views/
│   ├── layouts/
│   │   └── main.phtml          # Base HTML layout
│   └── home/
│       └── index.phtml         # Home page template
├── storage/
│   ├── cache/
│   └── logs/
└── tests/

API and MVC controllers coexist in the same src/Controllers/ directory. Use route groups to organize them:

$app->routes(function ($router) {
    // MVC routes
    $router->get('/', HomeController::class, 'index');

    // API routes
    $router->group('/api', function ($router) {
        $router->apiResource('/users', UserController::class);
    });
});

Project Types

Type Flag Description
Full (default) --type=full or omit MVC views + API routing — recommended for most apps
API only --type=api No views directory, API-only index.php
MVC only --type=mvc Views + HomeController, MVC-only index.php

Naming Conventions

Type Location Naming Example
DTO / Model src/DTO/ {Entity}Model ChurchModel
Query src/Data/{Entity}/Queries/ Get{Entity}ByIdQuery GetChurchByIdQuery
Command src/Data/{Entity}/Commands/ Create{Entity}Command CreateChurchCommand
Service src/Services/ {Entity}Service ChurchService
Controller src/Controllers/ {Entity}Controller ChurchController
Provider src/Providers/ {Name}ServiceProvider AppServiceProvider

Scaffolding

The melodic CLI generates projects and entity CQRS scaffolding:

# Create a new project (full — MVC + API, the default)
vendor/bin/melodic make:project my-app

# API-only project
vendor/bin/melodic make:project my-api --type=api

# MVC-only project
vendor/bin/melodic make:project my-site --type=mvc

# Generate entity files (DTO, queries, commands, service, controller)
cd my-app
vendor/bin/melodic make:entity Church

make:entity generates 8 files per entity following the CQRS pattern:

  • src/DTO/{Entity}Model.php
  • src/Data/{Entity}/Queries/GetAll{Plural}Query.php
  • src/Data/{Entity}/Queries/Get{Entity}ByIdQuery.php
  • src/Data/{Entity}/Commands/Create{Entity}Command.php
  • src/Data/{Entity}/Commands/Update{Entity}Command.php
  • src/Data/{Entity}/Commands/Delete{Entity}Command.php
  • src/Services/{Entity}Service.php
  • src/Controllers/{Entity}Controller.php

Existing files are never overwritten — the command skips any file that already exists.

Dependencies

Package Purpose
firebase/php-jwt JWT token encoding and decoding