Controllers

Controllers handle HTTP requests and return responses. Melodic provides three controller classes — an abstract base with response helpers, an API controller for JSON endpoints, and an MVC controller for rendered views.

Controller Base Class

The abstract Melodic\Controller\Controller class is the foundation for all controllers. It holds the current request and provides shorthand methods for building common HTTP responses.

The Request Property

Every controller has access to the current request via $this->request. This is set automatically by the RoutingMiddleware before your action method is called.

public function index(): JsonResponse
{
    $search = $this->request->query('search');
    $page   = $this->request->query('page', '1');

    // ...
}

Response Helpers

The base controller provides protected methods for building common responses. Each returns a Response or JsonResponse object that the framework sends to the client.

MethodStatusDescription
json($data, $statusCode = 200)200JSON response with any status code
created($data, $location = null)201JSON response with optional Location header
noContent()204Empty response body
badRequest($data = null)400Defaults to ['error' => 'Bad Request']
unauthorized($data = null)401Defaults to ['error' => 'Unauthorized']
forbidden($data = null)403Defaults to ['error' => 'Forbidden']
notFound($data = null)404Defaults to ['error' => 'Not Found']

Each error helper accepts an optional $data parameter to override the default error body:

// Default error body
return $this->notFound();
// Response: {"error": "Not Found"}

// Custom error body
return $this->notFound(['error' => 'User not found', 'id' => $id]);
// Response: {"error": "User not found", "id": "42"}

Full Signatures

protected function json(mixed $data, int $statusCode = 200): JsonResponse;
protected function created(mixed $data, ?string $location = null): JsonResponse;
protected function noContent(): Response;
protected function notFound(mixed $data = null): JsonResponse;
protected function badRequest(mixed $data = null): JsonResponse;
protected function unauthorized(mixed $data = null): JsonResponse;
protected function forbidden(mixed $data = null): JsonResponse;

ApiController

Melodic\Controller\ApiController extends the base Controller and adds access to the authenticated user context. Use this as the base class for JSON API endpoints.

use Melodic\Controller\ApiController;
use Melodic\Http\JsonResponse;

class ProfileController extends ApiController
{
    public function show(): JsonResponse
    {
        $userContext = $this->getUserContext();

        if ($userContext === null) {
            return $this->unauthorized();
        }

        return $this->json([
            'id'       => $userContext->getUser()->id,
            'username' => $userContext->getUsername(),
            'email'    => $userContext->getUser()->email,
        ]);
    }
}

getUserContext()

protected function getUserContext(): ?UserContextInterface

Returns the UserContextInterface instance that was attached to the request by the authentication middleware. If the request is not authenticated, this returns null.

Note: The user context is only available when an authentication middleware (such as ApiAuthenticationMiddleware) runs before the controller. Without it, getUserContext() will always return null.

Constructor Injection

The DI container auto-wires controller constructors. Declare your service dependencies as constructor parameters and they will be resolved automatically:

use Melodic\Controller\ApiController;

class OrderController extends ApiController
{
    public function __construct(
        private readonly OrderServiceInterface $orderService,
        private readonly InventoryServiceInterface $inventoryService,
    ) {}

    public function index(): JsonResponse
    {
        $orders = $this->orderService->getAll();
        return $this->json($orders);
    }
}

MvcController

Melodic\Controller\MvcController extends the base Controller and adds view rendering capabilities. Use this for controllers that return HTML pages.

Constructor

The MvcController receives a ViewEngine instance via constructor injection. This is handled automatically by the DI container:

// MvcController constructor (provided by the framework)
public function __construct(
    private readonly ViewEngine $viewEngine,
) {
    $this->viewBag = new ViewBag();
}

Important: If your subclass defines a constructor, you must call parent::__construct($viewEngine) and accept ViewEngine as a parameter so the view system is properly initialized.

use Melodic\Controller\MvcController;
use Melodic\View\ViewEngine;

class DashboardController extends MvcController
{
    public function __construct(
        ViewEngine $viewEngine,
        private readonly StatsServiceInterface $statsService,
    ) {
        parent::__construct($viewEngine);
    }
}

Rendering Views

Use the view() method to render a .phtml template and return an HTML response:

protected function view(string $template, array $data = []): Response

The $template parameter is a path relative to the views directory, without the .phtml extension. The $data array is extracted into the template scope as local variables.

public function index(): Response
{
    return $this->view('home/index', [
        'message' => 'Welcome!',
        'items'   => ['Alpha', 'Beta', 'Gamma'],
    ]);
}

// In views/home/index.phtml:
// $message and $items are available as local variables

