Controllers

Single-action classes, constructor injection, and when a closure is the better answer.

A controller is a class implementing Handler. One class, one route.

PHP
<?php
namespace App\Controllers;

use PhpOrbit\Http\Response;
use PhpOrbit\Http\ServerRequest;
use PhpOrbit\Routing\Handler;

final class ShowArticleController implements Handler
{
    public function handle(ServerRequest $request): Response
    {
        return Response::text('Article ' . $request->attribute('id'));
    }
}
PHP
<?php
$routes->get('/articles/{id:\d+}', ShowArticleController::class, 'articles.show');
Why one action per class

A [Controller::class, 'method'] pair can only be invoked dynamically, and a dynamic call returns mixed. That defeats the type guarantees the rest of the framework depends on. An interface keeps the signature statically checkable, and PHPStan can see that handle() returns a Response.

In practice a class per action also stops the 800-line controller that accumulates seven unrelated responsibilities.

Dependencies

Constructor parameters are resolved from the request scope. Nothing to register:

PHP
<?php
final class ListArticlesController implements Handler
{
    public function __construct(
        private readonly Connection $database,      // boot singleton
        private readonly TemplateEngine $view,      // boot singleton
        private readonly Session $session,          // published by middleware
    ) {
    }

    public function handle(ServerRequest $request): Response
    {
        $articles = $this->database->query('articles')
            ->orderBy('created_at', Direction::Descending)
            ->limit(20)
            ->get();

        return $this->view->respond('articles/index', [
            'title' => 'Articles',
            'articles' => $articles,
            'notice' => $this->session->takeFlash('notice'),
        ]);
    }
}

Resolution order for each parameter:

  1. Already provided for this request (the session, the matched route, the request scope)?
  2. Registered as scoped()? Build it, once per request.
  3. Registered as singleton()? Use the process-wide instance.
  4. Otherwise autowire it, per request, from its own constructor.

The controller itself is autowired, so it is built fresh per request and may hold state safely for that request's duration.

What cannot be autowired

PHP
<?php
final class ReportController implements Handler
{
    public function __construct(
        private readonly Connection $database,
        private readonly int $pageSize,        // no class type, no default
    ) {
    }
}

// CannotAutowire: Cannot resolve "$pageSize" of App\Controllers\ReportController::__construct():
//   it has no class type and no default. Register "App\Controllers\ReportController"
//   with an explicit factory.

Give it a default, or register the class explicitly:

PHP
<?php
$app->container->scoped(
    ReportController::class,
    static fn (RequestScope $scope): ReportController => new ReportController(
        $scope->get(Connection::class),
        pageSize: 50,
    ),
);

Guessing a scalar would silently inject a value nobody chose, so it is refused instead.

Closures

Closures receive the request and the scope, and are the right tool when a file would be ceremony:

PHP
<?php
$routes->get('/health', static fn (): Response => Response::json(['status' => 'ok']));

$routes->get('/whoami', static function (ServerRequest $request, RequestScope $scope): Response {
    $session = $scope->get(Session::class);

    return Response::text($session->get('name') ?? 'guest');
});
Rule of thumb

Closure if it fits on one line and needs nothing injected. A class the moment it needs a dependency, a template, or a test of its own.

Returning things

PHP
<?php
// Rendered HTML
return $this->view->respond('articles/show', ['article' => $article]);

// JSON
return Response::json(['id' => 42, 'title' => $title]);

// Redirect after a successful write, so a refresh does not repost
return Response::redirect('/articles');

// Nothing to say
return Response::noContent();

// A specific status
return Response::text('Not your article.', Status::Forbidden);

Full response API →

A complete example

Create an article: validate, write, flash, redirect.

PHP
<?php
namespace App\Controllers;

use PhpOrbit\Database\Connection;
use PhpOrbit\Http\Response;
use PhpOrbit\Http\ServerRequest;
use PhpOrbit\Routing\Handler;
use PhpOrbit\Session\Session;
use PhpOrbit\Validation\Validator;
use PhpOrbit\View\TemplateEngine;

final class CreateArticleController implements Handler
{
    public function __construct(
        private readonly Connection $database,
        private readonly Session $session,
        private readonly TemplateEngine $view,
    ) {
    }

    public function handle(ServerRequest $request): Response
    {
        $validator = Validator::forRequest($request)
            ->required('title')
            ->maxLength('title', 120)
            ->required('body')
            ->maxLength('body', 5000);

        if ($validator->fails()) {
            // Re-render with the errors rather than redirecting, so the user
            // keeps what they typed.
            return $this->view->respond('articles/new', [
                'title' => 'New article',
                'errors' => $validator->errors(),
                'old' => $request->formData(),
            ], Status::UnprocessableEntity);
        }

        $this->database->query('articles')->insert([
            'title' => $validator->validated('title'),
            'body' => $validator->validated('body'),
            'created_at' => gmdate('c'),
        ]);

        $this->session->flash('notice', 'Article published.');

        return Response::redirect('/articles');
    }
}

CSRF is already enforced for this POST — nothing in the controller asks for it. Why →