mini\Controller
namespace
Controller - Attribute-Based Routing for Controllers
Mini's controller system provides clean, type-safe routing with automatic parameter extraction and return value conversion.
Quick Start
use mini\Controller\AbstractController;
use mini\Controller\Attributes\GET;
use mini\Controller\Attributes\POST;
use Psr\Http\Message\ResponseInterface;
class UserController extends AbstractController
{
public function __construct()
{
parent::__construct();
$this->router->importRoutesFromAttributes($this);
}
#[GET('/')]
public function index(): array
{
return ['users' => db()->query("SELECT * FROM users")->fetchAll()];
}
#[GET('/{id}/')]
public function show(int $id): array
{
$user = db()->query("SELECT * FROM users WHERE id = ?", [$id])->fetch();
if (!$user) throw new \mini\Http\NotFoundException();
return $user;
}
#[POST('/')]
public function create(): array
{
db()->exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
[$_POST['name'], $_POST['email']]
);
return ['message' => 'Created', 'id' => db()->lastInsertId()];
}
}
Mount in Mini router:
// _routes/users/__DEFAULT__.php
return new UserController();
Core Philosophy
Controllers return data, not responses. The converter registry automatically transforms return values to HTTP responses:
array→ JSON responsestring→ text/plain responseResponseInterface→ used directly- Custom types → register converters
Route Attributes
HTTP Method Attributes
use mini\Controller\Attributes\{GET, POST, PUT, PATCH, DELETE};
#[GET('/path')] // GET requests
#[POST('/path')] // POST requests
#[PUT('/path')] // PUT requests
#[PATCH('/path')] // PATCH requests
#[DELETE('/path')] // DELETE requests
Generic Route Attribute
use mini\Controller\Attributes\Route;
#[Route('/path', method: 'GET')]
#[Route('/path', method: 'OPTIONS')]
Multiple Routes on Same Method
Attributes are repeatable - register multiple routes:
#[GET('/users/')]
#[GET('/people/')]
public function list(): array
{
return ['users' => db()->query("SELECT * FROM users")->fetchAll()];
}
Type-Aware URL Parameters
The router analyzes method signatures to extract and type-cast URL parameters:
#[GET('/{id}/')]
public function show(int $id): array
{
// $id is automatically extracted from URL and cast to int
return ['id' => $id, 'type' => gettype($id)]; // "integer"
}
#[GET('/{slug}/')]
public function showBySlug(string $slug): array
{
// $slug extracted as string
return ['slug' => $slug];
}
#[GET('/posts/{postId}/comments/{commentId}/')]
public function showComment(int $postId, int $commentId): array
{
// Multiple parameters, all type-cast
return compact('postId', 'commentId');
}
Supported types:
int→\d+pattern, cast to integerfloat→\d+\.?\d*pattern, cast to floatstring→[^/]+pattern, used as-isbool→[01]|true|falsepattern, cast to boolean
Return Value Conversion
Controllers can return any type - the converter registry handles transformation:
Built-in Conversions
// Array → JSON response
#[GET('/')]
public function index(): array
{
return ['users' => [...]]; // Becomes application/json
}
// String → text/plain response
#[GET('/health/')]
public function health(): string
{
return "OK"; // Becomes text/plain
}
// ResponseInterface → used directly
#[GET('/download/')]
public function download(): ResponseInterface
{
return $this->redirect('/files/document.pdf');
}
Custom Converters
Register converters for your domain objects:
// bootstrap.php
$registry = Mini::$mini->get(ConverterRegistryInterface::class);
$registry->register(function(User $user): ResponseInterface {
return new Response(
json_encode($user->toArray()),
['Content-Type' => 'application/json'],
200
);
});
Then return domain objects directly:
#[GET('/{id}/')]
public function show(int $id): User
{
return table(User::class)->find($id); // Converted to JSON automatically
}
Helper Methods
AbstractController provides response helpers when you need explicit control:
// JSON response
protected function json(mixed $data, int $status = 200, array $headers = []): ResponseInterface
// HTML response
protected function html(string $body, int $status = 200, array $headers = []): ResponseInterface
// Plain text response
protected function text(string $body, int $status = 200, array $headers = []): ResponseInterface
// Empty response (204 No Content)
protected function empty(int $status = 204, array $headers = []): ResponseInterface
// Redirect
protected function redirect(string $url, int $status = 302): ResponseInterface
// Content negotiation (tries HTML view, falls back to JSON)
protected function respond(mixed $data, int $status = 200, array $headers = []): ResponseInterface
Example Usage
#[POST('/')]
public function create(): ResponseInterface
{
db()->exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
[$_POST['name'], $_POST['email']]
);
return $this->json(['id' => db()->lastInsertId()], 201);
}
#[GET('/download/')]
public function download(): ResponseInterface
{
return $this->redirect('/files/document.pdf');
}
Content Negotiation
The respond() helper checks the Accept header and serves HTML or JSON:
#[GET('/{id}/')]
public function show(int $id): ResponseInterface
{
$user = db()->query("SELECT * FROM users WHERE id = ?", [$id])->fetch();
// If client accepts HTML and _views/users/show.php exists → HTML
// Otherwise → JSON
return $this->respond($user);
}
View mapping:
UserController::index()→_views/users/index.phpUserController::show()→_views/users/show.phpUserController::edit()→_views/users/edit.php
Trailing Slash Handling
Routes enforce trailing slash consistency with 301 redirects:
#[GET('/users/')] // Expects trailing slash
public function index() { }
// GET /users → 301 redirect to /users/
// GET /users/ → 200 OK (matches)
#[GET('/posts')] // No trailing slash
public function posts() { }
// GET /posts/ → 301 redirect to /posts
// GET /posts → 200 OK (matches)
Root path / is special - never redirected.
Manual Route Registration (Alternative)
You can register routes manually instead of using attributes:
class UserController extends AbstractController
{
public function __construct()
{
parent::__construct();
// Manual registration
$this->router->get('/', $this->index(...));
$this->router->get('/{id}/', $this->show(...));
$this->router->post('/', $this->create(...));
$this->router->put('/{id}/', $this->update(...));
$this->router->delete('/{id}/', $this->delete(...));
}
public function index(): array { return ['users' => []]; }
public function show(int $id): array { return ['id' => $id]; }
public function create(): array { return ['message' => 'Created']; }
public function update(int $id): array { return ['message' => 'Updated']; }
public function delete(int $id): ResponseInterface { return $this->empty(204); }
}
Architecture
Request Flow
- Mini router dispatches to controller
- AbstractController::handle() receives PSR-7 ServerRequest
- Router::match() finds matching route and extracts URL parameters
- AbstractController enriches request with parameters as attributes
- ConverterHandler invokes controller method with parameters
- Converter registry transforms return value to ResponseInterface
Components
Router (src/Controller/Router.php)
- Pure routing logic (no PSR-15 interfaces)
match()returns['handler' => \Closure, 'params' => array]- Type-aware parameter extraction from URL patterns
AbstractController (src/Controller/AbstractController.php)
- Implements PSR-15 RequestHandlerInterface
- Orchestrates routing, parameter injection, and response conversion
- Provides response helper methods
ConverterHandler (src/Controller/ConverterHandler.php)
- Extracts parameters from request attributes
- Invokes controller methods via closures
- Converts return values using converter registry
Route Attributes (src/Controller/Attributes/)
- Declarative routing via PHP attributes
#[GET],#[POST],#[PUT],#[PATCH],#[DELETE]- Generic
#[Route]for custom methods
Common Patterns
REST API Controller
class PostController extends AbstractController
{
public function __construct()
{
parent::__construct();
$this->router->importRoutesFromAttributes($this);
}
#[GET('/')]
public function index(): array
{
return iterator_to_array(Post::query()->limit(100));
}
#[GET('/{id}/')]
public function show(int $id): Post
{
return Post::find($id) ?? throw new \mini\Exceptions\NotFoundException();
}
#[POST('/')]
public function create(): array
{
$post = new Post($_POST);
$post->save();
return ['id' => $post->id];
}
#[PUT('/{id}/')]
public function update(int $id): Post
{
$post = Post::find($id) ?? throw new \mini\Exceptions\NotFoundException();
$post->fill($_POST);
$post->save();
return $post;
}
#[DELETE('/{id}/')]
public function delete(int $id): ResponseInterface
{
$post = Post::find($id) ?? throw new \mini\Exceptions\NotFoundException();
$post->delete();
return $this->empty(204);
}
}
Authentication Guard
class AdminController extends AbstractController
{
public function __construct()
{
parent::__construct();
// Guard all routes - runs before routing
if (!auth()->check() || !auth()->user()->isAdmin()) {
throw new AccessDeniedException();
}
$this->router->importRoutesFromAttributes($this);
}
#[GET('/')]
public function dashboard(): array
{
return ['stats' => $this->getStats()];
}
}
Nested Resources
class CommentController extends AbstractController
{
public function __construct()
{
parent::__construct();
$this->router->importRoutesFromAttributes($this);
}
#[GET('/posts/{postId}/comments/')]
public function index(int $postId): array
{
return db()->query(
"SELECT * FROM comments WHERE post_id = ?",
[$postId]
)->fetchAll();
}
#[POST('/posts/{postId}/comments/')]
public function create(int $postId): array
{
db()->exec(
"INSERT INTO comments (post_id, content) VALUES (?, ?)",
[$postId, $_POST['content']]
);
return ['id' => db()->lastInsertId()];
}
}
Error Handling
Throw HTTP exceptions - they're automatically converted to responses:
use mini\Http\{NotFoundException, AccessDeniedException, BadRequestException};
#[GET('/{id}/')]
public function show(int $id): array
{
$user = db()->query("SELECT * FROM users WHERE id = ?", [$id])->fetch();
if (!$user) {
throw new NotFoundException("User not found");
}
if (!auth()->canView($user)) {
throw new AccessDeniedException();
}
return $user;
}
See Also
- Converter System - src/Converter/README.md
- Database - src/Database/README.md
- Authentication - src/Auth/README.md