Tip: The $viewBag variable is automatically included in every template's data, so you do not need to pass it explicitly in the $data array.

Layouts

Set a layout template with setLayout(). The layout wraps your view content, providing shared page structure (header, navigation, footer, etc.).

public function setLayout(string $layout): void

The layout path is relative to the views directory, without the .phtml extension:

public function index(): Response
{
    $this->setLayout('layouts/main');
    return $this->view('home/index', ['message' => 'Hello']);
}

Inside the layout template, use $this->renderBody() to output the view content and $this->renderSection('name') to output named sections:

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

The ViewBag

The $this->viewBag property is a dynamic key-value store for passing data to views and layouts. Set properties in the controller, then read them in templates:

// In the controller
$this->viewBag->title = 'Dashboard';
$this->viewBag->showSidebar = true;

// In the template or layout
<title><?= htmlspecialchars($viewBag->title) ?></title>
<?php if ($viewBag->showSidebar): ?>
    <aside>...</aside>
<?php endif; ?>

ViewBag uses magic __get and __set methods, so any property name works. Accessing an unset property returns null.

getUserContext()

The MvcController also provides getUserContext(), identical in behavior to the ApiController version. This is useful for displaying user-specific content in views:

public function index(): Response
{
    $this->viewBag->userContext = $this->getUserContext();
    $this->setLayout('layouts/main');
    return $this->view('home/index');
}

Route Parameter Injection

When a route has {name} placeholders, the captured values are passed as arguments to the controller action method. The argument names must match the placeholder names in the route pattern.

// Route: GET /posts/{postId}/comments/{commentId}
$router->get('/posts/{postId}/comments/{commentId}', CommentController::class, 'show');

// Controller action
public function show(string $postId, string $commentId): JsonResponse
{
    // $postId = '7', $commentId = '23' for /posts/7/comments/23
    $comment = $this->commentService->find((int) $postId, (int) $commentId);

    if ($comment === null) {
        return $this->notFound();
    }

    return $this->json($comment->toArray());
}

Important: Route parameters are always strings. If you need integers, booleans, or other types, cast them explicitly in your action method.

Accessing Request Data

The $this->request property provides access to all parts of the incoming HTTP request.

Query String Parameters

// GET /users?search=john&page=2
$search = $this->request->query('search');          // 'john'
$page   = $this->request->query('page', '1');        // '2'
$all    = $this->request->query();                    // ['search' => 'john', 'page' => '2']

Request Body

For JSON APIs, the JsonBodyParserMiddleware parses the request body into an associative array. Access it with body():

// POST /users with JSON body: {"username": "john", "email": "john@example.com"}
$body     = $this->request->body();                  // ['username' => 'john', 'email' => '...']
$username = $this->request->body('username');          // 'john'
$role     = $this->request->body('role', 'user');      // 'user' (default)

Headers

$contentType = $this->request->header('Content-Type');  // 'application/json'
$custom      = $this->request->header('X-Custom');       // value or null

Header lookup is case-insensitive.

HTTP Method and Path

$method = $this->request->method();  // HttpMethod::GET
$path   = $this->request->path();    // '/users/42'

Request Attributes

Attributes are values set by middleware during the request lifecycle. Route parameters are stored as attributes, and the authentication middleware stores the user context as an attribute.

$id          = $this->request->getAttribute('id');           // route parameter
$userContext = $this->request->getAttribute('userContext');   // set by auth middleware

Model Binding & Validation

When a controller action has a parameter typed as a Melodic\Data\Model subclass, the framework automatically hydrates it from the request body and validates it. If validation fails, a 400 JSON response with the errors array is returned before your action is called.

First, define a request model with validation attributes:

<?php

use Melodic\Data\Model;
use Melodic\Validation\Rules\Required;
use Melodic\Validation\Rules\Email;
use Melodic\Validation\Rules\MaxLength;

class CreateUserRequest extends Model
{
    #[Required]
    #[MaxLength(50)]
    public string $username = '';

    #[Required]
    #[Email]
    public string $email = '';
}

Then type-hint it in your action method. The framework handles the rest:

class UserApiController extends ApiController
{
    public function __construct(
        private readonly UserServiceInterface $userService,
    ) {}

    public function store(CreateUserRequest $request): JsonResponse
    {
        // $request is already hydrated and validated
        $id = $this->userService->create($request->username, $request->email);
        return $this->created(['id' => $id], "/api/users/{$id}");
    }

    public function update(string $id, UpdateUserRequest $request): JsonResponse
    {
        // Route params and model params work together
        $this->userService->update($id, $request);
        return $this->noContent();
    }
}

If the request body fails validation, the framework returns a 400 response automatically:

