Controllers & Routing
Define routes with PHP attributes and create RESTful controllers
Creating a Controller
Controllers in Larafony extend the Controller base class and use the #[Route] attribute
to define routes directly on methods.
<?php
declare(strict_types=1);
namespace App\Controllers;
use Larafony\Framework\Routing\Advanced\Attributes\Route;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class HomeController extends Controller
{
#[Route('/', 'GET')]
public function index(ServerRequestInterface $request): ResponseInterface
{
return $this->render('home', [
'title' => 'Welcome to Larafony'
]);
}
}
src/Controllers directory.
No need to manually register routes!
Route Attributes
The #[Route] attribute accepts a path and HTTP method:
#[Route('/notes', 'GET')]
public function index(): ResponseInterface
{
// GET /notes
}
#[Route('/notes', 'POST')]
public function store(): ResponseInterface
{
// POST /notes
}
#[Route('/notes/{id}', 'GET')]
public function show(int $id): ResponseInterface
{
// GET /notes/123
}
#[Route('/notes/{id}', 'PUT')]
public function update(int $id): ResponseInterface
{
// PUT /notes/123
}
#[Route('/notes/{id}', 'DELETE')]
public function destroy(int $id): ResponseInterface
{
// DELETE /notes/123
}
Route Parameters
Capture route parameters using curly braces in the path:
#[Route('/users/{id}', 'GET')]
public function show(int $id): ResponseInterface
{
$user = User::query()->find($id);
return $this->render('users.show', ['user' => $user]);
}
#[Route('/posts/{slug}', 'GET')]
public function showBySlug(string $slug): ResponseInterface
{
$post = Post::query()
->where('slug', '=', $slug)
->first();
return $this->render('posts.show', ['post' => $post]);
}
Model Binding (Auto-Binding)
One of Larafony's most powerful features is automatic model binding. Using the #[RouteParam] attribute,
you can automatically resolve route parameters into model instances. The framework will fetch the model from the database
and inject it directly into your controller method.
Basic Model Binding
Use #[RouteParam] to configure model binding:
use App\Models\Note;
use Larafony\Framework\Routing\Advanced\Attributes\{Route, RouteParam};
#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
// $note is automatically loaded from database
// using the route parameter value
return $this->render('notes.show', ['note' => $note]);
}
/notes/123, Larafony:
- Validates the parameter matches the pattern (
\d+) - Calls
Note::findForRoute(123)to load the model - Injects the loaded model into your controller method
- Returns 404 automatically if the model is not found!
Model findForRoute Method
Your model must implement the findForRoute() method for binding to work:
use Larafony\Framework\Database\ORM\Model;
class Note extends Model
{
public string $table { get => 'notes'; }
public static function findForRoute(int|string $id): ?static
{
return static::query()->find($id);
}
}
Before and After Comparison
See how model binding simplifies your code:
// ❌ Without Model Binding (manual approach)
#[Route('/notes/<id:\d+>', 'GET')]
public function show(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getAttribute('routeParams');
$note = Note::query()->find($params['id']);
if (!$note) {
// Handle 404
return new Response(404, [], 'Note not found');
}
return $this->render('notes.show', ['note' => $note]);
}
// ✅ With Model Binding (automatic approach)
#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
// $note is already loaded, 404 handled automatically!
return $this->render('notes.show', ['note' => $note]);
}
Custom Resolution Methods
Use findMethod parameter to specify a custom resolution method (e.g., find by slug instead of ID):
// In your model
class Post extends Model
{
public static function findBySlug(string $slug): ?static
{
return static::query()->where('slug', '=', $slug)->first();
}
}
// In your controller
#[Route('/posts/<slug:[a-z0-9-]+>', 'GET')]
#[RouteParam(name: 'slug', bind: Post::class, findMethod: 'findBySlug')]
public function show(ServerRequestInterface $request, Post $post): ResponseInterface
{
// $post resolved via findBySlug() instead of default findForRoute()
// Pattern validation ([a-z0-9-]+) and binding in the same attribute!
return $this->render('posts.show', ['post' => $post]);
}
Multiple Model Bindings
You can bind multiple models in a single route:
use App\Models\User;
use App\Models\Note;
#[Route('/users/<user>/notes/<note>', 'GET')]
#[RouteParam(name: 'user', pattern: '\d+', bind: User::class)]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function showUserNote(
ServerRequestInterface $request,
User $user,
Note $note
): ResponseInterface {
// Both models are automatically loaded!
// $user is loaded from <user> parameter
// $note is loaded from <note> parameter
return $this->render('users.notes.show', [
'user' => $user,
'note' => $note
]);
}
Combining Model Binding with DTOs
Mix model binding with DTO injection for powerful update operations:
use App\Models\Note;
use App\DTOs\UpdateNoteDto;
#[Route('/notes/<note>', 'PUT')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function update(
ServerRequestInterface $request,
Note $note,
UpdateNoteDto $dto
): ResponseInterface {
// $note is auto-bound from route parameter
// $dto is auto-created and validated from request body
$note->title = $dto->title;
$note->content = $dto->content;
$note->save();
return $this->redirect("/notes/{$note->id}");
}
Working with Relationships
Auto-bound models work seamlessly with relationships:
#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
// Access relationships directly
$author = $note->user;
$tags = $note->tags;
$comments = $note->comments;
return $this->render('notes.show', [
'note' => $note,
'author' => $author,
'tags' => $tags,
'comments' => $comments
]);
}
Complete CRUD with Model Binding
Here's a complete CRUD controller using model binding:
use App\Models\Note;
use App\DTOs\{CreateNoteDto, UpdateNoteDto};
use Larafony\Framework\Routing\Advanced\Attributes\{Route, RouteParam};
class NoteController extends Controller
{
// List all notes (no binding needed)
#[Route('/notes', 'GET')]
public function index(ServerRequestInterface $request): ResponseInterface
{
$notes = Note::query()->get();
return $this->render('notes.index', ['notes' => $notes]);
}
// Show single note (model binding)
#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
return $this->render('notes.show', ['note' => $note]);
}
// Show edit form (model binding)
#[Route('/notes/<note>/edit', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function edit(ServerRequestInterface $request, Note $note): ResponseInterface
{
return $this->render('notes.edit', ['note' => $note]);
}
// Update note (model binding + DTO)
#[Route('/notes/<note>', 'PUT')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function update(
ServerRequestInterface $request,
Note $note,
UpdateNoteDto $dto
): ResponseInterface {
$note->title = $dto->title;
$note->content = $dto->content;
$note->save();
return $this->redirect("/notes/{$note->id}");
}
// Delete note (model binding)
#[Route('/notes/<note>', 'DELETE')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function destroy(ServerRequestInterface $request, Note $note): ResponseInterface
{
$note->delete();
return $this->redirect('/notes');
}
}
<param> or <param:pattern>
syntax for route parameters, not {param}. This allows inline regex patterns like
<id:\d+> or <slug:[a-z0-9-]+>.
DTO Injection
Type-hint a DTO class to automatically validate and hydrate request data:
use App\DTOs\CreateNoteDto;
#[Route('/notes', 'POST')]
public function store(CreateNoteDto $dto): ResponseInterface
{
// $dto is automatically created from request
// and validated based on attributes
$note = new Note()->fill([
'title' => $dto->title,
'content' => $dto->content,
]);
$note->save();
return $this->redirect('/notes');
}
See the DTO Validation guide for more details.
Response Helpers
The Controller base class provides helpful methods for creating responses:
Rendering Views
// Render a Blade template
return $this->render('notes.index', [
'notes' => $notes
]);
JSON Responses
// Return JSON
return $this->json([
'success' => true,
'data' => $notes
]);
// With status code
return $this->json(['error' => 'Not found'], 404);
Redirects
// Redirect to a URL
return $this->redirect('/notes');
// Redirect with status code
return $this->redirect('/login', 302);
Complete CRUD Example
Here's a complete RESTful controller for managing notes:
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\Note;
use App\DTOs\CreateNoteDto;
use App\DTOs\UpdateNoteDto;
use Larafony\Framework\Routing\Advanced\Attributes\Route;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;
class NoteController extends Controller
{
#[Route('/notes', 'GET')]
public function index(): ResponseInterface
{
$notes = Note::query()->get();
return $this->render('notes.index', ['notes' => $notes]);
}
#[Route('/notes/create', 'GET')]
public function create(): ResponseInterface
{
return $this->render('notes.create');
}
#[Route('/notes', 'POST')]
public function store(CreateNoteDto $dto): ResponseInterface
{
$note = new Note()->fill([
'title' => $dto->title,
'content' => $dto->content,
'user_id' => 1, // Get from auth
]);
$note->save();
return $this->redirect('/notes');
}
#[Route('/notes/{id}', 'GET')]
public function show(Note $note): ResponseInterface
{
return $this->render('notes.show', ['note' => $note]);
}
#[Route('/notes/{id}/edit', 'GET')]
public function edit(Note $note): ResponseInterface
{
return $this->render('notes.edit', ['note' => $note]);
}
#[Route('/notes/{id}', 'PUT')]
public function update(Note $note, UpdateNoteDto $dto): ResponseInterface
{
$note->title = $dto->title;
$note->content = $dto->content;
$note->save();
return $this->redirect("/notes/{$note->id}");
}
#[Route('/notes/{id}', 'DELETE')]
public function destroy(Note $note): ResponseInterface
{
$note->delete();
return $this->redirect('/notes');
}
}
API Controllers
Create JSON APIs by returning JSON responses:
<?php
namespace App\Controllers;
use App\Models\Note;
use Larafony\Framework\Routing\Advanced\Attributes\Route;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;
class ApiNoteController extends Controller
{
#[Route('/api/notes', 'GET')]
public function index(): ResponseInterface
{
$notes = Note::query()->get();
return $this->json([
'success' => true,
'data' => $notes
]);
}
#[Route('/api/notes/{id}', 'GET')]
public function show(Note $note): ResponseInterface
{
return $this->json([
'success' => true,
'data' => $note
]);
}
}