{
    "username": ["This field is required"],
    "email": ["Must be a valid email address"]
}

How it works. The RoutingMiddleware inspects action parameters via reflection. Route params (strings from the URL) are matched by name first. Parameters typed as a Model subclass are hydrated via Model::fromArray(), then validated using the Validator. If validation fails, the action is never called.

Manual Validation

When you need more control over the response or validation logic, inject the Validator and validate manually:

use Melodic\Controller\ApiController;
use Melodic\Http\Request;
use Melodic\Http\Response;
use Melodic\Validation\Validator;

class UserController extends ApiController
{
    public function __construct(
        private readonly UserServiceInterface $userService,
        private readonly Validator $validator,
    ) {}

    public function store(Request $request): Response
    {
        $data = $request->body();

        $result = $this->validator->validateArray($data, CreateUserRequest::class);

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

        $user = $this->userService->create($data);
        return $this->created(['user' => $user->toArray()]);
    }
}

Learn more. See the Validation docs for the full list of built-in rules, ValidationResult, ValidationException, and service-layer validation patterns.

Complete API Controller Example

Here is a full CRUD controller for a user resource, demonstrating constructor injection, all response helpers, route parameters, and request body access:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Services\UserServiceInterface;
use Melodic\Controller\ApiController;
use Melodic\Http\JsonResponse;
use Melodic\Http\Response;

class UserApiController extends ApiController
{
    public function __construct(
        private readonly UserServiceInterface $userService,
    ) {}

    public function index(): JsonResponse
    {
        $users = $this->userService->getAll();

        return $this->json(array_map(
            fn($user) => $user->toArray(),
            $users,
        ));
    }

    public function show(string $id): JsonResponse
    {
        $user = $this->userService->getById((int) $id);

        if ($user === null) {
            return $this->notFound(['error' => 'User not found']);
        }

        return $this->json($user->toArray());
    }

    public function store(): JsonResponse
    {
        $username = $this->request->body('username');
        $email    = $this->request->body('email');

        if ($username === null || $email === null) {
            return $this->badRequest([
                'error' => 'Username and email are required',
            ]);
        }

        $id   = $this->userService->create($username, $email);
        $user = $this->userService->getById($id);

        return $this->created($user->toArray(), "/api/users/{$id}");
    }

    public function update(string $id): JsonResponse
    {
        $user = $this->userService->getById((int) $id);

        if ($user === null) {
            return $this->notFound(['error' => 'User not found']);
        }

        $body = $this->request->body();
        $this->userService->update((int) $id, $body);

        $updated = $this->userService->getById((int) $id);
        return $this->json($updated->toArray());
    }

    public function destroy(string $id): Response
    {
        $deleted = $this->userService->delete((int) $id);

        if (!$deleted) {
            return $this->notFound(['error' => 'User not found']);
        }

        return $this->noContent();
    }
}

Register it with a single line:

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

Complete MVC Controller Example

Here is a controller that renders views with layouts, uses the viewBag, and demonstrates constructor injection alongside the ViewEngine:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Services\ArticleServiceInterface;
use Melodic\Controller\MvcController;
use Melodic\Http\Response;
use Melodic\View\ViewEngine;

class ArticleController extends MvcController
{
    public function __construct(
        ViewEngine $viewEngine,
        private readonly ArticleServiceInterface $articleService,
    ) {
        parent::__construct($viewEngine);
    }

    public function index(): Response
    {
        $this->viewBag->title = 'Articles';
        $this->viewBag->userContext = $this->getUserContext();
        $this->setLayout('layouts/main');

        $articles = $this->articleService->getAll();

        return $this->view('articles/index', [
            'articles' => $articles,
        ]);
    }

    public function show(string $id): Response
    {
        $article = $this->articleService->getById((int) $id);

        if ($article === null) {
            return $this->notFound();
        }

        $this->viewBag->title = $article->title;
        $this->viewBag->userContext = $this->getUserContext();
        $this->setLayout('layouts/main');

        return $this->view('articles/show', [
            'article' => $article,
        ]);
    }
}

And the corresponding view template:

<!-- views/articles/index.phtml -->
<h1>Articles</h1>

<?php if (empty($articles)): ?>
    <p>No articles found.</p>
<?php else: ?>
    <ul>
        <?php foreach ($articles as $article): ?>
            <li>
                <a href="/articles/<?= $article->id ?>">
                    <?= htmlspecialchars($article->title) ?>
                </a>
            </li>
        <?php endforeach; ?>
    </ul>
<?php endif; ?>

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

Register the routes:

$router->get('/articles', ArticleController::class, 'index');
$router->get('/articles/{id}', ArticleController::class, 'show');